import fs from 'node:fs'; import path from 'node:path'; import { buildMindSpacePublicRoutePath, resolveMindSpaceUserPublishDir, } from './mindspace-runtime-config.mjs'; import { PUBLIC_ZONE_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs'; const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/; const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i; export async function resolveMindSpacePublishOwnerKey(segment, resolveUsernameToUserId) { const lower = String(segment ?? '').trim().toLowerCase(); if (!lower) return null; if (PUBLISH_KEY_UUID.test(lower)) return lower; if (!USERNAME_SLUG.test(lower)) return null; const resolved = await resolveUsernameToUserId?.(lower); return resolved ? String(resolved).toLowerCase() : lower; } export function decodeMindSpaceRouteSegment(segment) { try { return decodeURIComponent(segment); } catch { return segment; } } export function resolveMindSpacePublicRouteTarget({ h5Root, ownerKey, parts = [] }) { const targetDir = resolveMindSpaceUserPublishDir(h5Root, { id: ownerKey }); const resolvedRoot = path.resolve(targetDir); const resolvedPath = path.resolve(path.join(targetDir, ...parts)); return { targetDir, resolvedRoot, resolvedPath, insideRoot: resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`), }; } export function buildMindSpaceCanonicalRedirect(ownerKey, parts = []) { return buildMindSpacePublicRoutePath(ownerKey, parts); } export function resolveMindSpaceHtmlFallbackRedirect({ ownerKey, targetDir, resolvedRoot, rest = [] }) { if (rest.length === 1 && String(rest[0]).toLowerCase().endsWith('.html')) { const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); if ( publicFallback.startsWith(`${resolvedRoot}${path.sep}`) && fs.existsSync(publicFallback) && fs.statSync(publicFallback).isFile() ) { return buildMindSpacePublicRoutePath(ownerKey, [PUBLIC_ZONE_DIR, rest[0]]); } } return null; } export function resolveMindSpaceSimilarHtmlRedirect({ ownerKey, relativePath }) { if (!relativePath) return null; return buildMindSpacePublicRoutePath(ownerKey, String(relativePath).split('/')); } export async function recoverMisplacedMindSpacePublicHtml({ h5Root, targetDir, resolvedRoot, rest = [], ensureThumbnail, logger = console, } = {}) { if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null; const filename = rest[1]; if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null; const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename); if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null; const candidates = [ path.resolve(h5Root, filename), path.resolve(h5Root, PUBLIC_ZONE_DIR, filename), ]; for (const candidate of candidates) { if (candidate === destination) continue; if (!candidate.startsWith(`${path.resolve(h5Root)}${path.sep}`)) continue; if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue; fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(candidate, destination); await ensureThumbnail?.(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`); logger?.warn?.( `[MindSpace] recovered misplaced public HTML ${path.relative(h5Root, candidate)} -> ${path.relative(h5Root, destination)}`, ); return destination; } return null; } export async function resolveMindSpacePublicRequest({ h5Root, requestPath = '', resolveUsernameToUserId, resolveClosestHtmlRelativePath, ensureThumbnail, logger = console, } = {}) { const parts = String(requestPath || '') .split('/') .filter(Boolean); if (parts.length < 1) { return { action: 'not_found', reason: 'missing_path' }; } const ownerKey = await resolveMindSpacePublishOwnerKey(parts[0], resolveUsernameToUserId); if (!ownerKey) { return { action: 'not_found', reason: 'missing_owner' }; } if (parts[0].toLowerCase() !== ownerKey && parts[0].trim()) { return { action: 'redirect', status: 301, location: buildMindSpaceCanonicalRedirect(ownerKey, parts.slice(1)), ownerKey, }; } const rest = parts.slice(1).map(decodeMindSpaceRouteSegment); const routeTarget = resolveMindSpacePublicRouteTarget({ h5Root, ownerKey, parts: rest, }); const { targetDir, resolvedRoot, resolvedPath } = routeTarget; if (!routeTarget.insideRoot) { return { action: 'forbidden', reason: 'outside_publish_root', ownerKey }; } if (!fs.existsSync(targetDir)) { return { action: 'not_found', reason: 'missing_owner_dir', ownerKey }; } if (!fs.existsSync(resolvedPath)) { const publicFallbackRedirect = resolveMindSpaceHtmlFallbackRedirect({ ownerKey, targetDir, resolvedRoot, rest, }); if (publicFallbackRedirect) { return { action: 'redirect', status: 301, location: publicFallbackRedirect, ownerKey, }; } if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) { const similarRelativePath = await resolveClosestHtmlRelativePath?.( targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`, ); if (similarRelativePath) { return { action: 'redirect', status: 301, location: resolveMindSpaceSimilarHtmlRedirect({ ownerKey, relativePath: similarRelativePath, }), ownerKey, }; } } const recoveredPublicHtml = await recoverMisplacedMindSpacePublicHtml({ h5Root, targetDir, resolvedRoot, rest, ensureThumbnail, logger, }); if (recoveredPublicHtml) { return { action: 'serve', filePath: recoveredPublicHtml, ownerKey, targetDir, }; } // og:image points at .thumbnail.png while only the SVG sidecar may exist on disk. // Serve resolves lazily via ensureThumbnailPng in the MindSpace public file handler. if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); if (fs.existsSync(svgSibling)) { return { action: 'serve', filePath: resolvedPath, ownerKey, targetDir, }; } } return { action: 'not_found', reason: 'missing_file', ownerKey }; } if (fs.statSync(resolvedPath).isDirectory()) { const indexPath = path.join(resolvedPath, 'index.html'); if (fs.existsSync(indexPath)) { return { action: 'serve', filePath: indexPath, ownerKey, targetDir, }; } return { action: 'not_found', reason: 'missing_directory_index', ownerKey }; } return { action: 'serve', filePath: resolvedPath, ownerKey, targetDir, }; } export const mindspacePublicRouteInternals = { USERNAME_SLUG, MISPLACED_PUBLIC_HTML_NAME, };