373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
|
import {
|
|
renderImageAssetViewerHtml,
|
|
wantsInlineImageViewer,
|
|
} from '../mindspace-asset-preview.mjs';
|
|
import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs';
|
|
import { verifyPublicAssetToken } from '../mindspace-public-asset-token.mjs';
|
|
|
|
function assertRouter(api) {
|
|
if (
|
|
!api ||
|
|
typeof api.get !== 'function' ||
|
|
typeof api.post !== 'function' ||
|
|
typeof api.delete !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'attachPortalMindSpaceAssetRoutes requires an Express-compatible router',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function attachPortalMindSpaceAssetRoutes(
|
|
api,
|
|
{
|
|
getMindSpacePublications = () => null,
|
|
getDatabasePool = () => null,
|
|
getMindSpaceAssets = () => null,
|
|
getMindSpacePages = () => null,
|
|
getMindSpaceAudit = () => null,
|
|
getInternalAgentSecret = () => '',
|
|
ensureMindSpaceEnabled = () => false,
|
|
resolveRequestOrigin = () => '',
|
|
sendData = (res, _req, data, status = 200) =>
|
|
res.status(status).json({ data }),
|
|
handleMindSpaceError = (_res, _req, error) => {
|
|
throw error;
|
|
},
|
|
normalizeWorkspacePath = normalizeWorkspaceRelativePath,
|
|
verifyAssetToken = verifyPublicAssetToken,
|
|
wantsImageViewer = wantsInlineImageViewer,
|
|
renderImageViewer = renderImageAssetViewerHtml,
|
|
readFile = fs.promises.readFile,
|
|
now = Date.now,
|
|
logger = console,
|
|
} = {},
|
|
) {
|
|
assertRouter(api);
|
|
|
|
api.get('/mindspace/v1/authorize-image', async (req, res) => {
|
|
if (!getMindSpacePublications()) {
|
|
return res.status(503).json({ error: 'Service unavailable' });
|
|
}
|
|
const assetId = String(req.query.asset_id ?? '');
|
|
if (!assetId) {
|
|
return res.status(400).json({ error: 'Missing asset_id parameter' });
|
|
}
|
|
try {
|
|
const [refs] = await getDatabasePool().query(
|
|
`SELECT pr.access_mode, pr.expires_at
|
|
FROM h5_publication_asset_refs refs
|
|
JOIN h5_publish_records pr ON refs.publication_id = pr.id
|
|
WHERE refs.asset_id = ? AND pr.status = 'online'
|
|
ORDER BY CASE
|
|
WHEN pr.access_mode = 'public' THEN 0
|
|
WHEN pr.access_mode = 'time_limited' THEN 1
|
|
ELSE 2
|
|
END,
|
|
pr.expires_at DESC
|
|
LIMIT 1`,
|
|
[assetId],
|
|
);
|
|
|
|
if (!refs[0]) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
const publication = refs[0];
|
|
if (publication.access_mode === 'public') {
|
|
res.set('Cache-Control', 'public, max-age=31536000, immutable');
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
|
|
if (
|
|
publication.access_mode === 'time_limited' &&
|
|
publication.expires_at &&
|
|
Number(publication.expires_at) > now()
|
|
) {
|
|
res.set('Cache-Control', 'public, max-age=60');
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
} catch (error) {
|
|
logger.error(
|
|
'[authorize-image]',
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
|
|
const assets = getMindSpaceAssets();
|
|
if (!assets || !ensureMindSpaceEnabled(res, req)) return;
|
|
try {
|
|
const assetId = req.params.assetId;
|
|
const currentUserId =
|
|
req.currentUser?.id ?? req.userSession?.userId ?? null;
|
|
let allowedByPublication = false;
|
|
let publicationAccessMode = null;
|
|
if (!currentUserId) {
|
|
const referrer = String(
|
|
req.get('referer') ?? req.get('referrer') ?? '',
|
|
);
|
|
const isPublicPageReferrer = (() => {
|
|
if (!referrer) return false;
|
|
try {
|
|
return new URL(
|
|
referrer,
|
|
resolveRequestOrigin(req) || 'http://localhost',
|
|
).pathname.includes('/public/');
|
|
} catch {
|
|
return referrer.includes('/public/');
|
|
}
|
|
})();
|
|
const [refs] = await getDatabasePool().query(
|
|
`SELECT pr.access_mode, pr.expires_at
|
|
FROM h5_publication_asset_refs refs
|
|
JOIN h5_publish_records pr ON refs.publication_id = pr.id
|
|
WHERE refs.asset_id = ? AND pr.status = 'online'
|
|
ORDER BY CASE
|
|
WHEN pr.access_mode = 'public' THEN 0
|
|
WHEN pr.access_mode = 'time_limited' THEN 1
|
|
ELSE 2
|
|
END,
|
|
pr.expires_at DESC
|
|
LIMIT 1`,
|
|
[assetId],
|
|
);
|
|
const publication = refs[0] ?? null;
|
|
if (publication?.access_mode === 'public') {
|
|
allowedByPublication = true;
|
|
publicationAccessMode = 'public';
|
|
} else if (
|
|
publication?.access_mode === 'time_limited' &&
|
|
publication.expires_at &&
|
|
Number(publication.expires_at) > now()
|
|
) {
|
|
allowedByPublication = true;
|
|
publicationAccessMode = 'time_limited';
|
|
}
|
|
if (
|
|
!allowedByPublication &&
|
|
verifyAssetToken(
|
|
assetId,
|
|
req.query.public_token,
|
|
getInternalAgentSecret(),
|
|
)
|
|
) {
|
|
allowedByPublication = true;
|
|
publicationAccessMode = 'signed-public-token';
|
|
}
|
|
if (!allowedByPublication && isPublicPageReferrer) {
|
|
allowedByPublication = true;
|
|
publicationAccessMode = 'public-page-referrer';
|
|
}
|
|
if (!allowedByPublication) {
|
|
return res.status(401).json({ message: '未授权,请重新登录' });
|
|
}
|
|
}
|
|
const { asset, path: assetPath } = currentUserId
|
|
? await assets.readAsset(currentUserId, assetId)
|
|
: await assets.readPublicAsset(assetId);
|
|
await getMindSpaceAudit()?.write({
|
|
userId: currentUserId,
|
|
action: 'asset.download',
|
|
objectType: 'asset',
|
|
objectId: assetId,
|
|
ip: req.ip,
|
|
riskLevel: asset.riskLevel,
|
|
detail: allowedByPublication
|
|
? { accessMode: publicationAccessMode }
|
|
: undefined,
|
|
});
|
|
res.type(asset.mimeType);
|
|
const inline =
|
|
req.query.inline === '1' ||
|
|
req.query.disposition === 'inline' ||
|
|
req.get('sec-fetch-dest') === 'iframe';
|
|
if (
|
|
inline &&
|
|
asset.mimeType.startsWith('image/') &&
|
|
wantsImageViewer(req)
|
|
) {
|
|
const downloadUrl =
|
|
`/api/mindspace/v1/assets/${encodeURIComponent(assetId)}` +
|
|
'/download?inline=1';
|
|
const html = renderImageViewer({ asset, downloadUrl });
|
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
res.set('Cache-Control', 'private, no-store');
|
|
res.setHeader('X-Request-Id', req.requestId);
|
|
return res.send(html);
|
|
}
|
|
res.set(
|
|
'Content-Disposition',
|
|
inline
|
|
? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}`
|
|
: `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`,
|
|
);
|
|
res.setHeader('X-Request-Id', req.requestId);
|
|
return res.sendFile(assetPath);
|
|
} catch (error) {
|
|
await getMindSpaceAudit()?.write({
|
|
userId: req.currentUser?.id ?? null,
|
|
action: 'asset.download',
|
|
objectType: 'asset',
|
|
objectId: req.params.assetId,
|
|
ip: req.ip,
|
|
result: 'denied',
|
|
riskLevel:
|
|
error?.code === 'security_risk_blocked' ? 'high' : null,
|
|
});
|
|
return handleMindSpaceError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => {
|
|
const assets = getMindSpaceAssets();
|
|
if (!assets || !ensureMindSpaceEnabled(res, req)) return;
|
|
try {
|
|
const svg = await assets.renderAssetThumbnail(
|
|
req.currentUser.id,
|
|
req.params.assetId,
|
|
);
|
|
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
|
res.set('Cache-Control', 'private, max-age=300');
|
|
res.setHeader('X-Request-Id', req.requestId);
|
|
return res.send(svg);
|
|
} catch (error) {
|
|
return handleMindSpaceError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => {
|
|
const assets = getMindSpaceAssets();
|
|
if (!assets || !ensureMindSpaceEnabled(res, req)) return;
|
|
try {
|
|
const html = await assets.renderAssetPreview(
|
|
req.currentUser.id,
|
|
req.params.assetId,
|
|
);
|
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
res.set('Cache-Control', 'private, no-store');
|
|
res.setHeader('X-Request-Id', req.requestId);
|
|
return res.send(html);
|
|
} catch (error) {
|
|
return handleMindSpaceError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/mindspace/v1/pages/from-asset', async (req, res) => {
|
|
const pages = getMindSpacePages();
|
|
const assets = getMindSpaceAssets();
|
|
if (!pages || !assets) {
|
|
return res.status(503).json({ message: 'MindSpace 未启用' });
|
|
}
|
|
try {
|
|
const assetId = req.body?.asset_id;
|
|
const existingPage = await pages.findPageBySourceAsset(
|
|
req.currentUser.id,
|
|
assetId,
|
|
);
|
|
if (existingPage) {
|
|
return sendData(res, req, {
|
|
kind: 'page',
|
|
categoryCode: existingPage.categoryCode ?? 'draft',
|
|
page: existingPage,
|
|
});
|
|
}
|
|
const { asset, path: assetPath } = await assets.readAsset(
|
|
req.currentUser.id,
|
|
assetId,
|
|
);
|
|
if (asset.mimeType === 'text/html') {
|
|
const relativePath = normalizeWorkspacePath(
|
|
String(
|
|
asset.originalFilename ?? asset.original_filename ?? '',
|
|
).includes('/')
|
|
? asset.originalFilename ?? asset.original_filename
|
|
: `public/${asset.originalFilename ?? asset.original_filename ?? ''}`,
|
|
);
|
|
const existingByPath = relativePath
|
|
? await pages
|
|
.findPageByRelativePath(req.currentUser.id, relativePath)
|
|
.catch(() => null)
|
|
: null;
|
|
if (existingByPath) {
|
|
return sendData(res, req, {
|
|
kind: 'page',
|
|
categoryCode: existingByPath.categoryCode ?? 'draft',
|
|
page: existingByPath,
|
|
});
|
|
}
|
|
}
|
|
const content = await readFile(assetPath, 'utf8');
|
|
const contentFormat =
|
|
asset.mimeType === 'text/html' ? 'html' : 'markdown';
|
|
const htmlRelativePath =
|
|
contentFormat === 'html'
|
|
? normalizeWorkspacePath(
|
|
String(
|
|
asset.originalFilename ?? asset.original_filename ?? '',
|
|
).includes('/')
|
|
? asset.originalFilename ?? asset.original_filename
|
|
: `public/${asset.originalFilename ?? asset.original_filename ?? ''}`,
|
|
)
|
|
: null;
|
|
const page = await pages.createFromChat(
|
|
req.currentUser.id,
|
|
{
|
|
title: req.body?.title || asset.displayName,
|
|
summary: req.body?.summary,
|
|
content,
|
|
contentFormat,
|
|
templateId: req.body?.template_id,
|
|
categoryCode: 'draft',
|
|
},
|
|
{
|
|
assetId: asset.id,
|
|
snapshot: {
|
|
source_asset_id: asset.id,
|
|
source_category: asset.categoryCode,
|
|
content_mode: contentFormat,
|
|
...(htmlRelativePath
|
|
? { relative_path: htmlRelativePath }
|
|
: {}),
|
|
},
|
|
},
|
|
);
|
|
return res.status(201).json({
|
|
data: {
|
|
kind: 'page',
|
|
categoryCode: 'draft',
|
|
page,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return handleMindSpaceError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/mindspace/v1/assets/:assetId', async (req, res) => {
|
|
const assets = getMindSpaceAssets();
|
|
if (!assets || !ensureMindSpaceEnabled(res, req)) return;
|
|
try {
|
|
const result = await assets.deleteAsset(
|
|
req.currentUser.id,
|
|
req.params.assetId,
|
|
);
|
|
await getMindSpaceAudit()?.write({
|
|
userId: req.currentUser.id,
|
|
action: 'asset.delete',
|
|
objectType: 'asset',
|
|
objectId: req.params.assetId,
|
|
ip: req.ip,
|
|
});
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return handleMindSpaceError(res, req, error);
|
|
}
|
|
});
|
|
}
|