dorfteich/apps/api/src/public/public.service.ts
Claude Fable 5 9e8ebfe49c
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
Degrade plugin content gracefully in HTML, PDF, and office exports (#79)
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
2026-07-11 14:13:09 +02:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}