Files
TREK/server/src/nest/feeds/feeds.controller.ts
T

146 lines
5.2 KiB
TypeScript

import {
Controller,
Delete,
Get,
Param,
Post,
Put,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { contentDisposition } from '../common/content-disposition';
import { FeedsService } from './feeds.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { User } from '../../types';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { Public } from '../auth/public.decorator';
// Resolve the public origin used to build feed URLs. APP_URL wins — it is the
// canonical externally-reachable URL behind a reverse proxy. When it is unset
// (the default on a plain `docker run`), fall back to the request's own host so
// the link is still absolute and copy-pasteable as webcal:// instead of a dead
// relative path.
function resolveFeedBase(req: Request): string {
// Single trailing slash stripped on purpose (legacy parity; notifications strips all).
const configured = (readEnv().app.appUrl || '').replace(/\/$/, '');
if (configured) return configured;
const host = req.get('host');
return host ? `${req.protocol}://${host}` : '';
}
/**
* Public subscribable ICS feed endpoints — no auth required.
* The secret token in the URL acts as the access credential.
*/
@Controller('api/feed')
@Public('the secret token in the feed URL is the credential; calendar clients never send a session')
export class FeedsPublicController {
constructor(private readonly feeds: FeedsService) {}
@Get('trip/:token.ics')
tripFeed(@Param('token') token: string, @Res() res: Response): void {
const result = this.feeds.buildTripIcs(token);
if (!result) {
res.status(404).json({ error: 'Feed not found' });
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', contentDisposition(result.filename, 'inline'));
res.setHeader('Cache-Control', 'no-cache, no-store');
res.setHeader('X-Published-TTL', 'PT1H');
res.send(result.ics);
}
@Get('user/:token.ics')
userFeed(@Param('token') token: string, @Res() res: Response): void {
const result = this.feeds.buildUserIcs(token);
if (!result) {
res.status(404).json({ error: 'Feed not found' });
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `inline; filename="all-trips.ics"`);
res.setHeader('Cache-Control', 'no-cache, no-store');
res.setHeader('X-Published-TTL', 'PT1H');
res.send(result.ics);
}
}
/**
* Authenticated token management for a single trip's feed.
* POST = enable (mint a token, idempotent)
* PUT = rotate (new token, invalidates the old URL)
* DELETE = disable (clear the token, public URL stops resolving)
*
* All four need `share_manage`, not merely trip access. The token is the only
* credential the anonymous /api/feed/trip/:token.ics route asks for, so handing
* it out is handing out the trip, and rotating it cancels every subscription the
* owner and the other members already set up. Reading is gated for the same
* reason as writing: GET returns the credential itself, not its status. Same
* call the invite link makes (trip-invite.controller.ts), and the default policy
* keeps `share_manage` with the owner while an instance may lower it to
* trip_member.
*/
@Controller('api/trips/:tripId/feed')
@UseGuards(JwtAuthGuard, TripAccessGuard)
@RequirePermission('share_manage')
export class TripFeedTokenController {
constructor(private readonly feeds: FeedsService) {}
@Get('token')
get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) {
return this.feeds.getTripToken(tripId, user.id, resolveFeedBase(req));
}
@Post('token')
generate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) {
return this.feeds.generateTripToken(tripId, user.id, resolveFeedBase(req));
}
@Put('token')
rotate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) {
return this.feeds.rotateTripToken(tripId, user.id, resolveFeedBase(req));
}
@Delete('token')
disable(@CurrentUser() user: User, @Param('tripId') tripId: string) {
this.feeds.disableTripToken(tripId, user.id);
return { feed_url: null };
}
}
/**
* Authenticated token management for the all-trips (per-user) feed.
* POST = enable PUT = rotate DELETE = disable
*/
@Controller('api/feed/user')
@UseGuards(JwtAuthGuard)
export class UserFeedTokenController {
constructor(private readonly feeds: FeedsService) {}
@Get('token')
get(@CurrentUser() user: User, @Req() req: Request) {
return this.feeds.getUserToken(user.id, resolveFeedBase(req));
}
@Post('token')
generate(@CurrentUser() user: User, @Req() req: Request) {
return this.feeds.generateUserToken(user.id, resolveFeedBase(req));
}
@Put('token')
rotate(@CurrentUser() user: User, @Req() req: Request) {
return this.feeds.rotateUserToken(user.id, resolveFeedBase(req));
}
@Delete('token')
disable(@CurrentUser() user: User) {
this.feeds.disableUserToken(user.id);
return { feed_url: null };
}
}