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 resolveSiblingDocxPath(htmlRelativePath) { const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, ''); if (!htmlPath.toLowerCase().endsWith('.html')) return null; return htmlPath.replace(/\.html$/i, '.docx'); } function resolveExistingSiblingDocxPath(htmlRelativePath, publishDir) { const sibling = resolveSiblingDocxPath(htmlRelativePath); if (!sibling || !publishDir) return null; return workspaceFileExists(publishDir, sibling) ? sibling : null; } export function inferWorkspaceHtmlRelativePath({ snapshotRelativePath = null, pageTitle = null, htmlContent = null, publishDir = null, } = {}) { const fromSnapshot = String(snapshotRelativePath ?? '').trim(); if (fromSnapshot) { const normalized = fromSnapshot.replace(/^\/+/, ''); if (normalized.toLowerCase().startsWith('public/')) return normalized; if (normalized.toLowerCase().endsWith('.html')) return `public/${path.posix.basename(normalized)}`; return normalized; } const publicDir = publishDir ? path.join(publishDir, 'public') : null; if (!publicDir || !fs.existsSync(publicDir) || !htmlContent) { return 'public/index.html'; } const normalizedContent = String(htmlContent); const title = String(pageTitle ?? '').trim(); for (const name of fs.readdirSync(publicDir)) { if (!name.toLowerCase().endsWith('.html')) continue; const absolutePath = path.join(publicDir, name); try { const diskContent = fs.readFileSync(absolutePath, 'utf8'); if (diskContent === normalizedContent) return `public/${name}`; if (title) { const titleMatch = diskContent.match(/]*>([^<]+)<\/title>/i); if (titleMatch?.[1]?.trim() === title) return `public/${name}`; } } catch { // ignore unreadable files } } return 'public/index.html'; } 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); } else if (!filename.includes('/')) { byPath.set(`public/${filename}`, 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; } } function collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir) { const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); const candidates = new Set([ workspacePath, path.posix.join('public', path.posix.basename(workspacePath)), ]); const siblingDocx = resolveSiblingDocxPath(htmlRelativePath); if (siblingDocx) { candidates.add(siblingDocx); candidates.add(path.posix.join('public', path.posix.basename(siblingDocx))); } return [...candidates]; } export function resolveDownloadTargetUrl({ relativeRef, htmlRelativePath = 'public/index.html', publishDir = null, linkIndex = null, publicBaseUrl = '', publishKey = null, preferAssetDownload = true, }) { const { suffix } = splitReferenceParts(relativeRef); const candidates = collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir); for (const candidate of candidates) { const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); if (preferAssetDownload && assetId) { return `${buildAssetDownloadUrl(assetId)}${suffix}`; } } if (publishDir && publishKey) { for (const candidate of candidates) { if (workspaceFileExists(publishDir, candidate)) { return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`; } } } for (const candidate of candidates) { const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); 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, resolveSiblingDocxPath, inferWorkspaceHtmlRelativePath, buildWorkspaceDownloadLinkIndex, lookupAssetIdForWorkspacePath, resolveDownloadTargetUrl, rewriteRelativeDownloadLinks, };