Files
memind/mindspace-html-download-links.mjs
T
john ea6c49f046 feat(mindspace): 全部页面管理、级联删除与页面同步去重
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

158 lines
5.2 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { buildPublicUrl } from './user-publish.mjs';
export const DOWNLOADABLE_FILE_PATTERN = /\.(?:docx?|pdf|xlsx?|pptx?|zip|csv|txt|md)$/i;
const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi;
export function isRelativeDownloadReference(value) {
const trimmed = String(value ?? '').trim();
if (!trimmed) return false;
const pathPart = trimmed.split('#')[0].split('?')[0];
if (/^([a-z][a-z0-9+.-]*:|\/|#|data:|mailto:|javascript:)/i.test(pathPart)) return false;
return DOWNLOADABLE_FILE_PATTERN.test(pathPart);
}
export function splitReferenceParts(relativeRef) {
const value = String(relativeRef ?? '');
const match = value.match(/^([^#?]+)(.*)$/);
return {
pathPart: match?.[1] ?? value,
suffix: match?.[2] ?? '',
};
}
export function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) {
const { pathPart } = splitReferenceParts(relativeRef);
const ref = String(pathPart ?? '').replace(/^\/+/, '');
if (!ref) return ref;
if (/^(?:public|oa|private)\//.test(ref)) return ref;
const htmlPath = String(htmlRelativePath ?? 'public/index.html').replace(/^\/+/, '');
const htmlDir = path.posix.dirname(htmlPath);
if (!htmlDir || htmlDir === '.') return `public/${ref}`;
return path.posix.join(htmlDir, ref).replace(/\\/g, '/');
}
export function buildWorkspaceDownloadLinkIndex(assets = []) {
const byBasename = new Map();
const byPath = new Map();
for (const asset of assets) {
const id = asset.id;
const filename = String(asset.original_filename ?? asset.originalFilename ?? '').replace(/\\/g, '/');
if (!id || !filename) continue;
byPath.set(filename, id);
byBasename.set(path.posix.basename(filename), id);
if (filename.startsWith('public/')) {
byPath.set(filename.slice('public/'.length), id);
}
}
return { byBasename, byPath };
}
export function lookupAssetIdForWorkspacePath(index, workspaceRelativePath) {
const normalized = String(workspaceRelativePath ?? '').replace(/\\/g, '/');
if (!normalized || !index) return null;
return (
index.byPath.get(normalized)
?? index.byPath.get(`public/${normalized}`)
?? index.byBasename.get(path.posix.basename(normalized))
?? null
);
}
export function buildAssetDownloadUrl(assetId) {
return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`;
}
export function buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, workspaceRelativePath) {
const clean = String(workspaceRelativePath ?? '').replace(/^\/+/, '');
return buildPublicUrl(publicBaseUrl, publishKey, clean);
}
function workspaceFileExists(publishDir, workspaceRelativePath) {
if (!publishDir || !workspaceRelativePath) return false;
const absolutePath = path.join(publishDir, ...String(workspaceRelativePath).split('/'));
try {
return fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile();
} catch {
return false;
}
}
export function resolveDownloadTargetUrl({
relativeRef,
htmlRelativePath = 'public/index.html',
publishDir = null,
linkIndex = null,
publicBaseUrl = '',
publishKey = null,
preferAssetDownload = true,
}) {
const { suffix } = splitReferenceParts(relativeRef);
const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef);
const assetId = lookupAssetIdForWorkspacePath(linkIndex, workspacePath);
const publicCandidates = [
workspacePath,
path.posix.join('public', path.posix.basename(workspacePath)),
];
if (preferAssetDownload && assetId) {
return `${buildAssetDownloadUrl(assetId)}${suffix}`;
}
if (publishDir && publishKey) {
for (const candidate of publicCandidates) {
if (workspaceFileExists(publishDir, candidate)) {
return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`;
}
}
}
if (assetId) {
return `${buildAssetDownloadUrl(assetId)}${suffix}`;
}
return null;
}
export function rewriteRelativeDownloadLinks(html, options = {}) {
const source = String(html ?? '');
if (!source) return { html: source, count: 0 };
let count = 0;
const rewritten = source.replace(URL_ATTR_PATTERN, (full, prefix, url, suffix) => {
if (!isRelativeDownloadReference(url)) return full;
const resolved = resolveDownloadTargetUrl({ ...options, relativeRef: url });
if (!resolved) return full;
count += 1;
return `${prefix}${resolved}${suffix}`;
});
return { html: rewritten, count };
}
export async function loadWorkspaceDownloadLinkIndex(pool, userId) {
const [rows] = await pool.query(
`SELECT id, original_filename
FROM h5_assets
WHERE user_id = ? AND status <> 'deleted'`,
[userId],
);
return buildWorkspaceDownloadLinkIndex(rows);
}
export async function prepareHtmlDownloadLinks(pool, userId, html, options = {}) {
const linkIndex = options.linkIndex ?? (await loadWorkspaceDownloadLinkIndex(pool, userId));
return rewriteRelativeDownloadLinks(html, { ...options, linkIndex });
}
export const downloadLinkInternals = {
isRelativeDownloadReference,
resolveWorkspaceRelativeFilePath,
buildWorkspaceDownloadLinkIndex,
lookupAssetIdForWorkspacePath,
resolveDownloadTargetUrl,
rewriteRelativeDownloadLinks,
};