import { BadRequestException, Controller, Delete, Get, NotFoundException, Param, Post, Query, Req, Res, UploadedFiles, UseGuards, UseInterceptors, } from '@nestjs/common'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { BrandingView, FAVICON_SIZES, FaviconSize, LOGO_VARIANTS, LogoVariant, MAX_BRANDING_BYTES, PondBranding, } from '@dorfteich/shared'; import type { Response } from 'express'; import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest, Public } from '../auth/auth.guard'; import { RequiresPondRole } from '../permissions/permission.decorators'; import { PrismaService } from '../prisma/prisma.service'; import { BrandingService } from './branding.service'; function parseVariant(value: unknown): LogoVariant { if (!LOGO_VARIANTS.includes(value as LogoVariant)) { throw new BadRequestException({ code: 'bad_request' }); } return value as LogoVariant; } /** * Public branding surface (issue #306). * * Unauthenticated by design and worth stating plainly in the admin UI: the * login screen carries the branding and the browser fetches the favicon before * anyone signs in, so an operator's logo IS visible to anonymous visitors. */ @Controller('branding') export class BrandingController { constructor(private readonly branding: BrandingService) {} @Public() @Get() view(): Promise { return this.branding.view(); } @Public() @Get('logo') async logo( @Query('variant') variant: string | undefined, @Query('pond') pondId: string | undefined, @Res() res: Response, ): Promise { const wanted = parseVariant(variant ?? 'light'); // A pond scope serves the pond's own bytes and nothing else: the caller // already resolved WHICH level applies (`resolveBranding`), so silently // falling back here would mix variants across levels — exactly what #307 // forbids. const bytes = pondId ? await this.branding.pondLogoBytes(pondId, wanted) : await this.branding.logoBytes(wanted); // No shipped default: without a logo the app renders the instance NAME as // text, so an empty answer here is the honest one. if (!bytes) { res.status(404).json({ code: 'not_found', message: 'no logo' }); return; } res.setHeader('Content-Type', 'image/png'); // The caller puts the content hash in the query string, so a given URL // never changes what it points at. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.send(bytes); } @Public() @Get('favicon') async favicon( @Query('size') size: string | undefined, @Query('pond') pondId: string | undefined, @Res() res: Response, ): Promise { const wanted = Number(size ?? 32); if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) { throw new BadRequestException({ code: 'bad_request' }); } const pondBytes = pondId ? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize) : null; const { bytes, uploaded } = pondBytes ? { bytes: pondBytes, uploaded: true } : await this.branding.faviconBytes(wanted as FaviconSize); res.setHeader('Content-Type', 'image/png'); // The `` href is a constant in index.html, so this URL // cannot carry a hash — revalidation is the only way a replaced favicon // ever reaches a browser that already has one. res.setHeader('Cache-Control', 'no-cache'); res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`); res.send(bytes); } } /** Site-Admin management of the instance branding (issue #306). */ @Controller('admin/branding') @UseGuards(SiteAdminGuard) export class BrandingAdminController { constructor(private readonly branding: BrandingService) {} @Post('logo') @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) async setLogo( @Query('variant') variant: string | undefined, @Req() request: AuthedRequest, @UploadedFiles() files: Express.Multer.File[] | undefined, ): Promise { const file = files?.find((entry) => entry.fieldname === 'file'); if (!file) throw new BadRequestException({ code: 'branding_file_missing' }); return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer); } @Delete('logo') clearLogo( @Query('variant') variant: string | undefined, @Req() request: AuthedRequest, ): Promise { return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light')); } @Post('favicon') @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) async setFavicon( @Req() request: AuthedRequest, @UploadedFiles() files: Express.Multer.File[] | undefined, ): Promise { // Field names are the pixel sizes the browser rendered: `png-32`, `png-180`. const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer])); const collected = {} as Record; for (const size of FAVICON_SIZES) { const bytes = byField.get(`png-${size}`); if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' }); collected[size] = bytes; } return this.branding.setFavicon(request.user!, collected); } @Delete('favicon') clearFavicon(@Req() request: AuthedRequest): Promise { return this.branding.clearFavicon(request.user!); } } /** * Pond-level branding (issue #307). The uploader here is an ordinary Pond * Admin rather than the operator, so the security rules of #306 are not * relaxed by a single line: SVG refused, magic bytes checked server-side, * size caps enforced, content type pinned on serving, no image parsing. * * 404/403 policy: a user who cannot see the pond gets 404 from the pond-role * guard, one who can see but not administer it gets 403. */ @Controller('ponds/:pondId/branding') export class PondBrandingController { constructor( private readonly branding: BrandingService, private readonly prisma: PrismaService, ) {} /** The pond row the quota is charged to. */ private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> { const pond = await this.prisma.pond.findUnique({ where: { id: pondId }, select: { id: true, ownerId: true }, }); if (!pond) throw new NotFoundException(); return pond; } @Get() @RequiresPondRole('reader', { idParam: 'pondId' }) view(@Param('pondId') pondId: string): Promise { return this.branding.pondBranding(pondId); } @Post('logo') @RequiresPondRole('pond_admin', { idParam: 'pondId' }) @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) async setLogo( @Param('pondId') pondId: string, @Query('variant') variant: string | undefined, @Req() request: AuthedRequest, @UploadedFiles() files: Express.Multer.File[] | undefined, ): Promise { const file = files?.find((entry) => entry.fieldname === 'file'); if (!file) throw new BadRequestException({ code: 'branding_file_missing' }); return this.branding.setPondLogo( request.user!, await this.pondOf(pondId), parseVariant(variant ?? 'light'), file.buffer, ); } @Delete('logo') @RequiresPondRole('pond_admin', { idParam: 'pondId' }) async clearLogo( @Param('pondId') pondId: string, @Query('variant') variant: string | undefined, @Req() request: AuthedRequest, ): Promise { return this.branding.clearPondLogo( request.user!, await this.pondOf(pondId), parseVariant(variant ?? 'light'), ); } @Post('favicon') @RequiresPondRole('pond_admin', { idParam: 'pondId' }) @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) async setFavicon( @Param('pondId') pondId: string, @Req() request: AuthedRequest, @UploadedFiles() files: Express.Multer.File[] | undefined, ): Promise { const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer])); const collected = {} as Record; for (const size of FAVICON_SIZES) { const bytes = byField.get(`png-${size}`); if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' }); collected[size] = bytes; } return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected); } @Delete('favicon') @RequiresPondRole('pond_admin', { idParam: 'pondId' }) async clearFavicon( @Param('pondId') pondId: string, @Req() request: AuthedRequest, ): Promise { return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId)); } }