Files
memind/mindspace-public-links.mjs
john b273bb7aa2 fix: gate public page download links before Portal release
Add a MindSpace public HTML checker for missing companion docx/PDF paths, wire it into runtime packaging and release-portal-runtime-prod.sh, and document the workflow for agents and ops.

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

152 lines
4.5 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i;
const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i;
export function isFilesystemPublicReference(raw) {
const value = String(raw ?? '').trim();
if (!value) return false;
if (SKIP_REFERENCE.test(value)) return false;
// /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML.
if (value.startsWith('/')) return false;
return true;
}
/** @deprecated use isFilesystemPublicReference */
export function isRelativePublicReference(raw) {
return isFilesystemPublicReference(raw);
}
export function isDownloadLikeReference(raw, source = '') {
if (!isFilesystemPublicReference(raw)) return false;
const ref = normalizeReferencePath(raw);
if (DOWNLOAD_EXTENSIONS.test(ref)) return true;
return /\bdownload\b/i.test(String(source ?? ''));
}
export function normalizeReferencePath(raw) {
const trimmed = String(raw ?? '')
.trim()
.split('#')[0]
.split('?')[0]
.trim();
if (!trimmed) return '';
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
export function resolveReferencePath(htmlDir, raw) {
const ref = normalizeReferencePath(raw);
if (!ref) return null;
return path.resolve(htmlDir, ref);
}
function parseMindspaceCoverPaths(contentAttr) {
const paths = [];
if (!contentAttr) return paths;
let parsed = null;
try {
parsed = JSON.parse(contentAttr);
} catch {
for (const key of ['cover', 'image']) {
const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`));
if (match?.[1]) paths.push(match[1]);
}
return paths;
}
for (const key of ['cover', 'image']) {
if (parsed?.[key]) paths.push(String(parsed[key]));
}
return paths;
}
export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) {
const refs = [];
const seen = new Set();
const add = (raw, source) => {
if (downloadsOnly && !isDownloadLikeReference(raw, source)) return;
if (!downloadsOnly && !isFilesystemPublicReference(raw)) return;
const ref = normalizeReferencePath(raw);
if (!ref) return;
const key = `${source}\0${ref}`;
if (seen.has(key)) return;
seen.add(key);
refs.push({
ref,
source,
resolvedPath: resolveReferencePath(htmlDir, ref),
});
};
for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) {
add(match[1], match[0]);
}
if (!downloadsOnly) {
for (const match of String(html ?? '').matchAll(
/<meta\b[^>]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi,
)) {
const tag = match[0];
const contentMatch =
tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i);
for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) {
add(coverPath, 'mindspace-cover');
}
}
}
return refs;
}
export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) {
const resolvedHtmlPath = path.resolve(htmlPath);
const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8');
const htmlDir = path.dirname(resolvedHtmlPath);
const missing = [];
const seen = new Set();
for (const item of collectPublicHtmlReferences(content, htmlDir, options)) {
const key = item.ref;
if (seen.has(key)) continue;
seen.add(key);
if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) {
missing.push({ htmlPath: resolvedHtmlPath, ...item });
}
}
return missing;
}
export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) {
const root = path.resolve(publishRoot);
if (!fs.existsSync(root)) return [];
const userDirs = userId
? [path.join(root, userId)]
: fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name));
const files = [];
for (const userDir of userDirs) {
const publicDir = path.join(userDir, 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
for (const name of fs.readdirSync(publicDir)) {
if (!name.toLowerCase().endsWith('.html')) continue;
files.push(path.join(publicDir, name));
}
}
return files.sort();
}
export function scanPublicHtmlLinks(publishRoot, options = {}) {
const issues = [];
for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) {
issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options));
}
return issues;
}