#307: pond-level branding overrides the instance logo and favicon #317

Merged
opus-5 merged 1 commits from issue-307-pond-branding into main 2026-08-01 21:02:05 +02:00
16 changed files with 1125 additions and 39 deletions
Showing only changes of commit a327126fac - Show all commits

View File

@ -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<void> {
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<void> {
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<void> {
async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
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 `<link rel="icon">` 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<PondBranding> {
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<PondBranding> {
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<PondBranding> {
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<PondBranding> {
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
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<PondBranding> {
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
}
}

View File

@ -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],
})

View File

@ -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<void> {
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<PondBranding> {
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<PondBranding> {
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<PondBranding> {
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<PondBranding> {
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<FaviconSize, Buffer>,
): Promise<PondBranding> {
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<PondBranding> {
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<Buffer | null> {
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
}
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
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<void> {
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
}
private record(
admin: User,
asset: 'logo' | 'logoDark' | 'favicon',

View File

@ -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<void> => {
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<string> =>
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> =>
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('<svg xmlns="x"><script/></svg>'), '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();
});
});

View File

@ -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,

View File

@ -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);

View File

@ -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 (
<Link to="/" className="brand-logo" aria-label={instanceName}>
{logo ? (
<Link to="/" className="brand-logo" aria-label={name}>
{resolved.logo || resolved.logoDark ? (
<>
{resolved.logo && (
<img
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', logo.hash)}
width={logo.width}
height={logo.height}
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', resolved.logo.hash, logoPond)}
width={resolved.logo.width}
height={resolved.logo.height}
alt=""
/>
{logoDark && (
)}
{resolved.logoDark && (
<img
className="brand-logo__img brand-logo__img--dark"
src={logoUrl('dark', logoDark.hash)}
width={logoDark.width}
height={logoDark.height}
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
width={resolved.logoDark.width}
height={resolved.logoDark.height}
alt=""
/>
)}
</>
) : (
<span className="brand-logo__name">{instanceName}</span>
<span className="brand-logo__name">{name}</span>
)}
</Link>
);

View File

@ -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<PondBranding>(`/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<HTMLLinkElement>('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<void> {

View File

@ -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<PondView>(`/ponds/${pondSlug!}`),
enabled: Boolean(pondSlug),
});
return { pondId: pond.data?.id, pondName: pond.data?.name };
}

View File

@ -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}
/>
<h3>{tBranding('admin.title')}</h3>
<PondBrandingSection pondId={pond.data.id} />
</section>
)}
{canModify && <PondPluginSettings pondId={pond.data.id} />}

View File

@ -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<PondBranding>(`/ponds/${pondId}/branding`),
});
const view = branding.data;
const refresh = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
};
return (
<div className="branding pond-branding">
<p>{t('pond.intro')}</p>
<p>{t('pond.quotaNote')}</p>
<PondLogoSlot
variant="light"
pondId={pondId}
asset={view?.logo ?? null}
onChanged={refresh}
/>
<PondLogoSlot
variant="dark"
pondId={pondId}
asset={view?.logoDark ?? null}
onChanged={refresh}
/>
{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.
<p className="branding__warning" role="note">
<span aria-hidden="true"> </span>
{t('pond.darkMissing')}
</p>
)}
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
</div>
);
}
function PondLogoSlot({
pondId,
variant,
asset,
onChanged,
}: {
pondId: string;
variant: LogoVariant;
asset: { hash: string; width: number; height: number } | null;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(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<PondBranding>(`/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 (
<div className="branding__slot" data-pond-logo-variant={variant}>
<h3>{t(`admin.logo.${variant}`)}</h3>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.logo.saved') : null} />
{asset ? (
<div className="branding__current">
<img
src={logoUrl(variant, asset.hash, pondId)}
alt={t('admin.logo.currentAlt')}
className={`branding__preview branding__preview--${variant}`}
/>
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
{/* A real, labelled control not an empty file field standing in
for "remove". */}
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
</div>
) : (
<p>{t('pond.inherited')}</p>
)}
<CropField
idPrefix={`pond-logo-${variant}`}
square={false}
maxEdge={MAX_LOGO_EDGE}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.logo.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}
function PondFaviconSlot({
pondId,
present,
onChanged,
}: {
pondId: string;
present: boolean;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(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<PondBranding>(`/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 (
<div className="branding__slot" data-branding-slot="pond-favicon">
<h3>{t('admin.favicon.title')}</h3>
<p>{t('pond.faviconHint')}</p>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
{present && (
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
)}
<CropField
idPrefix="pond-favicon"
square
maxEdge={180}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.favicon.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -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('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe(true);
expect(looksLikeSvg(Buffer.from('<?xml version="1.0"?>\n<svg>'))).toBe(true);
expect(looksLikeSvg(png(1, 1))).toBe(false);
});
});

View File

@ -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<typeof brandingAssetSchema>;
@ -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<typeof pondBrandingSchema>;
/** 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<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
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,
};
}

View File

@ -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):