All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m52s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m34s
CI / Import/export fidelity gate (push) Successful in 58s
Chosen path: an instance master switch following the api.enabled/ mcp.enabled pattern — while off, both feed routes AND the feed-token management answer 404 (existence hidden). Default ON: feeds predate the switch, existing instances and their subscribed readers keep working; the VS-NfD reference configuration (#227) turns it off. Admin UI gets the toggle next to the API/MCP switches (i18n de+en). Moving the token out of the query string is documented as rejected: a path segment lands in the same proxy and request logs, and feed readers cannot send headers — that is why the credential is in the URL at all. What DID leak was our own request log (pino logs req.url): the req serializer now masks ?token= values (common/mask-token-param.ts), so no code path logs the credential. Refs #191 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
100 lines
3.7 KiB
TypeScript
100 lines
3.7 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 { 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);
|
|
}
|
|
|
|
@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);
|
|
response.set('Content-Type', 'text/html; charset=utf-8');
|
|
return html;
|
|
}
|
|
}
|
|
|
|
function baseUrlOf(request: AuthedRequest): string {
|
|
return `${request.protocol}://${request.get('host') ?? ''}`;
|
|
}
|