import { Injectable, NotFoundException } from '@nestjs/common'; import type { PageClassification, PageCommentsView } from '@dorfteich/shared'; import { Page, Pond, User } from '@prisma/client'; import { CommentsService } from '../comments/comments.service'; import { TasksService } from '../pages/tasks.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { escapeHtml, htmlDocument } from './html-shell'; /** 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; /** VS-NfD marking level (ADR 0022, issue #206) — the read view renders it * above and below the content. */ classification: PageClassification; updatedAt: string; } interface ResolvedPage { pond: Pond; page: { id: string; pondId: string; slug: string; title: string; classification: Page['classification']; }; } /** * 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 readonly settings: InstanceSettingsService, private readonly commentsService: CommentsService, private readonly tasks: TasksService, ) {} 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, classification: 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 read view (public and authenticated, issue #56). */ 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 } }); // The pond's active section-style CSS travels inline — the read view loads // no plugin runtime, and the CSS passed the install gate's scoping rules. const styleTag = await this.fallbacks.sectionStyleTag(pond.id); // Plugin blocks render their static form (#79) and page embeds expand to the // target's rendered HTML (#135), then media is resolved once over the whole // tree. `visited` seeds with this page so an embed of self is not expanded. const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug])); return { pondName: pond.name, pondSlug: pond.slug, title: page.title, slug: page.slug, html: styleTag + resolveMediaUrls(body), classification: page.classification.toLowerCase() as PageClassification, updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), }; } /** Longest embed chain we follow before falling back to a link (issue #135). */ private static readonly MAX_EMBED_DEPTH = 2; /** * A page's body HTML: cached HTML + plugin fallbacks + expanded page embeds, * but WITHOUT media resolution or the style tag — those are applied once at * the top of {@link content} so nested embeds are not double-processed. */ private async renderBody( user: User | null, pondId: string, page: { id: string }, depth: number, visited: Set, ): Promise { const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id); return this.expandEmbeds(withTasks, user, pondId, depth, visited); } /** Replaces each task-overview placeholder (issue #154) with the static, * permission-filtered table — read-only in the public rendering. */ private async expandTaskOverviews( html: string, user: User | null, pondId: string, pageId: string, ): Promise { const placeholder = /
[^<]*<\/div>/g; if (!placeholder.test(html)) return html; const lang = await this.settings.get('instance.defaultLocale'); const table = await this.tasks.renderStaticTable(user, pondId, pageId, lang); return html.replace(placeholder, () => table); } /** * Replaces each `dt-transclusion` placeholder (issue #135) with the target * page's rendered body. Same-pond only, read-permission-checked; a missing, * unreadable, cyclic, or too-deep target degrades to a plain link so the page * never leaks existence and never loops. */ private async expandEmbeds( html: string, user: User | null, pondId: string, depth: number, visited: Set, ): Promise { const placeholder = /
[^<]*<\/div>/g; return replaceAsync(html, placeholder, async (_match, rawSlug, bareAttr) => { const slug = rawSlug as string; const bare = Boolean(bareAttr); const target = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null }, select: { id: true, pondId: true, slug: true, title: true }, }); const readable = target && (await this.permissions.canAccessPage(user, target, 'read')); if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) { return embedLink(slug, target?.title ?? slug); } const inner = await this.renderBody( user, pondId, target, depth + 1, new Set(visited).add(slug), ); // A bare embed (`$[[…]]`, #146) reads as part of the host page: no // frame, no title — just the expanded content. if (bare) return inner; return ( `` ); }); } /** * The page's comments for the anonymous public view (issue #133), read-only. * `resolve()` enforces (possibly anonymous) read access — a non-public page * 404s here too, so comments never leak. `list` builds the same * `PageCommentsView` the authenticated endpoint returns; the SPA renders it * without any composer or action controls. */ async comments(user: User | null, pondSlug: string, pageSlug: string): Promise { const { page } = await this.resolve(user, pondSlug, pageSlug); return this.commentsService.list(page.id, 'all'); } /** 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); // No session-dependent content: this document is identical for every viewer // who may read the page (crawler-safe, cacheable). The shared shell adds // the legal footer links (issue #82) in the instance default locale. return htmlDocument({ lang: await this.settings.get('instance.defaultLocale'), title: `${content.title} — ${content.pondName}`, canonical, // Advertise the pond's Atom feed (issue #149) to feed readers. feedUrl: new URL( `/api/v1/public/${encodeURIComponent(pondSlug)}/feed.xml`, canonical, ).toString(), bodyHtml: `

${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"', ); } /** The fallback for an embed that cannot expand (missing/unreadable/cyclic/too * deep, issue #135): a plain wikilink, so the page never loops or leaks. */ function embedLink(slug: string, label: string): string { const safeSlug = escapeHtml(slug); return ( `` ); } /** `String.replace` with an async replacer (issue #135): resolves every match's * replacement in parallel, then splices them back in match order. */ async function replaceAsync( input: string, regex: RegExp, replacer: (match: string, ...groups: string[]) => Promise, ): Promise { const matches = [...input.matchAll(regex)]; if (matches.length === 0) return input; const replacements = await Promise.all( matches.map((match) => replacer(match[0], ...match.slice(1))), ); let result = ''; let lastIndex = 0; matches.forEach((match, i) => { result += input.slice(lastIndex, match.index) + replacements[i]; lastIndex = (match.index ?? 0) + match[0].length; }); return result + input.slice(lastIndex); }