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 { 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 { 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 { 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 ` ${title}

${escapeHtml(content.pondName)}

${escapeHtml(content.title)}

${content.html}
`; } } /** * The cached HTML carries images as `` — 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, '"'); }