forked from lunanting/cangku
232 lines
8.1 KiB
JavaScript
232 lines
8.1 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const authMiddleware = require('../middleware/auth');
|
|
const {
|
|
buildPublicUrl,
|
|
getDefaultS3Config,
|
|
getObjectFromDefaultS3,
|
|
} = require('../services/objectStorage');
|
|
const {
|
|
compareVersions,
|
|
getDesktopReleaseById,
|
|
getLatestDesktopRelease,
|
|
mapRelease,
|
|
normalizeVersion,
|
|
} = require('../services/desktopReleaseService');
|
|
const {
|
|
DesktopSessionError,
|
|
createDesktopSession,
|
|
createSsoTicket,
|
|
exchangeSsoTicket,
|
|
getDesktopBootstrap,
|
|
revokeDesktopSession,
|
|
} = require('../services/desktopSessionService');
|
|
|
|
const router = express.Router();
|
|
|
|
function publicRelease(release) {
|
|
const mapped = mapRelease(release);
|
|
if (!mapped) return null;
|
|
return {
|
|
id: mapped.id,
|
|
version: mapped.version,
|
|
releaseNotes: mapped.releaseNotes,
|
|
platform: mapped.platform,
|
|
architecture: mapped.architecture,
|
|
fileName: mapped.fileName,
|
|
fileSize: mapped.fileSize,
|
|
sha256: mapped.sha256,
|
|
publishedAt: mapped.publishedAt,
|
|
downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`,
|
|
};
|
|
}
|
|
|
|
function requestUserId(req) {
|
|
return req.user?.sub ?? req.user?.id;
|
|
}
|
|
|
|
function sendDesktopSessionError(res, error, fallbackMessage) {
|
|
if (error instanceof DesktopSessionError) {
|
|
return res.status(error.status).json({
|
|
success: false,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
}
|
|
console.error(fallbackMessage, error);
|
|
return res.status(500).json({ success: false, error: fallbackMessage });
|
|
}
|
|
|
|
function safeScriptJson(value) {
|
|
return JSON.stringify(value)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/>/g, '\\u003e')
|
|
.replace(/&/g, '\\u0026')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029');
|
|
}
|
|
|
|
function sendSsoExchangeHtml(res, result) {
|
|
const nonce = crypto.randomBytes(18).toString('base64');
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'`
|
|
);
|
|
res.type('html').send(`<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>正在进入总控</title>
|
|
<style nonce="${nonce}">html,body{height:100%;margin:0;background:#f7f8fa;color:#8c8c8c;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{display:grid;place-items:center}</style>
|
|
</head>
|
|
<body><span>正在进入总控...</span>
|
|
<script nonce="${nonce}">
|
|
try {
|
|
localStorage.setItem('token', ${safeScriptJson(result.accessToken)});
|
|
localStorage.setItem('auth_user', JSON.stringify(${safeScriptJson(result.user)}));
|
|
sessionStorage.setItem('desktop_embed', '1');
|
|
location.replace(${safeScriptJson(result.redirectPath)});
|
|
} catch (error) {
|
|
document.body.textContent = '总控登录状态写入失败,请返回桌面客户端重试';
|
|
}
|
|
</script></body></html>`);
|
|
}
|
|
|
|
router.post('/session', authMiddleware, async (req, res) => {
|
|
try {
|
|
if (req.user?.tokenType) {
|
|
throw new DesktopSessionError('请使用用户登录令牌创建桌面会话', 401, 'DESKTOP_USER_TOKEN_REQUIRED');
|
|
}
|
|
const data = await createDesktopSession({
|
|
userId: requestUserId(req),
|
|
payload: req.body || {},
|
|
});
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.json({ success: true, data });
|
|
} catch (error) {
|
|
return sendDesktopSessionError(res, error, '创建桌面会话失败');
|
|
}
|
|
});
|
|
|
|
router.get('/bootstrap', authMiddleware, async (req, res) => {
|
|
try {
|
|
const data = await getDesktopBootstrap({
|
|
claims: req.user,
|
|
clientId: req.query.clientId,
|
|
});
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.json({ success: true, data });
|
|
} catch (error) {
|
|
return sendDesktopSessionError(res, error, '读取桌面启动配置失败');
|
|
}
|
|
});
|
|
|
|
router.post('/sso-ticket', authMiddleware, async (req, res) => {
|
|
try {
|
|
const data = await createSsoTicket({
|
|
claims: req.user,
|
|
clientId: req.body?.clientId,
|
|
redirectPath: req.body?.redirectPath,
|
|
});
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.json({ success: true, data });
|
|
} catch (error) {
|
|
return sendDesktopSessionError(res, error, '创建总控登录票据失败');
|
|
}
|
|
});
|
|
|
|
router.get('/sso/exchange', async (req, res) => {
|
|
try {
|
|
const result = await exchangeSsoTicket(req.query.ticket);
|
|
return sendSsoExchangeHtml(res, result);
|
|
} catch (error) {
|
|
if (error instanceof DesktopSessionError) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(error.status).type('html').send('<!doctype html><meta charset="utf-8"><title>登录失效</title><body>登录票据无效或已过期,请返回桌面客户端重试。</body>');
|
|
}
|
|
console.error('交换总控登录票据失败', error);
|
|
return res.status(500).type('html').send('<!doctype html><meta charset="utf-8"><title>登录失败</title><body>总控登录失败,请稍后重试。</body>');
|
|
}
|
|
});
|
|
|
|
router.post('/session/revoke', authMiddleware, async (req, res) => {
|
|
try {
|
|
const data = await revokeDesktopSession(req.user);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.json({ success: true, data });
|
|
} catch (error) {
|
|
return sendDesktopSessionError(res, error, '退出桌面会话失败');
|
|
}
|
|
});
|
|
|
|
router.get('/latest', async (req, res) => {
|
|
try {
|
|
const platform = req.query.platform || 'win32';
|
|
const architecture = req.query.architecture || '';
|
|
const release = await getLatestDesktopRelease(platform, architecture);
|
|
const currentVersion = normalizeVersion(req.query.currentVersion);
|
|
const data = publicRelease(release);
|
|
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
|
res.json({
|
|
success: true,
|
|
data: data ? {
|
|
...data,
|
|
currentVersion: currentVersion || null,
|
|
updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null,
|
|
} : null,
|
|
});
|
|
} catch (error) {
|
|
console.error('获取桌面客户端最新版本失败:', error);
|
|
res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' });
|
|
}
|
|
});
|
|
|
|
router.get('/releases/:id/download', async (req, res) => {
|
|
try {
|
|
const release = await getDesktopReleaseById(Number(req.params.id));
|
|
if (!release) {
|
|
return res.status(404).json({ success: false, error: '安装包不存在或已下线' });
|
|
}
|
|
|
|
const storageConfig = await getDefaultS3Config();
|
|
if (storageConfig?.customDomain) {
|
|
return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey));
|
|
}
|
|
|
|
const object = await getObjectFromDefaultS3(release.objectKey, {
|
|
range: req.headers.range,
|
|
});
|
|
const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+');
|
|
res.status(object.ContentRange ? 206 : 200);
|
|
const fallbackContentType = release.platform === 'darwin'
|
|
? 'application/x-apple-diskimage'
|
|
: 'application/vnd.microsoft.portable-executable';
|
|
res.setHeader('Content-Type', object.ContentType || fallbackContentType);
|
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
|
|
res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes');
|
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength));
|
|
if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange);
|
|
if (object.ETag) res.setHeader('ETag', object.ETag);
|
|
|
|
object.Body.on('error', (error) => {
|
|
console.error('桌面客户端安装包下载流中断:', error);
|
|
if (!res.headersSent) res.status(502).end();
|
|
else res.destroy(error);
|
|
});
|
|
object.Body.pipe(res);
|
|
} catch (error) {
|
|
console.error('下载桌面客户端安装包失败:', error);
|
|
if (!res.headersSent) {
|
|
res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' });
|
|
}
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|