Files
memind/server/portal-mindspace-asset-routes.mjs
T

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);
}
});
}