All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m57s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m2s
CI / Import/export fidelity gate (push) Successful in 46s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m10s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m12s
Completes M7: exports and the public read view no longer show raw plugin placeholders (ADR 0008/0009). - PluginFallbackRenderer (api): replaces each plugin-block placeholder in content-cache HTML with its best static form — the block's stored SVG snapshot (block data is author-controlled, so it passes the same DOMPurify sanitizer as uploaded SVG files before entering host HTML), else the manifest fallback from the stored snapshot (text, or an image inlined as a data URI so network-isolated renderers work; tombstone-safe for uninstalled plugins), else the literal '[plugin content]' marker. - Office exports (docx/odt): the export markdown is degraded before pandoc — GFM knows neither the dorfteich-plugin fence nor the section fenced div, so blocks become their fallback text and sections plain quoted blocks (shared replacePluginNodesForExport, AST-level so nesting and embedded blocks inside sections survive). - PDF export applies the HTML fallback pass before building the Gotenberg document — resolving the TODO left in #67. - Public read view: the same fallback pass plus the pond's active section-style CSS inlined as a <style> block, so public pages show styled sections and static plugin content without any plugin runtime. - Covered in export.service.db.test (snapshot SVG sanitized — hostile <script> stripped; manifest text; tombstone text; quoted sections and no fence artifacts in the pandoc input) and shared export-fallbacks tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Pond, User } from '@prisma/client';
|
|
|
|
import { PermissionService } from '../permissions/permission.service';
|
|
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */
|
|
export interface PublicPageContent {
|
|
pondName: string;
|
|
pondSlug: string;
|
|
title: string;
|
|
slug: string;
|
|
/** Pre-rendered body HTML from the content cache (issue #24). */
|
|
html: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface ResolvedPage {
|
|
pond: Pond;
|
|
page: { id: string; pondId: string; slug: string; title: string };
|
|
}
|
|
|
|
/**
|
|
* Public read access (issue #56): resolves a pond/page by slug and enforces
|
|
* read permission for the (possibly anonymous) viewer through the shared
|
|
* resolver — a `public` grant is what lets a logged-out visitor in. Denied or
|
|
* missing → 404, so non-public pages never reveal their existence
|
|
* (security.md). Serves both the SPA's JSON and a server-rendered HTML page for
|
|
* crawlers and the PDF exporter (ADR 0005/0009).
|
|
*/
|
|
@Injectable()
|
|
export class PublicService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionService,
|
|
private readonly fallbacks: PluginFallbackRenderer,
|
|
) {}
|
|
|
|
private async resolve(
|
|
user: User | null,
|
|
pondSlug: string,
|
|
pageSlug: string,
|
|
): Promise<ResolvedPage> {
|
|
const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } });
|
|
if (!pond) throw new NotFoundException();
|
|
const page = await this.prisma.page.findFirst({
|
|
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
|
|
select: { id: true, pondId: true, slug: true, title: true },
|
|
});
|
|
// Hide existence: no read access (incl. anonymous without a public grant) → 404.
|
|
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
|
|
throw new NotFoundException();
|
|
}
|
|
return { pond, page };
|
|
}
|
|
|
|
/** The page content for the SPA's read-only public view. */
|
|
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
|
|
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
|
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
|
// Plugin blocks render their static form (#79), and the pond's active
|
|
// section-style CSS travels inline — the public view loads no plugin
|
|
// runtime, and the CSS passed the install gate's scoping rules.
|
|
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
|
const styleTag = await this.fallbacks.sectionStyleTag(pond.id);
|
|
return {
|
|
pondName: pond.name,
|
|
pondSlug: pond.slug,
|
|
title: page.title,
|
|
slug: page.slug,
|
|
html: styleTag + resolveMediaUrls(withFallbacks),
|
|
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
|
|
};
|
|
}
|
|
|
|
/** A complete, self-contained HTML document for crawlers / PDF export. */
|
|
async html(
|
|
user: User | null,
|
|
pondSlug: string,
|
|
pageSlug: string,
|
|
canonical: string,
|
|
): Promise<string> {
|
|
const content = await this.content(user, pondSlug, pageSlug);
|
|
const title = escapeHtml(`${content.title} — ${content.pondName}`);
|
|
// No session-dependent content: this document is identical for every viewer
|
|
// who may read the page (crawler-safe, cacheable).
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${title}</title>
|
|
<link rel="canonical" href="${escapeHtml(canonical)}">
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
|
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
|
|
img { max-width: 100%; height: auto; }
|
|
.public-page__pond { color: #64748b; font-size: 0.9rem; }
|
|
pre { overflow-x: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="public-page">
|
|
<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
|
|
<h1>${escapeHtml(content.title)}</h1>
|
|
${content.html}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The cached HTML carries images as `<img data-file-id="…">` — in the live app
|
|
* the editor's node view resolves that to `/media/:fileId` client-side. The
|
|
* static public view has no such runtime, so resolve it here to a real `src`
|
|
* (the media endpoint is public too, issue #56).
|
|
*/
|
|
function resolveMediaUrls(html: string): string {
|
|
// Same URL the editor's image node view uses (apps/web/.../nodes/image.tsx).
|
|
return html.replace(
|
|
/data-file-id="([A-Za-z0-9-]+)"/g,
|
|
'src="/api/v1/media/$1" data-file-id="$1"',
|
|
);
|
|
}
|
|
|
|
/** Minimal HTML escaping for the values we interpolate into the shell (not the
|
|
* already-sanitized cached body HTML). */
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|