566 lines
22 KiB
JavaScript

"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 ? `<style>\n${css}\n</style>` : '';
return `{% extends "main.html" %}
{% block content %}
${styleBlock}
${html}
{% endblock %}
`;
}
function wrapInStandaloneDocument(html, css, title, description) {
const metaDesc = description ? `\n <meta name="description" content="${description.replace(/"/g, '&quot;')}">` : '';
const styleBlock = css ? `\n <style>\n${css}\n </style>` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title.replace(/</g, '&lt;')}</title>${metaDesc}${styleBlock}
</head>
<body>
${html}
</body>
</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