diff --git a/apps/api/src/branding/branding.controller.ts b/apps/api/src/branding/branding.controller.ts index 83a1b71..51e2857 100644 --- a/apps/api/src/branding/branding.controller.ts +++ b/apps/api/src/branding/branding.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + NotFoundException, + Param, Post, Query, Req, @@ -19,11 +21,14 @@ import { 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 { @@ -52,8 +57,19 @@ export class BrandingController { @Public() @Get('logo') - async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise { - const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light')); + 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) { @@ -69,12 +85,21 @@ export class BrandingController { @Public() @Get('favicon') - async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise { + 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 { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize); + 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 @@ -133,3 +158,96 @@ export class BrandingAdminController { 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)); + } +} diff --git a/apps/api/src/branding/branding.module.ts b/apps/api/src/branding/branding.module.ts index f4845cf..6d9f4f1 100644 --- a/apps/api/src/branding/branding.module.ts +++ b/apps/api/src/branding/branding.module.ts @@ -1,6 +1,13 @@ import { Module } from '@nestjs/common'; -import { BrandingAdminController, BrandingController } from './branding.controller'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { QuotasModule } from '../quotas/quotas.module'; + +import { + BrandingAdminController, + BrandingController, + PondBrandingController, +} from './branding.controller'; import { BrandingStorageService } from './branding-storage.service'; import { BrandingService } from './branding.service'; @@ -8,7 +15,8 @@ import { BrandingService } from './branding.service'; * the pond-level override (#307) can build on the same storage and the same * resolution path instead of a parallel one. */ @Module({ - controllers: [BrandingController, BrandingAdminController], + imports: [PermissionsModule, QuotasModule], + controllers: [BrandingController, BrandingAdminController, PondBrandingController], providers: [BrandingService, BrandingStorageService], exports: [BrandingService, BrandingStorageService], }) diff --git a/apps/api/src/branding/branding.service.ts b/apps/api/src/branding/branding.service.ts index 08fb9c9..07253cc 100644 --- a/apps/api/src/branding/branding.service.ts +++ b/apps/api/src/branding/branding.service.ts @@ -2,12 +2,16 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { BadRequestException, Injectable } from '@nestjs/common'; +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, @@ -17,6 +21,8 @@ import { 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'; @@ -41,6 +47,8 @@ export class BrandingService { 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 { @@ -51,6 +59,26 @@ export class BrandingService { 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 @@ -71,11 +99,37 @@ export class BrandingService { 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, }; } @@ -164,6 +218,150 @@ export class BrandingService { 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', diff --git a/apps/api/src/branding/pond-branding.e2e.db.test.ts b/apps/api/src/branding/pond-branding.e2e.db.test.ts new file mode 100644 index 0000000..7828503 --- /dev/null +++ b/apps/api/src/branding/pond-branding.e2e.db.test.ts @@ -0,0 +1,256 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deflateSync } from 'node:zlib'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { + createTestPrisma, + deletePondsWhere, + grantOwnerAdmin, + hasTestDb, + uniqueSuffix, +} from '../testing/test-db'; +import { TrashService } from '../trash/trash.service'; +import { UsersService } from '../users/users.service'; + +import { BrandingService } from './branding.service'; +import { BrandingStorageService } from './branding-storage.service'; + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} +/** A real PNG — the api reads the IHDR, so the header has to be genuine. */ +function png(size: number): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let storage: BrandingStorageService; + let brandingDir: string; + const suffix = uniqueSuffix(); + const password = 'teichmarke mit eigenem logo 1'; + const owner = { username: `pb-${suffix}` }; + const member = { username: `pbm-${suffix}` }; + let ownerCookie: string; + let memberCookie: string; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-')); + process.env.BRANDING_DIR = brandingDir; + app = await createTestApp(); + storage = app.get(BrandingStorageService); + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + // Verification through the endpoint, not `markEmailVerified`: only this + // path creates the personal pond these tests brand. + const verify = async (userId: string): Promise => { + await api() + .post('/api/v1/auth/verify-email') + .send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) }) + .expect(204); + }; + + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: `Pond Branding Owner ${suffix}`, + password, + locale: 'en', + }); + await verify(ownerUser.id); + const memberUser = await users.createUser({ + username: member.username, + email: `${member.username}@example.org`, + displayName: `Pond Branding Member ${suffix}`, + password, + locale: 'en', + }); + await verify(memberUser.id); + + const login = async (username: string): Promise => + sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + ownerCookie = await login(owner.username); + memberCookie = await login(member.username); + + pondId = ( + await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } }) + ).id; + // A reader on the same pond: may see it, may not administer it. Through + // the API, not a raw row — the permission cache would not see the row + // (the documented rule for grants in tests). + await api() + .post(`/api/v1/ponds/${pondId}/grants`) + .set('Cookie', ownerCookie) + .send({ + subjectType: 'user', + subjectId: memberUser.id, + role: 'reader', + scopeType: 'pond', + effect: 'allow', + }) + .expect(201); + }); + + afterAll(async () => { + await prisma.roleGrant.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + await rm(brandingDir, { recursive: true, force: true }); + delete process.env.BRANDING_DIR; + }); + + it('stores a pond logo, reports it, and serves it under the pond scope', async () => { + const view = await api() + .post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`) + .set('Cookie', ownerCookie) + .attach('file', png(64), 'logo.png') + .expect(201); + expect(view.body.logo).toMatchObject({ width: 64, height: 64 }); + + const served = await api() + .get(`/api/v1/branding/logo?variant=light&pond=${pondId}`) + .expect(200); + expect(served.headers['content-type']).toContain('image/png'); + + // Without the pond scope the instance level answers — 404 here, since no + // instance logo is set. The two levels never leak into each other. + await api().get('/api/v1/branding/logo?variant=light').expect(404); + }); + + it('charges the pond quota and gives the bytes back when the logo is replaced', async () => { + const usageOf = async (): Promise => + Number( + ( + await prisma.pondUsage.findUnique({ + where: { pondId }, + select: { storageBytesUsed: true }, + }) + )?.storageBytesUsed ?? 0, + ); + const before = await usageOf(); + + const big = png(120); + await api() + .post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`) + .set('Cookie', ownerCookie) + .attach('file', big, 'logo.png') + .expect(201); + const afterUpload = await usageOf(); + expect(afterUpload).toBe(before + big.length); + + // Replacing releases the old reservation first — otherwise re-uploading + // the same logo would eat the quota again and again. + await api() + .post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`) + .set('Cookie', ownerCookie) + .attach('file', big, 'logo.png') + .expect(201); + expect(await usageOf()).toBe(afterUpload); + + await api() + .delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`) + .set('Cookie', ownerCookie) + .expect(200); + expect(await usageOf()).toBe(before); + }); + + it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => { + const res = await api() + .post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from(''), 'x.png') + .expect(400); + expect(res.body.code).toBe('branding_svg_rejected'); + }); + + it('lets a member read the pond branding but not change it', async () => { + await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200); + await api() + .post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`) + .set('Cookie', memberCookie) + .attach('file', png(32), 'x.png') + .expect(403); + await api() + .delete(`/api/v1/ponds/${pondId}/branding/favicon`) + .set('Cookie', memberCookie) + .expect(403); + }); + + it('purging the pond removes its branding files', async () => { + // A pond of its own, so the purge does not take the shared fixture with it. + const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } }); + const created = await prisma.pond.create({ + data: { + name: `Purge Branding ${suffix}`, + slug: `purge-branding-${suffix}`, + type: 'SHARED', + ownerId: ownerRow.id, + }, + }); + // Raw grant row, before this pond's first permission query — the + // documented exception to "grants through the API". + await grantOwnerAdmin(prisma, created.id, ownerRow.id); + await api() + .post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`) + .set('Cookie', ownerCookie) + .attach('file', png(48), 'logo.png') + .expect(201); + expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull(); + + await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } }); + const trash = app.get(TrashService); + await trash.purgePondNow(ownerRow, created.id); + + // The purge standard is absolute: after it nothing referencing the pond + // survives — rows OR files. + expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull(); + }); +}); diff --git a/apps/api/src/trash/trash.module.ts b/apps/api/src/trash/trash.module.ts index 0bf688e..26b2cb1 100644 --- a/apps/api/src/trash/trash.module.ts +++ b/apps/api/src/trash/trash.module.ts @@ -1,5 +1,6 @@ import { Module, OnModuleInit } from '@nestjs/common'; +import { BrandingModule } from '../branding/branding.module'; import { CommonModule } from '../common/common.module'; import { FilesModule } from '../files/files.module'; import { PagesModule } from '../pages/pages.module'; @@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60; @Module({ imports: [ + BrandingModule, CommonModule, PondsModule, QuotasModule, diff --git a/apps/api/src/trash/trash.service.ts b/apps/api/src/trash/trash.service.ts index 89b3afa..045a54b 100644 --- a/apps/api/src/trash/trash.service.ts +++ b/apps/api/src/trash/trash.service.ts @@ -4,6 +4,7 @@ import { User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AuditService } from '../audit/audit.service'; +import { BrandingService } from '../branding/branding.service'; import { ClockService } from '../common/clock.service'; import { SearchProvider } from '../search/search.provider'; import { PagesService } from '../pages/pages.service'; @@ -32,6 +33,7 @@ export class TrashService { private readonly settings: InstanceSettingsService, private readonly quotas: QuotaService, private readonly storage: FileStorageService, + private readonly branding: BrandingService, private readonly clock: ClockService, private readonly watches: WatchesService, private readonly audit: AuditService, @@ -183,6 +185,9 @@ export class TrashService { for (const attachment of attachments) { await this.storage.delete(pondId, attachment.id); } + // The pond's branding files (issue #307). The purge standard is absolute: + // after it, nothing referencing the pond survives — rows OR files. + await this.branding.removePondAssets(pondId); const pageIds = ( await this.prisma.page.findMany({ where: { pondId }, select: { id: true } }) ).map((page) => page.id); diff --git a/apps/web/src/branding/BrandLogo.tsx b/apps/web/src/branding/BrandLogo.tsx index 45b04f8..469f982 100644 --- a/apps/web/src/branding/BrandLogo.tsx +++ b/apps/web/src/branding/BrandLogo.tsx @@ -1,50 +1,57 @@ import { Link } from 'react-router-dom'; -import { logoUrl, useBranding } from './use-branding'; +import { useCurrentPondRoute } from '../layout/use-pond-route'; +import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding'; +import { usePondId } from './use-pond-id'; /** - * The instance identity at the top of the sidebar (issue #306): the uploaded - * logo as a link home, or the instance name as text when nothing is uploaded. + * The identity at the top of the sidebar (issues #306/#307): the pond's own + * logo when it has one, else the instance's, else the instance name as text. * - * Its accessible name is the INSTANCE NAME, never "logo": for a screen reader - * this is the link home, and a link's name has to say where it goes. The - * images are therefore `alt=""` — the link is already named. + * Its accessible name follows the LEVEL the logo came from — a pond logo is + * named by the pond, an instance logo by the instance. For a screen reader + * this is the link home, and a link's name has to say where it goes; keeping + * the instance name on a pond logo would announce the wrong destination. * * Both variants are rendered and one is hidden by CSS (`:root[data-theme]`), * not by JavaScript: `theme-init.js` resolves the theme before first paint, so - * the correct logo is the one painted rather than the one that appears after a - * flash. Without a dark variant the light one carries both themes — the - * operator's own asset, shown unchanged, rather than a substitute they did - * not choose (the rule #307 extends to ponds). + * the correct logo is the one painted. A logo set belongs to ONE level and is + * never mixed across levels — see `resolveBranding`. */ export function BrandLogo(): React.JSX.Element | null { - const branding = useBranding(); - if (!branding) return null; - const { logo, logoDark, instanceName } = branding; + const { pondSlug } = useCurrentPondRoute(); + const { pondId, pondName } = usePondId(pondSlug); + const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId); + usePondFavicon(pondId, resolved.faviconLevel === 'pond'); + + const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName; + if (!instanceName && resolved.logoLevel === 'none') return null; return ( - - {logo ? ( + + {resolved.logo || resolved.logoDark ? ( <> - - {logoDark && ( + {resolved.logo && ( + )} + {resolved.logoDark && ( + )} ) : ( - {instanceName} + {name} )} ); diff --git a/apps/web/src/branding/use-branding.ts b/apps/web/src/branding/use-branding.ts index 4d0ceb8..9103628 100644 --- a/apps/web/src/branding/use-branding.ts +++ b/apps/web/src/branding/use-branding.ts @@ -1,5 +1,6 @@ -import { BrandingView } from '@dorfteich/shared'; +import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { apiGet } from '../lib/api'; @@ -21,9 +22,65 @@ export function useBranding(): BrandingView | undefined { } /** URL of a logo variant, with the content hash so a replaced logo is never - * served from cache. */ -export function logoUrl(variant: 'light' | 'dark', hash: string): string { - return `/api/v1/branding/logo?variant=${variant}&v=${hash}`; + * served from cache. `pondId` scopes it to a pond's own asset (issue #307); + * the route never falls back on its own — the CALLER decided which level + * applies, and a silent fallback here would mix variants across levels. */ +export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string { + const pond = pondId ? `&pond=${pondId}` : ''; + return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`; +} + +/** + * The branding in force here: the pond's own, else the instance's, else the + * default (issue #307). One helper, in shared, so api and web cannot drift. + */ +export function useResolvedBranding(pondId?: string): { + resolved: ResolvedBranding; + instanceName: string; + /** Which level the logo came from — the asset URLs need the pond scope + * exactly when the pond supplied it. */ + pondId?: string; +} { + const instance = useBranding(); + const pond = useQuery({ + queryKey: ['pond', pondId, 'branding'], + queryFn: () => apiGet(`/ponds/${pondId!}/branding`), + enabled: Boolean(pondId), + staleTime: 5 * 60 * 1000, + }); + const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' }; + const resolved = resolveBranding(base, pond.data ?? null); + return { + resolved, + instanceName: base.instanceName, + pondId: resolved.logoLevel === 'pond' ? pondId : undefined, + }; +} + +/** + * Points the document's icon at the pond's favicon while a pond is open, and + * back at the instance's on leaving (issue #307). + * + * Accepted and worth stating: the swap necessarily happens AFTER first paint, + * so opening a pond link directly shows the instance favicon briefly before it + * changes. Avoiding that would mean server-rendering index.html, which is + * #179's territory and deliberately out of scope here. In a pinned tab — where + * telling ponds apart matters most — the tab is already open, so the swap is + * the normal case rather than the exception. + * + * Driven by the RESOLVED pond, never by the raw route parameter: an unreadable + * or non-existent pond slug must not leave a stale icon in the tab. + */ +export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void { + useEffect(() => { + const link = document.querySelector('link[rel="icon"]'); + if (!link) return undefined; + const instanceHref = '/api/v1/branding/favicon'; + link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref; + return () => { + link.href = instanceHref; + }; + }, [pondId, hasPondFavicon]); } export function useInvalidateBranding(): () => Promise { diff --git a/apps/web/src/branding/use-pond-id.ts b/apps/web/src/branding/use-pond-id.ts new file mode 100644 index 0000000..d3b3c84 --- /dev/null +++ b/apps/web/src/branding/use-pond-id.ts @@ -0,0 +1,21 @@ +import type { PondView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; + +import { apiGet } from '../lib/api'; + +/** + * The current pond's id and name from its slug (issue #307). + * + * Shares the sidebar's query key, so the pond is fetched once. Returns + * nothing for an unreadable or unknown slug — which is exactly why the + * favicon swap is driven by this and not by the raw route parameter: a bad + * slug must not leave a stale icon in the tab. + */ +export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } { + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug!}`), + enabled: Boolean(pondSlug), + }); + return { pondId: pond.data?.id, pondName: pond.data?.name }; +} diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 70c82d9..33724a9 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting'; import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { MemberManager } from '../members/MemberManager'; import { DeletePondSection } from '../ponds/DeletePondSection'; +import { PondBrandingSection } from '../ponds/PondBrandingSection'; import { PondPluginSettings } from '../plugins/PondPluginSettings'; import { PondThemeSection } from '../theme/PondThemeSection'; @@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tMembers } = useTranslation('members'); const { t: tErrors } = useTranslation('errors'); const { t: tFiles } = useTranslation('files'); + const { t: tBranding } = useTranslation('branding'); const { t: tExport } = useTranslation('export'); const { t: tComments } = useTranslation('comments'); const { t: tApiTokens } = useTranslation('apiTokens'); @@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element { pondSlug={pondSlug} theme={pond.data.settings.theme} /> +

{tBranding('admin.title')}

+ )} {canModify && } diff --git a/apps/web/src/ponds/PondBrandingSection.tsx b/apps/web/src/ponds/PondBrandingSection.tsx new file mode 100644 index 0000000..93aebf1 --- /dev/null +++ b/apps/web/src/ponds/PondBrandingSection.tsx @@ -0,0 +1,234 @@ +import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CropField } from '../branding/CropField'; +import { canvasToPngFile, drawCrop } from '../branding/crop'; +import { logoUrl } from '../branding/use-branding'; +import { FormError, FormSuccess } from '../components/forms'; +import { apiDelete, apiGet, apiPostForm } from '../lib/api'; + +const FAVICON_SIZES = [32, 180] as const; + +/** + * A pond's own logo and favicon (issue #307). + * + * Reuses the instance screen's crop control rather than growing a second, + * drag-only one: the uploader here is an ordinary Pond Admin, and the + * keyboard operability is not theirs to lose. + */ +export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element { + const { t } = useTranslation('branding'); + const queryClient = useQueryClient(); + + const branding = useQuery({ + queryKey: ['pond', pondId, 'branding'], + queryFn: () => apiGet(`/ponds/${pondId}/branding`), + }); + const view = branding.data; + + const refresh = async (): Promise => { + await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] }); + }; + + return ( +
+

{t('pond.intro')}

+

{t('pond.quotaNote')}

+ + + + {view?.logo && !view.logoDark && ( + // Advisory, exactly as on the instance screen: a pond that uploads + // only a light logo shows THAT logo in dark mode — it does not borrow + // the instance's dark one. Nothing is blocked. +

+ + {t('pond.darkMissing')} +

+ )} + + +
+ ); +} + +function PondLogoSlot({ + pondId, + variant, + asset, + onChanged, +}: { + pondId: string; + variant: LogoVariant; + asset: { hash: string; width: number; height: number } | null; + onChanged: () => Promise; +}): React.JSX.Element { + const { t } = useTranslation('branding'); + const canvasRef = useRef(null); + const [done, setDone] = useState(false); + + const upload = useMutation({ + mutationFn: async () => { + const canvas = canvasRef.current; + if (!canvas) throw new Error('no image'); + const form = new FormData(); + form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`)); + return apiPostForm(`/ponds/${pondId}/branding/logo?variant=${variant}`, form); + }, + onSuccess: async () => { + setDone(true); + await onChanged(); + }, + }); + + const reset = useMutation({ + mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`), + onSuccess: async () => { + setDone(false); + await onChanged(); + }, + }); + + return ( +
+

{t(`admin.logo.${variant}`)}

+ + + {asset ? ( +
+ {t('admin.logo.currentAlt')} +

{t('admin.logo.current', { width: asset.width, height: asset.height })}

+ {/* A real, labelled control — not an empty file field standing in + for "remove". */} + +
+ ) : ( +

{t('pond.inherited')}

+ )} + { + canvasRef.current = canvas; + setDone(false); + }} + /> + +

{upload.isPending ? t('admin.uploading') : ''}

+
+ ); +} + +function PondFaviconSlot({ + pondId, + present, + onChanged, +}: { + pondId: string; + present: boolean; + onChanged: () => Promise; +}): React.JSX.Element { + const { t } = useTranslation('branding'); + const canvasRef = useRef(null); + const [done, setDone] = useState(false); + + const upload = useMutation({ + mutationFn: async () => { + const source = canvasRef.current; + if (!source) throw new Error('no image'); + const form = new FormData(); + for (const size of FAVICON_SIZES) { + const scratch = document.createElement('canvas'); + drawCrop( + source, + { x: 0, y: 0, width: source.width, height: source.height }, + { width: size, height: size }, + scratch, + ); + form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`)); + } + return apiPostForm(`/ponds/${pondId}/branding/favicon`, form); + }, + onSuccess: async () => { + setDone(true); + await onChanged(); + }, + }); + + const reset = useMutation({ + mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`), + onSuccess: async () => { + setDone(false); + await onChanged(); + }, + }); + + return ( +
+

{t('admin.favicon.title')}

+

{t('pond.faviconHint')}

+ + +

{present ? t('admin.favicon.present') : t('pond.inherited')}

+ {present && ( + + )} + { + canvasRef.current = canvas; + setDone(false); + }} + /> + +

{upload.isPending ? t('admin.uploading') : ''}

+
+ ); +} diff --git a/packages/shared/i18n/de/branding.json b/packages/shared/i18n/de/branding.json index f285c88..e4285aa 100644 --- a/packages/shared/i18n/de/branding.json +++ b/packages/shared/i18n/de/branding.json @@ -37,5 +37,13 @@ "size": "Kantenlänge des Ausschnitts (px)", "reset": "Ausschnitt zurücksetzen", "result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)." + }, + "pond": { + "intro": "Dieser Teich kann ein eigenes Logo und ein eigenes Favicon führen. Beides überschreibt das der Instanz — nur für diesen Teich.", + "quotaNote": "Die Dateien zählen auf das Speicher-Kontingent dieses Teichs, wie Anhänge auch.", + "inherited": "Nichts hinterlegt — es gilt die Einstellung der Instanz.", + "reset": "Auf die Instanz-Einstellung zurücksetzen", + "darkMissing": "Für den Dunkelmodus ist kein eigenes Teich-Logo hinterlegt. Dann wird dort das helle Logo dieses Teichs verwendet — NICHT das dunkle Logo der Instanz. Ein Logo-Satz gehört zu einer Ebene und wird nie ebenenübergreifend gemischt. Das ist ein Hinweis, keine Sperre.", + "faviconHint": "Das Favicon wird beim Betreten des Teichs im Tab gesetzt und beim Verlassen wieder zurückgestellt. Beim direkten Öffnen eines Teich-Links erscheint kurz das Instanz-Favicon, bevor es wechselt." } } diff --git a/packages/shared/i18n/en/branding.json b/packages/shared/i18n/en/branding.json index c90d385..d1dcda6 100644 --- a/packages/shared/i18n/en/branding.json +++ b/packages/shared/i18n/en/branding.json @@ -37,5 +37,13 @@ "size": "Crop edge length (px)", "reset": "Reset the crop", "result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)." + }, + "pond": { + "intro": "This pond can carry its own logo and favicon. Both override the instance's — for this pond only.", + "quotaNote": "The files count against this pond's storage quota, just like attachments.", + "inherited": "Nothing set — the instance's setting applies.", + "reset": "Reset to the instance setting", + "darkMissing": "No separate dark-mode logo is set for this pond. The pond's light logo is then used there — NOT the instance's dark one. A logo set belongs to one level and is never mixed across levels. This is advice, not a block.", + "faviconHint": "The favicon is applied to the tab when the pond is entered and restored on leaving. Opening a pond link directly shows the instance favicon briefly before it changes." } } diff --git a/packages/shared/src/branding.test.ts b/packages/shared/src/branding.test.ts new file mode 100644 index 0000000..dc61189 --- /dev/null +++ b/packages/shared/src/branding.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { pngDimensions, hasPngMagic, looksLikeSvg, resolveBranding } from './branding'; + +const asset = (hash: string) => ({ hash, width: 10, height: 10 }); +const NONE = { logo: null, logoDark: null, favicon: null }; + +/** + * The resolution order (issues #306/#307) lives in one function, so this is + * where the decision that most easily gets "fixed" by accident is pinned: + * a logo set belongs to ONE level and variants are never mixed across levels. + */ +describe('resolveBranding', () => { + it('prefers the pond over the instance', () => { + const resolved = resolveBranding( + { logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null }, + { ...NONE, logo: asset('bbbbbbbbbbbbbbbb') }, + ); + expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb'); + expect(resolved.logoLevel).toBe('pond'); + }); + + it('keeps a pond on ITS OWN light logo in dark mode, never the instance dark one', () => { + // Decided 2026-08-01: a logo silently swapping to a different image when + // the viewer switches theme is a change nobody ordered. A design that + // looks wrong is more honest than one that is quietly substituted. + const resolved = resolveBranding( + { logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null }, + { ...NONE, logo: asset('bbbbbbbbbbbbbbbb') }, + ); + expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb'); + expect(resolved.logoDark).toBeNull(); + }); + + it('inherits BOTH instance variants when the pond has no logo at all', () => { + const resolved = resolveBranding( + { logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null }, + NONE, + ); + expect(resolved.logo?.hash).toBe('aaaaaaaaaaaaaaaa'); + expect(resolved.logoDark?.hash).toBe('cccccccccccccccc'); + expect(resolved.logoLevel).toBe('instance'); + }); + + it('treats a pond with only a DARK logo as a set of its own too', () => { + const resolved = resolveBranding( + { logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null }, + { ...NONE, logoDark: asset('dddddddddddddddd') }, + ); + expect(resolved.logo).toBeNull(); + expect(resolved.logoDark?.hash).toBe('dddddddddddddddd'); + }); + + it('falls through to nothing, which is the render-the-name case', () => { + expect(resolveBranding(NONE, NONE).logoLevel).toBe('none'); + expect(resolveBranding(NONE, null).logo).toBeNull(); + }); + + it('resolves the favicon per level, down to the shipped default', () => { + expect(resolveBranding(NONE, { ...NONE, favicon: asset('e'.repeat(16)) }).faviconLevel).toBe( + 'pond', + ); + expect(resolveBranding({ ...NONE, favicon: asset('f'.repeat(16)) }, NONE).faviconLevel).toBe( + 'instance', + ); + expect(resolveBranding(NONE, NONE).faviconLevel).toBe('default'); + }); +}); + +describe('image checks (no decoding)', () => { + const png = (width: number, height: number): Uint8Array => { + const buf = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0); + buf.write('IHDR', 12, 'latin1'); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; + }; + + it('reads the dimensions out of the IHDR', () => { + expect(pngDimensions(png(512, 128))).toEqual({ width: 512, height: 128 }); + }); + + it('refuses anything that is not a PNG with an IHDR first', () => { + expect(hasPngMagic(Buffer.from('GIF89a'))).toBe(false); + expect(pngDimensions(Buffer.from('GIF89a'))).toBeNull(); + // Truncated: a reader that trusted the signature alone would run off the + // end here. + expect(pngDimensions(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull(); + }); + + it('recognises SVG so the rejection can say why', () => { + expect(looksLikeSvg(Buffer.from(''))).toBe(true); + expect(looksLikeSvg(Buffer.from('\n'))).toBe(true); + expect(looksLikeSvg(png(1, 1))).toBe(false); + }); +}); diff --git a/packages/shared/src/branding.ts b/packages/shared/src/branding.ts index 0b0d2a0..853bddb 100644 --- a/packages/shared/src/branding.ts +++ b/packages/shared/src/branding.ts @@ -81,6 +81,10 @@ export const brandingAssetSchema = z.object({ .regex(/^[a-f0-9]{16,64}$/), width: z.number().int().min(1), height: z.number().int().min(1), + /** Stored bytes. Needed so a pond's quota can be released exactly when the + * asset is replaced or removed (issue #307) — optional because instance + * assets predating it are not charged to anything. */ + byteSize: z.number().int().min(0).optional(), }); export type BrandingAsset = z.infer; @@ -96,3 +100,54 @@ export interface BrandingView { * screen carries the branding. */ instanceName: string; } + +/** A pond's own branding (issue #307), stored in its settings. Null per slot + * means "not set at this level". */ +export const pondBrandingSchema = z.object({ + logo: brandingAssetSchema.nullable().default(null), + logoDark: brandingAssetSchema.nullable().default(null), + favicon: brandingAssetSchema.nullable().default(null), +}); +export type PondBranding = z.infer; + +/** What a pond page should actually show. */ +export interface ResolvedBranding { + logo: BrandingAsset | null; + logoDark: BrandingAsset | null; + favicon: BrandingAsset | null; + /** Which level the LOGO came from — the link's accessible name follows it: + * a pond logo is named by the pond, an instance logo by the instance. */ + logoLevel: 'pond' | 'instance' | 'none'; + faviconLevel: 'pond' | 'instance' | 'default'; +} + +/** + * The single place that decides which asset applies (issues #306/#307): + * the pond's own, else the instance's, else the shipped default (favicon) or + * the instance name as text (logo). + * + * **A logo set belongs to one level — variants are NEVER mixed across + * levels.** A pond that uploaded only a light logo shows THAT logo in dark + * mode; it does not fall back to the instance's dark variant. Decided + * 2026-08-01: a logo silently swapping to a different image when the viewer + * switches theme is a change nobody ordered, and a design that looks wrong is + * more honest than one that is quietly substituted — the pond admin can see + * it and fix it. Only a pond with NO logo at all inherits the instance's set, + * again as a set. + */ +export function resolveBranding( + instance: Pick, + pond?: PondBranding | null, +): ResolvedBranding { + const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark)); + const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none'; + const source = pondHasLogo ? pond! : instance; + const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default'; + return { + logo: logoLevel === 'none' ? null : source.logo, + logoDark: logoLevel === 'none' ? null : source.logoDark, + favicon: pond?.favicon ?? instance.favicon ?? null, + logoLevel, + faviconLevel, + }; +} diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 9b93760..5b50295 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { pondBrandingSchema } from './branding'; + import { COMMENT_POLICIES } from './comments'; /** @@ -66,6 +68,12 @@ export const pondSettingsSchema = z.object({ * as an id, not a slug, so renaming or moving the page does not break it; * a dangling id (page trashed) falls back rather than erroring. */ startPageId: z.string().uuid().nullable().default(null), + /** The pond's own logo and favicon (issue #307), overriding the instance's. + * Metadata only — the PNG bytes live under `BRANDING_DIR`, like the + * instance's. Written through the branding endpoints, never through + * `PATCH /ponds/:id`: it describes bytes on disk, and hand-writing it would + * claim an asset that is not there. */ + branding: pondBrandingSchema.default({ logo: null, logoDark: null, favicon: null }), /** Who may write comments (issue #91): every reader, or editors only. */ commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), /** Per-pond opt-in to the public REST API (issue #104, default off):