dorfteich/apps/api/src/public/public.service.ts
Claude Fable 5 05a979bac3
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
#222: read-access trail for classified pages
Instrument every full-content read channel for pages with
classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read
rendering, public JSON content, no-JS shell, expanded embeds, public API
GET (incl. the MCP read_page path and write echoes), attachment download
under the #212 effective classification, all export shapes (markdown,
pond ZIP, account data export, queued docx/odt/pdf at enqueue), and
collab-token issuance as the api-side proxy for the WS join.

Events land in the new read_events table (no FKs — evidence survives
page purges and hard user deletions) with actor, session key
(session:/token:/job:/anon), page, pond, channel and the classification
at read time. Recording failures are NOT swallowed: a failed write
aborts the read (hard failure, the deliberate contrast to AuditService —
decision recorded in ADR 0023 and security.md §Logging, together with
the recorded residuals: content fragments and feeds).

One e2e test per channel proves both the event and its absence for
unclassified pages, plus the hard-failure semantics.

Refs #222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:12:55 +02:00

315 lines
12 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { classificationMarking } from '@dorfteich/shared';
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 { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { escapeHtml, htmlDocument } from './html-shell';
/** Who is reading and through which surface (issue #222, ADR 0023) — the
* controllers resolve this once; `content` and the embed expansion record
* classified pages under it. */
export interface ReadContext {
actor: ReadActor;
channel: 'page_view' | 'no_js_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 readonly readTrail: ReadTrailService,
) {}
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, 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,
read: ReadContext,
): Promise<PublicPageContent> {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
// Read trail (issue #222): a classified page leaving through this surface
// is recorded before any content is assembled — a failed write aborts
// the read (ADR 0023, deliberate contrast to AuditService).
if (page.classification === 'VS_NFD') {
await this.readTrail.record({
...read.actor,
pageId: page.id,
pondId: pond.id,
channel: read.channel,
});
}
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]), read);
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<string>,
read: ReadContext,
): Promise<string> {
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, read);
}
/** 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<string> {
const placeholder = /<div class="dt-task-overview" data-task-overview="1">[^<]*<\/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<string>,
read: ReadContext,
): Promise<string> {
const placeholder =
/<div class="dt-transclusion" data-transclusion="([^"]+)"( data-transclusion-bare="1")?>[^<]*<\/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, classification: 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);
}
// An expanded embed shows the target's FULL content, so a classified
// target is a read of that page too (issue #222) — recorded under the
// host's channel. The degraded link above shows no content: no event.
if (target.classification === 'VS_NFD') {
await this.readTrail.record({
...read.actor,
pageId: target.id,
pondId,
channel: read.channel,
details: { embedded: true },
});
}
const inner = await this.renderBody(
user,
pondId,
target,
depth + 1,
new Set(visited).add(slug),
read,
);
// A bare embed (`$[[…]]`, #146) reads as part of the host page: no
// frame, no title — just the expanded content.
if (bare) return inner;
return (
`<div class="dt-embed"><div class="dt-embed__title">` +
`<a class="wikilink" href="${escapeHtml(slug)}" data-wikilink="${escapeHtml(slug)}">` +
`${escapeHtml(target.title)}</a></div>${inner}</div>`
);
});
}
/**
* 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<PageCommentsView> {
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,
actor: ReadActor,
): Promise<string> {
// `content` records the read-trail event (#222) under the shell's channel.
const content = await this.content(user, pondSlug, pageSlug, {
actor,
channel: 'no_js_shell',
});
// The VS-NfD marking renders in the same places as the SPA — above and
// below the content (issue #211, ADR 0022). The no-JS shell is its own
// render path, so it carries its own banner markup; unclassified pages
// get none.
const marking = classificationMarking(content.classification);
const banner = marking ? `<p class="classification-banner">${escapeHtml(marking)}</p>\n` : '';
// 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: `${banner}<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<h1>${escapeHtml(content.title)}</h1>
${content.html}
${banner}`,
});
}
}
/**
* 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"',
);
}
/** 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 (
`<div class="dt-embed dt-embed--link">` +
`<a class="wikilink" href="${safeSlug}" data-wikilink="${safeSlug}">${escapeHtml(label)}</a>` +
`</div>`
);
}
/** `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<string>,
): Promise<string> {
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);
}