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
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
233 lines
9.3 KiB
TypeScript
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);
|
|
}
|