566 lines
22 KiB
JavaScript
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, '"')}">` : '';
|
|
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, '<')}</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
|