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
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
315 lines
12 KiB
TypeScript
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);
|
|
}
|