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 { 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 { 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 { 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 { 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 { 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 { 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') ?? ''}`; }