import fs from 'node:fs/promises';
import path from 'node:path';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs';
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
const URL_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
function decodePathSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function extractStaticPageLinks(content, { userId, username } = {}) {
const text = String(content ?? '');
const links = [];
const seen = new Set();
const normalizedUserId = userId ? String(userId).trim().toLowerCase() : null;
const normalizedUsername = username ? String(username).trim().toLowerCase() : null;
for (const match of text.matchAll(URL_PATTERN)) {
const owner = decodePathSegment(match[1]).toLowerCase();
const relativePath = decodePathSegment(match[2]);
if (normalizedUserId) {
if (owner !== normalizedUserId) continue;
} else if (normalizedUsername && owner !== normalizedUsername) {
continue;
}
const key = `${owner}/${relativePath}`;
if (seen.has(key)) continue;
seen.add(key);
links.push({
publicUrl: match[0],
owner,
relativePath,
filename: path.basename(relativePath),
});
}
return links;
}
export function buildWorkspaceAssetUrl(userId, relativePath) {
const key = String(userId ?? '').trim();
const clean = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..')
.map((part) => encodeURIComponent(part))
.join('/');
if (!key || !clean) return null;
return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${clean}`;
}
export function buildWorkspaceThumbnailUrl(userId, htmlRelativePath) {
const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath);
return buildWorkspaceAssetUrl(userId, thumbRel);
}
export function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) {
const key = String(userId ?? '').trim().toLowerCase();
if (!PUBLISH_KEY_UUID.test(key)) {
throw Object.assign(new Error('无效的用户 ID'), { code: 'invalid_page_path' });
}
const clean = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..')
.join('/');
if (!key || !clean || !clean.toLowerCase().endsWith('.html')) {
throw Object.assign(new Error('无效的页面路径'), { code: 'invalid_page_path' });
}
const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key);
const absolute = path.resolve(publishRoot, clean);
if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) {
throw Object.assign(new Error('页面路径越界'), { code: 'invalid_page_path' });
}
return absolute;
}
export async function readPublishHtml(h5Root, userId, relativePath) {
const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath);
const content = await fs.readFile(absolute, 'utf8');
if (!content.trim()) {
throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' });
}
return { absolute, content, relativePath, filename: path.basename(relativePath) };
}
async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, depth = 0) {
if (depth > maxDepth || !basename.toLowerCase().endsWith('.html')) return null;
let entries;
try {
entries = await fs.readdir(publishRoot, { withFileTypes: true });
} catch {
return null;
}
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const full = path.join(publishRoot, entry.name);
if (entry.isFile() && entry.name === basename) return full;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const found = await walkPublishHtmlByBasename(
path.join(publishRoot, entry.name),
basename,
maxDepth,
depth + 1,
);
if (found) return found;
}
return null;
}
export async function findPublishHtml(h5Root, userId, relativePath) {
const normalized = String(relativePath ?? '').replace(/^\/+/, '');
const basename = path.basename(normalized);
const candidates = [
normalized,
path.posix.join('public', basename),
basename,
].filter((value, index, list) => value && list.indexOf(value) === index);
for (const candidate of candidates) {
try {
return await readPublishHtml(h5Root, userId, candidate);
} catch {
// try next candidate
}
}
const publishRoot = path.resolve(
h5Root,
PUBLISH_ROOT_DIR,
String(userId ?? '').trim().toLowerCase(),
);
const absolute = await walkPublishHtmlByBasename(publishRoot, basename);
if (absolute) {
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
return readPublishHtml(h5Root, userId, resolvedRelativePath);
}
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
export function buildWorkspaceBaseHref(userId, htmlRelativePath) {
const key = String(userId ?? '').trim();
const dir = path.posix.dirname(String(htmlRelativePath ?? '').replace(/^\/+/, ''));
const segments = dir === '.' ? [] : dir.split('/').filter(Boolean);
const encoded = segments.map((part) => encodeURIComponent(part)).join('/');
return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${encoded ? `${encoded}/` : ''}`;
}
export function injectHtmlBaseHref(html, baseHref) {
const safeBase = String(baseHref ?? '').replace(/"/g, '%22');
if (!safeBase) return html;
if (/