import { EditorMode, MkdocsExportMode, Prisma } from '@prisma/client'; import fs from 'fs/promises'; import path from 'path'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { AppError } from '../../middleware/error-handler'; import { logger } from '../../utils/logger'; import type { CreateLandingPageInput, UpdateLandingPageInput, ListLandingPagesInput } from './pages.schemas'; const landingPageSelect = { id: true, slug: true, title: true, description: true, editorMode: true, blocks: true, htmlOutput: true, cssOutput: true, mkdocsPath: true, mkdocsStubPath: true, mkdocsExportMode: true, mkdocsHideNav: true, mkdocsHideToc: true, mkdocsSkipExport: true, published: true, listed: true, seoTitle: true, seoDescription: true, seoImage: true, createdAt: true, updatedAt: true, } satisfies Prisma.LandingPageSelect; function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); } async function resolveSlugCollision(slug: string, excludeId?: string): Promise { let candidate = slug; let suffix = 2; while (true) { const existing = await prisma.landingPage.findUnique({ where: { slug: candidate }, select: { id: true }, }); if (!existing || (excludeId && existing.id === excludeId)) { return candidate; } candidate = `${slug}-${suffix}`; suffix++; } } const MKDOCS_OVERRIDES = path.join(env.MKDOCS_DOCS_PATH, 'overrides'); const MKDOCS_DOCS_ROOT = env.MKDOCS_DOCS_PATH; function validateMkdocsPath(mkdocsPath: string): void { // Check for null bytes if (mkdocsPath.includes('\0')) { throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH'); } // Normalize and check for traversal const normalized = path.normalize(mkdocsPath); if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH'); } // Check for encoded traversal sequences if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) { throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH'); } if (!mkdocsPath.endsWith('.html')) { throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH'); } } function validateStubPath(stubPath: string): void { // Check for null bytes if (stubPath.includes('\0')) { throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_STUB_PATH'); } // Normalize and check for traversal const normalized = path.normalize(stubPath); if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new AppError(400, 'Path traversal not allowed', 'INVALID_STUB_PATH'); } // Check for encoded traversal sequences if (stubPath.includes('%2e') || stubPath.includes('%2E')) { throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_STUB_PATH'); } } function wrapInMaterialOverride(html: string, css: string | null): string { const styleBlock = css ? `` : ''; return `{% extends "main.html" %} {% block content %} ${styleBlock} ${html} {% endblock %} `; } function wrapInStandaloneDocument(html: string, css: string | null, title: string, description: string | null): string { const metaDesc = description ? `\n ` : ''; const styleBlock = css ? `\n ` : ''; return ` ${title.replace(/</g, '<')}${metaDesc}${styleBlock} ${html} `; } function deriveStubPath(overridePath: string): string { return overridePath.replace(/\.html$/, '.md'); } interface StubOptions { overrideFilename: string; title: string; description: string | null; hideNav: boolean; hideToc: boolean; } function generateMdStub(opts: StubOptions): string { const hideItems: string[] = []; if (opts.hideNav) hideItems.push(' - navigation'); if (opts.hideToc) hideItems.push(' - toc'); const hideBlock = hideItems.length > 0 ? `hide:\n${hideItems.join('\n')}\n` : ''; const descLine = opts.description ? `description: "${opts.description.replace(/"/g, '\\"')}"\n` : ''; return `--- template: ${opts.overrideFilename} ${hideBlock}title: "${opts.title.replace(/"/g, '\\"')}" ${descLine}--- `; } async function writeStubFile(stubPath: string, content: string): Promise { try { validateStubPath(stubPath); const filePath = path.join(MKDOCS_DOCS_ROOT, stubPath); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content, 'utf-8'); logger.info(`Wrote MkDocs stub: ${stubPath}`); } catch (err) { if (err instanceof AppError) throw err; logger.warn(`Failed to write stub: ${stubPath}`, err); } } async function removeStubFile(stubPath: string): Promise { try { const filePath = path.join(MKDOCS_DOCS_ROOT, stubPath); await fs.unlink(filePath); logger.info(`Removed MkDocs stub: ${stubPath}`); } catch { // File may not exist — ignore } } interface ExportOptions { mkdocsPath: string; html: string; css: string | null; editorMode: EditorMode; exportMode: MkdocsExportMode; title: string; seoTitle: string | null; seoDescription: string | null; hideNav: boolean; hideToc: boolean; } async function exportToMkDocs(opts: ExportOptions): Promise { const { mkdocsPath, html, css, editorMode, exportMode, title, seoTitle, seoDescription, hideNav, hideToc } = opts; try { validateMkdocsPath(mkdocsPath); // Write the override template const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath); await fs.mkdir(path.dirname(filePath), { recursive: true }); let content: string; if (editorMode === 'CODE') { content = html; } else if (exportMode === 'STANDALONE') { content = wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription); } else { content = wrapInMaterialOverride(html, css); } // Rewrite relative media/gallery URLs to absolute for MkDocs context const adminUrl = env.ADMIN_URL || 'http://localhost:3000'; content = content.replace(/src="\/media\/public\//g, `src="${adminUrl}/media/public/`); content = content.replace(/href="\/gallery\/watch\//g, `href="${adminUrl}/gallery/watch/`); content = content.replace(/href="\/gallery\?expanded=/g, `href="${adminUrl}/gallery?expanded=`); content = content.replace(/src="http:\/\/localhost:4100\//g, `src="${adminUrl.replace(/:\d+$/, ':4100')}/`); // Rewrite payment page URLs to absolute for MkDocs context content = content.replace(/href="\/donate"/g, `href="${adminUrl}/donate"`); content = content.replace(/href="\/pricing"/g, `href="${adminUrl}/pricing"`); content = content.replace(/href="\/shop"/g, `href="${adminUrl}/shop"`); content = content.replace(/href="\/payments\/success"/g, `href="${adminUrl}/payments/success"`); await fs.writeFile(filePath, content, 'utf-8'); logger.info(`Exported landing page to MkDocs: ${mkdocsPath} (${editorMode}/${exportMode})`); // Write the .md stub const stubPath = deriveStubPath(mkdocsPath); const stubContent = generateMdStub({ overrideFilename: mkdocsPath, title: seoTitle || title, description: seoDescription, hideNav, hideToc, }); await writeStubFile(stubPath, stubContent); return stubPath; } catch (err) { if (err instanceof AppError) throw err; logger.warn(`Failed to export to MkDocs: ${mkdocsPath}`, err); return deriveStubPath(mkdocsPath); } } async function removeFromMkDocs(mkdocsPath: string, stubPath?: string | null): Promise { try { const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath); await fs.unlink(filePath); logger.info(`Removed MkDocs export: ${mkdocsPath}`); } catch { // File may not exist — ignore } // Also remove the stub const effectiveStubPath = stubPath || deriveStubPath(mkdocsPath); await removeStubFile(effectiveStubPath); } async function scanOverrideFiles(dir: string, base: string = ''): Promise<{ relativePath: string; fullPath: string }[]> { const results: { relativePath: string; fullPath: string }[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const rel = base ? `${base}/${entry.name}` : entry.name; const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...await scanOverrideFiles(full, rel)); } else if (entry.name.endsWith('.html')) { results.push({ relativePath: rel, fullPath: full }); } } } catch { // Directory may not exist } return results; } async function stubExistsOnDisk(stubPath: string): Promise { try { await fs.access(path.join(MKDOCS_DOCS_ROOT, stubPath)); return true; } catch { return false; } } export const pagesService = { async findAll(filters: ListLandingPagesInput) { const { page, limit, search, published } = filters; const skip = (page - 1) * limit; const where: Prisma.LandingPageWhereInput = {}; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, { slug: { contains: search, mode: 'insensitive' } }, ]; } if (published === 'true') where.published = true; else if (published === 'false') where.published = false; const [pages, total] = await Promise.all([ prisma.landingPage.findMany({ where, select: landingPageSelect, skip, take: limit, orderBy: { createdAt: 'desc' }, }), prisma.landingPage.count({ where }), ]); return { pages, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id: string) { const page = await prisma.landingPage.findUnique({ where: { id }, select: landingPageSelect, }); if (!page) { throw new AppError(404, 'Landing page not found', 'PAGE_NOT_FOUND'); } return page; }, async findBySlugPublic(slug: string) { const page = await prisma.landingPage.findUnique({ where: { slug }, select: landingPageSelect, }); if (!page || !page.published) { throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND'); } return page; }, async create(data: CreateLandingPageInput) { const baseSlug = generateSlug(data.title); const slug = await resolveSlugCollision(baseSlug); // Auto-generate mkdocsPath from slug if not provided const mkdocsPath = data.mkdocsPath ?? `${slug}.html`; validateMkdocsPath(mkdocsPath); const page = await prisma.landingPage.create({ data: { ...data, slug, mkdocsPath, blocks: data.blocks as unknown as Prisma.InputJsonValue, }, select: landingPageSelect, }); return page; }, async update(id: string, data: UpdateLandingPageInput) { const existing = await prisma.landingPage.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Landing page not found', 'PAGE_NOT_FOUND'); } if (data.mkdocsPath) { validateMkdocsPath(data.mkdocsPath); } const updateData: Prisma.LandingPageUncheckedUpdateInput = { ...data }; // Handle blocks JSON field if (data.blocks !== undefined) { updateData.blocks = data.blocks as unknown as Prisma.InputJsonValue; } // Regenerate slug if title changes, and keep mkdocsPath in sync if (data.title && data.title !== existing.title) { const baseSlug = generateSlug(data.title); const newSlug = await resolveSlugCollision(baseSlug, id); updateData.slug = newSlug; // Update mkdocsPath if it was auto-generated (matches old slug pattern) const autoPath = `${existing.slug}.html`; if (existing.mkdocsPath === autoPath) { const oldMkdocsPath = existing.mkdocsPath; updateData.mkdocsPath = `${newSlug}.html`; // Clean up old override + old stub await removeFromMkDocs(oldMkdocsPath, existing.mkdocsStubPath); updateData.mkdocsStubPath = null; } } const page = await prisma.landingPage.update({ where: { id }, data: updateData, select: landingPageSelect, }); // Handle MkDocs export/cleanup based on publish state if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) { const stubPath = await exportToMkDocs({ mkdocsPath: page.mkdocsPath, html: page.htmlOutput, css: page.cssOutput, editorMode: page.editorMode, exportMode: page.mkdocsExportMode, title: page.title, seoTitle: page.seoTitle, seoDescription: page.seoDescription, hideNav: page.mkdocsHideNav, hideToc: page.mkdocsHideToc, }); // Store stubPath if changed if (stubPath !== page.mkdocsStubPath) { await prisma.landingPage.update({ where: { id }, data: { mkdocsStubPath: stubPath }, }); } } else if ((!page.published || page.mkdocsSkipExport) && existing.mkdocsPath) { await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath); if (existing.mkdocsStubPath) { await prisma.landingPage.update({ where: { id }, data: { mkdocsStubPath: null }, }); } } return page; }, async delete(id: string) { const existing = await prisma.landingPage.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Landing page not found', 'PAGE_NOT_FOUND'); } // Remove MkDocs export + stub if exists if (existing.mkdocsPath) { await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath); } await prisma.landingPage.delete({ where: { id } }); }, async syncOverrides(): Promise<{ imported: number; updated: number; stubs: number }> { let imported = 0; let updated = 0; let stubs = 0; const files = await scanOverrideFiles(MKDOCS_OVERRIDES); if (files.length === 0) { logger.info('syncOverrides: No HTML files found in overrides directory'); } // Get all existing pages with mkdocsPath set const existingPages = await prisma.landingPage.findMany({ where: { mkdocsPath: { not: null } }, select: { id: true, mkdocsPath: true, mkdocsStubPath: true, mkdocsExportMode: true, mkdocsHideNav: true, mkdocsHideToc: true, editorMode: true, title: true, seoTitle: true, seoDescription: true, published: true, }, }); const pathMap = new Map(existingPages.map(p => [p.mkdocsPath!, p])); for (const file of files) { const content = await fs.readFile(file.fullPath, 'utf-8'); const tracked = pathMap.get(file.relativePath); if (!tracked) { // Untracked file — import as CODE page const title = path.basename(file.relativePath, '.html'); const baseSlug = generateSlug(title); const slug = await resolveSlugCollision(baseSlug); await prisma.landingPage.create({ data: { slug, title, editorMode: 'CODE', htmlOutput: content, mkdocsPath: file.relativePath, published: true, blocks: {} as unknown as Prisma.InputJsonValue, }, }); imported++; logger.info(`syncOverrides: Imported ${file.relativePath} as CODE page`); } else if (tracked.editorMode === 'CODE') { // Tracked CODE page — update from disk (disk wins) await prisma.landingPage.update({ where: { id: tracked.id }, data: { htmlOutput: content }, }); updated++; logger.info(`syncOverrides: Updated ${file.relativePath} from disk`); } // VISUAL pages: don't overwrite from disk (managed by GrapesJS) } // Backfill missing .md stubs for published pages with overrides for (const page of existingPages) { if (!page.published || !page.mkdocsPath) continue; const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath); const exists = await stubExistsOnDisk(expectedStubPath); if (!exists) { const stubContent = generateMdStub({ overrideFilename: page.mkdocsPath, title: page.seoTitle || page.title, description: page.seoDescription, hideNav: page.mkdocsHideNav, hideToc: page.mkdocsHideToc, }); await writeStubFile(expectedStubPath, stubContent); if (page.mkdocsStubPath !== expectedStubPath) { await prisma.landingPage.update({ where: { id: page.id }, data: { mkdocsStubPath: expectedStubPath }, }); } stubs++; logger.info(`syncOverrides: Created missing stub ${expectedStubPath}`); } } logger.info(`syncOverrides: Imported ${imported}, updated ${updated}, stubs created ${stubs}`); return { imported, updated, stubs }; }, async validateExports(): Promise<{ validated: number; repaired: number; errors: Array<{ pageId: string; slug: string; error: string }> }> { let validated = 0; let repaired = 0; const errors: Array<{ pageId: string; slug: string; error: string }> = []; const pages = await prisma.landingPage.findMany({ where: { published: true, mkdocsSkipExport: false, mkdocsPath: { not: null }, htmlOutput: { not: null }, }, select: { id: true, slug: true, title: true, mkdocsPath: true, mkdocsStubPath: true, htmlOutput: true, cssOutput: true, editorMode: true, mkdocsExportMode: true, mkdocsHideNav: true, mkdocsHideToc: true, seoTitle: true, seoDescription: true, }, }); logger.info(`Validating MkDocs exports for ${pages.length} published pages`); for (const page of pages) { validated++; try { // Check override HTML exists const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath!); let overrideExists = false; try { await fs.access(overridePath); overrideExists = true; } catch { // File doesn't exist } // Check stub exists const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath!); const stubExists = await stubExistsOnDisk(expectedStubPath); // Repair if either missing if (!overrideExists || !stubExists) { logger.warn(`Missing exports for ${page.slug}: override=${overrideExists}, stub=${stubExists}. Re-exporting...`); const stubPath = await exportToMkDocs({ mkdocsPath: page.mkdocsPath!, html: page.htmlOutput!, css: page.cssOutput, editorMode: page.editorMode, exportMode: page.mkdocsExportMode, title: page.title, seoTitle: page.seoTitle, seoDescription: page.seoDescription, hideNav: page.mkdocsHideNav, hideToc: page.mkdocsHideToc, }); if (stubPath !== page.mkdocsStubPath) { await prisma.landingPage.update({ where: { id: page.id }, data: { mkdocsStubPath: stubPath }, }); } repaired++; logger.info(`✓ Repaired exports for ${page.slug}`); } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); logger.error(`Failed to validate/repair ${page.slug}:`, err); errors.push({ pageId: page.id, slug: page.slug, error: errorMsg }); } } logger.info(`Validation complete: ${validated} validated, ${repaired} repaired, ${errors.length} errors`); return { validated, repaired, errors }; }, };