Compare commits
1 Commits
90748b743e
...
4b9018c033
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b9018c033 |
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
<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}
|
||||
alt=""
|
||||
/>
|
||||
{logoDark && (
|
||||
{resolved.logo && (
|
||||
<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--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
|
||||
src={logoUrl('light', resolved.logo.hash, logoPond)}
|
||||
width={resolved.logo.width}
|
||||
height={resolved.logo.height}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{resolved.logoDark && (
|
||||
<img
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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> {
|
||||
|
||||
21
apps/web/src/branding/use-pond-id.ts
Normal file
21
apps/web/src/branding/use-pond-id.ts
Normal 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 };
|
||||
}
|
||||
@ -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} />}
|
||||
|
||||
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
97
packages/shared/src/branding.test.ts
Normal file
97
packages/shared/src/branding.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user