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
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
import { Controller, Get, NotFoundException, Param, Query, Req, Res } from '@nestjs/common';
|
|
import type { PageCommentsView } from '@dorfteich/shared';
|
|
import type { Response } from 'express';
|
|
|
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
|
import { readActorOf } from '../read-trail/read-actor';
|
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
import { FeedService } from './feed.service';
|
|
import { PublicPageContent, PublicService } from './public.service';
|
|
|
|
/**
|
|
* Public read endpoints (issue #56). `@Public()` so anonymous visitors reach
|
|
* them; the service enforces the `public` grant through the shared resolver and
|
|
* 404s otherwise (non-public pages never leak). Two shapes: JSON for the SPA's
|
|
* read-only view, and a self-contained HTML document for crawlers / PDF export.
|
|
*/
|
|
@Controller('public')
|
|
export class PublicController {
|
|
constructor(
|
|
private readonly publicPages: PublicService,
|
|
private readonly feeds: FeedService,
|
|
private readonly settings: InstanceSettingsService,
|
|
) {}
|
|
|
|
/** Feed master switch (issue #191): disabled ⇒ 404, existence hidden. */
|
|
private async assertFeedsEnabled(): Promise<void> {
|
|
if (!(await this.settings.get('feeds.enabled'))) throw new NotFoundException();
|
|
}
|
|
|
|
// The feed routes come FIRST: `:pondSlug/feed.xml` would otherwise be
|
|
// swallowed by the `:pondSlug/:pageSlug` HTML route below (issue #149).
|
|
@Get(':pondSlug/feed.xml')
|
|
@Public()
|
|
async pondFeed(
|
|
@Param('pondSlug') pondSlug: string,
|
|
@Query('token') token: string | undefined,
|
|
@Req() request: AuthedRequest,
|
|
@Res({ passthrough: true }) response: Response,
|
|
): Promise<string> {
|
|
await this.assertFeedsEnabled();
|
|
const viewer = await this.feeds.viewerFor(request.user ?? null, token);
|
|
const xml = await this.feeds.pondFeed(viewer, pondSlug, baseUrlOf(request));
|
|
response.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
|
return xml;
|
|
}
|
|
|
|
@Get(':pondSlug/:pageSlug/feed.xml')
|
|
@Public()
|
|
async pageFeed(
|
|
@Param('pondSlug') pondSlug: string,
|
|
@Param('pageSlug') pageSlug: string,
|
|
@Query('token') token: string | undefined,
|
|
@Req() request: AuthedRequest,
|
|
@Res({ passthrough: true }) response: Response,
|
|
): Promise<string> {
|
|
await this.assertFeedsEnabled();
|
|
const viewer = await this.feeds.viewerFor(request.user ?? null, token);
|
|
const xml = await this.feeds.pageFeed(viewer, pondSlug, pageSlug, baseUrlOf(request));
|
|
response.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
|
return xml;
|
|
}
|
|
|
|
@Get(':pondSlug/:pageSlug/content')
|
|
@Public()
|
|
async content(
|
|
@Param('pondSlug') pondSlug: string,
|
|
@Param('pageSlug') pageSlug: string,
|
|
@Req() request: AuthedRequest,
|
|
): Promise<PublicPageContent> {
|
|
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, {
|
|
actor: readActorOf(request),
|
|
channel: 'page_view',
|
|
});
|
|
}
|
|
|
|
@Get(':pondSlug/:pageSlug/comments')
|
|
@Public()
|
|
async comments(
|
|
@Param('pondSlug') pondSlug: string,
|
|
@Param('pageSlug') pageSlug: string,
|
|
@Req() request: AuthedRequest,
|
|
): Promise<PageCommentsView> {
|
|
return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug);
|
|
}
|
|
|
|
@Get(':pondSlug/:pageSlug')
|
|
@Public()
|
|
async html(
|
|
@Param('pondSlug') pondSlug: string,
|
|
@Param('pageSlug') pageSlug: string,
|
|
@Req() request: AuthedRequest,
|
|
@Res({ passthrough: true }) response: Response,
|
|
): Promise<string> {
|
|
const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`;
|
|
const html = await this.publicPages.html(
|
|
request.user ?? null,
|
|
pondSlug,
|
|
pageSlug,
|
|
canonical,
|
|
readActorOf(request),
|
|
);
|
|
response.set('Content-Type', 'text/html; charset=utf-8');
|
|
return html;
|
|
}
|
|
}
|
|
|
|
function baseUrlOf(request: AuthedRequest): string {
|
|
return `${request.protocol}://${request.get('host') ?? ''}`;
|
|
}
|