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