import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BrandingAsset, BrandingView, FAVICON_SIZES, FaviconSize, LOGO_VARIANTS, LogoVariant, PondBranding, pondSettingsSchema, MAX_BRANDING_BYTES, MAX_LOGO_EDGE, hasPngMagic, looksLikeSvg, pngDimensions, } from '@dorfteich/shared'; import { User } from '@prisma/client'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { BrandingStorageService } from './branding-storage.service'; /** The settings key each instance asset's metadata lives under. */ const INSTANCE_KEYS = { logoLight: 'instance.logo', logoDark: 'instance.logoDark', favicon: 'instance.favicon', } as const; /** * Instance branding (issue #306): the logo shown at the top of the sidebar and * the favicon served to the browser. * * The api stores and serves bytes; it never decodes them. Validation is the * PNG signature, the IHDR dimensions and the size cap — see * `packages/shared/src/branding.ts` for why that line is drawn there. */ @Injectable() export class BrandingService { constructor( private readonly settings: InstanceSettingsService, private readonly storage: BrandingStorageService, private readonly audit: AuditService, private readonly prisma: PrismaService, private readonly quotas: QuotaService, ) {} static logoKey(variant: LogoVariant): string { return `instance-logo-${variant}`; } static faviconKey(size: FaviconSize): string { return `instance-favicon-${size}`; } /** Pond assets share the directory and the naming rules (issue #307); the * pond id keeps them apart and makes purge a prefix delete. */ static pondLogoKey(pondId: string, variant: LogoVariant): string { return `pond-${pondId}-logo-${variant}`; } static pondFaviconKey(pondId: string, size: FaviconSize): string { return `pond-${pondId}-favicon-${size}`; } /** Every branding file a pond can own — the purge deletes exactly this set * (issue #307). The purge standard is absolute: after it, nothing * referencing the pond survives, rows or files. */ static pondKeys(pondId: string): string[] { return [ ...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)), ...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)), ]; } /** * Rejects anything that is not a PNG within the caps, before a byte is * written. SVG gets its own message: an operator who tried one deserves to * learn that it is refused on purpose, not that "the file is broken". */ private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } { if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' }); if (bytes.length > MAX_BRANDING_BYTES) { throw new BadRequestException({ code: 'branding_file_too_large' }); } if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' }); if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' }); const size = pngDimensions(bytes); if (!size) throw new BadRequestException({ code: 'branding_not_a_png' }); if (size.width > maxEdge || size.height > maxEdge) { throw new BadRequestException({ code: 'branding_image_too_large' }); } return size; } /** * Reserve the pond's storage for a branding asset, releasing what the asset * it replaces occupied. Doing it in that order means replacing a logo with * one of the same size costs nothing — otherwise every re-upload would eat * the quota again, which is how "a pond admin fills the disk with logos" * happens. */ private async chargeQuota( pond: { id: string; ownerId: string }, bytes: number, previous: BrandingAsset | null, ): Promise { if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize); try { await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes); } catch (error) { // Put the released reservation back: a refused upload must not leave // the pond with MORE room than before. if (previous?.byteSize) { await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize); } throw error; } } private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset { return { // Short digest: it only has to change when the bytes change, and it // travels in every logo URL. hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16), byteSize: bytes.length, ...size, }; } async view(): Promise { const [logo, logoDark, favicon, instanceName] = await Promise.all([ this.settings.get(INSTANCE_KEYS.logoLight), this.settings.get(INSTANCE_KEYS.logoDark), this.settings.get(INSTANCE_KEYS.favicon), this.settings.get('instance.name'), ]); return { logo, logoDark, favicon, instanceName }; } async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise { const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE); await this.storage.save(BrandingService.logoKey(variant), bytes); await this.settings.set( variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight, this.assetOf(bytes, size), admin.id, ); await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set'); return this.view(); } async clearLogo(admin: User, variant: LogoVariant): Promise { await this.storage.remove(BrandingService.logoKey(variant)); await this.settings.set( variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight, null, admin.id, ); await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared'); return this.view(); } /** * Both favicon sizes arrive together: the browser produced them from one * source on the same canvas, and the api cannot resize. Storing them as a * pair keeps the tab icon and the home-screen icon from ever showing two * different images. */ async setFavicon(admin: User, files: Record): Promise { const sizes = Object.entries(files).map(([declared, bytes]) => { const size = this.assertUsablePng(bytes, 512); const expected = Number(declared); if (size.width !== expected || size.height !== expected) { throw new BadRequestException({ code: 'branding_favicon_not_square' }); } return { expected: expected as FaviconSize, bytes, size }; }); for (const entry of sizes) { await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes); } // The 32px variant identifies the pair — it is what the tab shows. const small = sizes.find((entry) => entry.expected === 32)!; await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id); await this.record(admin, 'favicon', 'set'); return this.view(); } async clearFavicon(admin: User): Promise { await this.storage.remove(BrandingService.faviconKey(32)); await this.storage.remove(BrandingService.faviconKey(180)); await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id); await this.record(admin, 'favicon', 'cleared'); return this.view(); } /** The bytes to serve for a logo variant, or null when none is stored. */ logoBytes(variant: LogoVariant): Promise { return this.storage.read(BrandingService.logoKey(variant)); } /** * The favicon bytes: the uploaded one, else the shipped default. The * `` in index.html is static, so this route must always * answer with an image — a 404 there would leave the browser's generic * icon for good. */ async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> { const stored = await this.storage.read(BrandingService.faviconKey(size)); if (stored) return { bytes: stored, uploaded: true }; const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`)); return { bytes, uploaded: false }; } /** The pond's own branding, defaulted — one place reads the settings blob. */ async pondBranding(pondId: string): Promise { const pond = await this.prisma.pond.findUnique({ where: { id: pondId }, select: { settings: true }, }); if (!pond) throw new NotFoundException(); return pondSettingsSchema.parse(pond.settings ?? {}).branding; } private async writePondBranding( actor: User, pondId: string, next: PondBranding, asset: 'logo' | 'logoDark' | 'favicon', change: 'set' | 'cleared', ): Promise { const pond = await this.prisma.pond.findUniqueOrThrow({ where: { id: pondId }, select: { settings: true }, }); const settings = pondSettingsSchema.parse(pond.settings ?? {}); await this.prisma.pond.update({ where: { id: pondId }, data: { settings: { ...settings, branding: next } as object }, }); await this.audit.record({ action: 'branding.changed', actorId: actor.id, targetType: 'pond', targetId: pondId, details: { scope: 'pond', pondId, asset, change }, }); return next; } /** * A pond logo, charged to the pond's storage quota (issue #307). * * Without the charge, branding would be a way around the quota — and * replacing a logo repeatedly would let a pond admin consume disk with no * ceiling. Charged BEFORE the write, like attachments, so a race never * leaves bytes on the volume without a reservation; the bytes a replaced * asset frees are released first, so re-uploading the same logo is free * rather than cumulative. */ async setPondLogo( actor: User, pond: { id: string; ownerId: string }, variant: LogoVariant, bytes: Buffer, ): Promise { const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE); const current = await this.pondBranding(pond.id); const previous = variant === 'dark' ? current.logoDark : current.logo; await this.chargeQuota(pond, bytes.length, previous); await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes); const asset = this.assetOf(bytes, size); return this.writePondBranding( actor, pond.id, variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset }, variant === 'dark' ? 'logoDark' : 'logo', 'set', ); } async clearPondLogo( actor: User, pond: { id: string; ownerId: string }, variant: LogoVariant, ): Promise { const current = await this.pondBranding(pond.id); const previous = variant === 'dark' ? current.logoDark : current.logo; await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant)); if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize); return this.writePondBranding( actor, pond.id, variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null }, variant === 'dark' ? 'logoDark' : 'logo', 'cleared', ); } async setPondFavicon( actor: User, pond: { id: string; ownerId: string }, files: Record, ): Promise { const checked = Object.entries(files).map(([declared, bytes]) => { const size = this.assertUsablePng(bytes, 512); const expected = Number(declared); if (size.width !== expected || size.height !== expected) { throw new BadRequestException({ code: 'branding_favicon_not_square' }); } return { expected: expected as FaviconSize, bytes, size }; }); const current = await this.pondBranding(pond.id); const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0); await this.chargeQuota(pond, total, current.favicon); for (const entry of checked) { await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes); } const small = checked.find((entry) => entry.expected === 32)!; // The pair is charged together, so the stored size is the pair's — that // is what a later release has to give back. const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total }; return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set'); } async clearPondFavicon( actor: User, pond: { id: string; ownerId: string }, ): Promise { const current = await this.pondBranding(pond.id); for (const size of FAVICON_SIZES) { await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size)); } if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize); return this.writePondBranding( actor, pond.id, { ...current, favicon: null }, 'favicon', 'cleared', ); } /** Bytes for a pond asset — null when the pond has none at that slot, which * is what makes the caller fall back to the instance level. */ pondLogoBytes(pondId: string, variant: LogoVariant): Promise { return this.storage.read(BrandingService.pondLogoKey(pondId, variant)); } pondFaviconBytes(pondId: string, size: FaviconSize): Promise { return this.storage.read(BrandingService.pondFaviconKey(pondId, size)); } /** Removes every branding file of a pond (issue #307's purge obligation). */ async removePondAssets(pondId: string): Promise { for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key); } private record( admin: User, asset: 'logo' | 'logoDark' | 'favicon', action: 'set' | 'cleared', ): Promise { // `scope` is here from the start so the pond-level change (#307) is the // same event with a different scope, not a second id in the catalogue. return this.audit.record({ action: 'branding.changed', actorId: admin.id, targetType: 'setting', targetId: `instance.${asset}`, details: { scope: 'instance', asset, change: action }, }); } }