"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.pagesService = void 0; const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const database_1 = require("../../config/database"); const env_1 = require("../../config/env"); const error_handler_1 = require("../../middleware/error-handler"); const logger_1 = require("../../utils/logger"); 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, }; function generateSlug(title) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); } async function resolveSlugCollision(slug, excludeId) { let candidate = slug; let suffix = 2; while (true) { const existing = await database_1.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_1.default.join(env_1.env.MKDOCS_DOCS_PATH, 'overrides'); const MKDOCS_DOCS_ROOT = env_1.env.MKDOCS_DOCS_PATH; function validateMkdocsPath(mkdocsPath) { // Check for null bytes if (mkdocsPath.includes('\0')) { throw new error_handler_1.AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH'); } // Normalize and check for traversal const normalized = path_1.default.normalize(mkdocsPath); if (normalized.includes('..') || path_1.default.isAbsolute(normalized)) { throw new error_handler_1.AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH'); } // Check for encoded traversal sequences if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) { throw new error_handler_1.AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH'); } if (!mkdocsPath.endsWith('.html')) { throw new error_handler_1.AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH'); } } function validateStubPath(stubPath) { // Check for null bytes if (stubPath.includes('\0')) { throw new error_handler_1.AppError(400, 'Invalid path: null byte detected', 'INVALID_STUB_PATH'); } // Normalize and check for traversal const normalized = path_1.default.normalize(stubPath); if (normalized.includes('..') || path_1.default.isAbsolute(normalized)) { throw new error_handler_1.AppError(400, 'Path traversal not allowed', 'INVALID_STUB_PATH'); } // Check for encoded traversal sequences if (stubPath.includes('%2e') || stubPath.includes('%2E')) { throw new error_handler_1.AppError(400, 'Encoded path traversal not allowed', 'INVALID_STUB_PATH'); } } function wrapInMaterialOverride(html, css) { const styleBlock = css ? `` : ''; return `{% extends "main.html" %} {% block content %} ${styleBlock} ${html} {% endblock %} `; } function wrapInStandaloneDocument(html, css, title, description) { const metaDesc = description ? `\n ` : ''; const styleBlock = css ? `\n ` : ''; return ` ${title.replace(/</g, '<')}${metaDesc}${styleBlock} ${html} `; } function deriveStubPath(overridePath) { return overridePath.replace(/\.html$/, '.md'); } function generateMdStub(opts) { const hideItems = []; 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, content) { try { validateStubPath(stubPath); const filePath = path_1.default.join(MKDOCS_DOCS_ROOT, stubPath); await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true }); await promises_1.default.writeFile(filePath, content, 'utf-8'); logger_1.logger.info(`Wrote MkDocs stub: ${stubPath}`); } catch (err) { if (err instanceof error_handler_1.AppError) throw err; logger_1.logger.warn(`Failed to write stub: ${stubPath}`, err); } } async function removeStubFile(stubPath) { try { const filePath = path_1.default.join(MKDOCS_DOCS_ROOT, stubPath); await promises_1.default.unlink(filePath); logger_1.logger.info(`Removed MkDocs stub: ${stubPath}`); } catch { // File may not exist — ignore } } async function exportToMkDocs(opts) { const { mkdocsPath, html, css, editorMode, exportMode, title, seoTitle, seoDescription, hideNav, hideToc } = opts; try { validateMkdocsPath(mkdocsPath); // Write the override template const filePath = path_1.default.join(MKDOCS_OVERRIDES, mkdocsPath); await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true }); let content; 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_1.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 promises_1.default.writeFile(filePath, content, 'utf-8'); logger_1.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 error_handler_1.AppError) throw err; logger_1.logger.warn(`Failed to export to MkDocs: ${mkdocsPath}`, err); return deriveStubPath(mkdocsPath); } } async function removeFromMkDocs(mkdocsPath, stubPath) { try { const filePath = path_1.default.join(MKDOCS_OVERRIDES, mkdocsPath); await promises_1.default.unlink(filePath); logger_1.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, base = '') { const results = []; try { const entries = await promises_1.default.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const rel = base ? `${base}/${entry.name}` : entry.name; const full = path_1.default.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) { try { await promises_1.default.access(path_1.default.join(MKDOCS_DOCS_ROOT, stubPath)); return true; } catch { return false; } } exports.pagesService = { async findAll(filters) { const { page, limit, search, published } = filters; const skip = (page - 1) * limit; const where = {}; 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([ database_1.prisma.landingPage.findMany({ where, select: landingPageSelect, skip, take: limit, orderBy: { createdAt: 'desc' }, }), database_1.prisma.landingPage.count({ where }), ]); return { pages, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id) { const page = await database_1.prisma.landingPage.findUnique({ where: { id }, select: landingPageSelect, }); if (!page) { throw new error_handler_1.AppError(404, 'Landing page not found', 'PAGE_NOT_FOUND'); } return page; }, async findBySlugPublic(slug) { const page = await database_1.prisma.landingPage.findUnique({ where: { slug }, select: landingPageSelect, }); if (!page || !page.published) { throw new error_handler_1.AppError(404, 'Page not found', 'PAGE_NOT_FOUND'); } return page; }, async create(data) { 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 database_1.prisma.landingPage.create({ data: { ...data, slug, mkdocsPath, blocks: data.blocks, }, select: landingPageSelect, }); return page; }, async update(id, data) { const existing = await database_1.prisma.landingPage.findUnique({ where: { id } }); if (!existing) { throw new error_handler_1.AppError(404, 'Landing page not found', 'PAGE_NOT_FOUND'); } if (data.mkdocsPath) { validateMkdocsPath(data.mkdocsPath); } const updateData = { ...data }; // Handle blocks JSON field if (data.blocks !== undefined) { updateData.blocks = data.blocks; } // 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 database_1.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 database_1.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 database_1.prisma.landingPage.update({ where: { id }, data: { mkdocsStubPath: null }, }); } } return page; }, async delete(id) { const existing = await database_1.prisma.landingPage.findUnique({ where: { id } }); if (!existing) { throw new error_handler_1.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 database_1.prisma.landingPage.delete({ where: { id } }); }, async syncOverrides() { let imported = 0; let updated = 0; let stubs = 0; const files = await scanOverrideFiles(MKDOCS_OVERRIDES); if (files.length === 0) { logger_1.logger.info('syncOverrides: No HTML files found in overrides directory'); } // Get all existing pages with mkdocsPath set const existingPages = await database_1.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 promises_1.default.readFile(file.fullPath, 'utf-8'); const tracked = pathMap.get(file.relativePath); if (!tracked) { // Untracked file — import as CODE page const title = path_1.default.basename(file.relativePath, '.html'); const baseSlug = generateSlug(title); const slug = await resolveSlugCollision(baseSlug); await database_1.prisma.landingPage.create({ data: { slug, title, editorMode: 'CODE', htmlOutput: content, mkdocsPath: file.relativePath, published: true, blocks: {}, }, }); imported++; logger_1.logger.info(`syncOverrides: Imported ${file.relativePath} as CODE page`); } else if (tracked.editorMode === 'CODE') { // Tracked CODE page — update from disk (disk wins) await database_1.prisma.landingPage.update({ where: { id: tracked.id }, data: { htmlOutput: content }, }); updated++; logger_1.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 database_1.prisma.landingPage.update({ where: { id: page.id }, data: { mkdocsStubPath: expectedStubPath }, }); } stubs++; logger_1.logger.info(`syncOverrides: Created missing stub ${expectedStubPath}`); } } logger_1.logger.info(`syncOverrides: Imported ${imported}, updated ${updated}, stubs created ${stubs}`); return { imported, updated, stubs }; }, async validateExports() { let validated = 0; let repaired = 0; const errors = []; const pages = await database_1.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_1.logger.info(`Validating MkDocs exports for ${pages.length} published pages`); for (const page of pages) { validated++; try { // Check override HTML exists const overridePath = path_1.default.join(MKDOCS_OVERRIDES, page.mkdocsPath); let overrideExists = false; try { await promises_1.default.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_1.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 database_1.prisma.landingPage.update({ where: { id: page.id }, data: { mkdocsStubPath: stubPath }, }); } repaired++; logger_1.logger.info(`✓ Repaired exports for ${page.slug}`); } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); logger_1.logger.error(`Failed to validate/repair ${page.slug}:`, err); errors.push({ pageId: page.id, slug: page.slug, error: errorMsg }); } } logger_1.logger.info(`Validation complete: ${validated} validated, ${repaired} repaired, ${errors.length} errors`); return { validated, repaired, errors }; }, }; //# sourceMappingURL=pages.service.js.map