ea6c49f046
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
5.2 KiB
JavaScript
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,
|
|
};
|