Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m24s
CI / Build container images (pull_request) Successful in 4m24s
CI / Auth e2e pack (pull_request) Successful in 8m44s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 26s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m30s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m55s
CI / Import/export fidelity gate (push) Failing after 50s
ClassificationBanner renders the fixed ADR-0022 wording above and below the content in reading view, editor and public page view; unclassified pages show nothing. Announced to assistive tech via a localized hidden prefix (de+en); styled from the plain text token only, so contrast holds in both themes and under every accent with no new color pair. Public content endpoint now carries the classification. New seed fixture classified-note; a11y pack asserts banner top+bottom and axe-clean in light and dark. Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
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<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): Promise<PublicPageContent> {
|
|
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<string>,
|
|
): 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);
|
|
}
|
|
|
|
/** 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>,
|
|
): 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 },
|
|
});
|
|
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 (
|
|
`<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,
|
|
): Promise<string> {
|
|
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: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
|
|
<h1>${escapeHtml(content.title)}</h1>
|
|
${content.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"',
|
|
);
|
|
}
|
|
|
|
/** 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);
|
|
}
|