dorfteich/apps/api/src/public/public.service.ts
Claude Fable 5 7252bd16e0
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 7m12s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m14s
CI / Import/export fidelity gate (push) Has been skipped
#149: Atom-Feeds für Teiche und Seiten, privat via Feed-Token
GET /public/:pond/feed.xml (zuletzt geänderte Seiten) und
GET /public/:pond/:page/feed.xml (Versions-Historie), @Public mit
404-Semantik; öffentliche Teiche anonym, nicht-öffentliche über neues
read-only Feed-Token je Nutzer als ?token=dt_feed_… (neue Tabelle
feed_tokens + Migration, Verwaltung in den Nutzer-Einstellungen,
FeedTokensSection). Öffentliche HTML-Seiten annoncieren den Teich-Feed
per link rel=alternate. DB-Tests (anonym/privat/Token-Lifecycle) und
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:53 +02:00

233 lines
9.3 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.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;
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 readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
) {}
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 },
});
// 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),
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 ?? '');
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited);
}
/**
* 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);
}