#307: pond-level branding overrides the instance logo and favicon
Built on #306's storage, serving and crop control — a layer, not a parallel implementation. `resolveBranding` in shared is the ONE place that answers "which asset applies here?", and both the sidebar logo and the favicon swap read it. The decision most likely to be "fixed" by accident, so it is pinned by name in `branding.test.ts`: **a logo set belongs to one level and variants are never mixed across levels.** A pond that uploaded only a light logo shows THAT logo in dark mode; it does not borrow 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. Only a pond with no logo at all inherits the instance's set, again as a set. The settings screen warns about a missing dark variant; it never blocks. Consequences that fall out of that rule and are easy to get wrong: - The serving route does NOT fall back when given a pond scope. The caller already decided which level applies; a "helpful" fallback in the route would mix variants across levels behind the resolver's back. - The logo link's accessible name follows the LEVEL: a pond logo is named by the pond, an instance logo by the instance. It is the link home, and a link's name has to say where it goes. - **Charged to the pond's storage quota**, before the write, like attachments. Without it branding would be a way around the quota, and replacing a logo repeatedly would consume disk with no ceiling. The replaced asset's bytes are released FIRST, so re-uploading the same logo costs nothing — and a refused upload puts the released reservation back, so a rejection cannot leave the pond with more room than it had. - **Purge removes the branding files.** The purge standard is absolute: after it nothing referencing the pond survives, rows or files. Asserted against the real purge path, not the new code alone. - Security unchanged from #306 and not relaxed because the uploader is now an ordinary Pond Admin: SVG refused, magic bytes and IHDR checked server-side, size caps, content type pinned, no image parsing. - The favicon swap is driven by the RESOLVED pond, never the raw route parameter — an unreadable or unknown slug must not leave a stale icon in the tab. That it happens after first paint is accepted and stated in the code and the UI: avoiding it would mean server-rendering index.html, which is #179's territory. Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue already carries the field, so no version bump. Verified: api suite 105 files / 592 tests green; 5 pond-branding e2e tests (pond scope serves the pond's bytes while the instance level still 404s, the quota is charged and released exactly, SVG refused at pond level, a reader may read but not change, purge deletes the files); 9 shared unit tests on the resolution order including both mixing directions.
This commit is contained in:
parent
3310ae3926
commit
90748b743e
@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@ -19,11 +21,14 @@ import {
|
|||||||
LOGO_VARIANTS,
|
LOGO_VARIANTS,
|
||||||
LogoVariant,
|
LogoVariant,
|
||||||
MAX_BRANDING_BYTES,
|
MAX_BRANDING_BYTES,
|
||||||
|
PondBranding,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
import { AuthedRequest, Public } from '../auth/auth.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';
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
function parseVariant(value: unknown): LogoVariant {
|
function parseVariant(value: unknown): LogoVariant {
|
||||||
@ -52,8 +57,19 @@ export class BrandingController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('logo')
|
@Get('logo')
|
||||||
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
|
async logo(
|
||||||
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
|
@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
|
// No shipped default: without a logo the app renders the instance NAME as
|
||||||
// text, so an empty answer here is the honest one.
|
// text, so an empty answer here is the honest one.
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
@ -69,12 +85,21 @@ export class BrandingController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('favicon')
|
@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);
|
const wanted = Number(size ?? 32);
|
||||||
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
||||||
throw new BadRequestException({ code: 'bad_request' });
|
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');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
// 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
|
// 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!);
|
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 { 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 { BrandingStorageService } from './branding-storage.service';
|
||||||
import { BrandingService } from './branding.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
|
* the pond-level override (#307) can build on the same storage and the same
|
||||||
* resolution path instead of a parallel one. */
|
* resolution path instead of a parallel one. */
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [BrandingController, BrandingAdminController],
|
imports: [PermissionsModule, QuotasModule],
|
||||||
|
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
|
||||||
providers: [BrandingService, BrandingStorageService],
|
providers: [BrandingService, BrandingStorageService],
|
||||||
exports: [BrandingService, BrandingStorageService],
|
exports: [BrandingService, BrandingStorageService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,12 +2,16 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
BrandingAsset,
|
BrandingAsset,
|
||||||
BrandingView,
|
BrandingView,
|
||||||
|
FAVICON_SIZES,
|
||||||
FaviconSize,
|
FaviconSize,
|
||||||
|
LOGO_VARIANTS,
|
||||||
LogoVariant,
|
LogoVariant,
|
||||||
|
PondBranding,
|
||||||
|
pondSettingsSchema,
|
||||||
MAX_BRANDING_BYTES,
|
MAX_BRANDING_BYTES,
|
||||||
MAX_LOGO_EDGE,
|
MAX_LOGO_EDGE,
|
||||||
hasPngMagic,
|
hasPngMagic,
|
||||||
@ -17,6 +21,8 @@ import {
|
|||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
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 { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
|
||||||
@ -41,6 +47,8 @@ export class BrandingService {
|
|||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly storage: BrandingStorageService,
|
private readonly storage: BrandingStorageService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly quotas: QuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static logoKey(variant: LogoVariant): string {
|
static logoKey(variant: LogoVariant): string {
|
||||||
@ -51,6 +59,26 @@ export class BrandingService {
|
|||||||
return `instance-favicon-${size}`;
|
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
|
* 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
|
* written. SVG gets its own message: an operator who tried one deserves to
|
||||||
@ -71,11 +99,37 @@ export class BrandingService {
|
|||||||
return size;
|
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 {
|
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
||||||
return {
|
return {
|
||||||
// Short digest: it only has to change when the bytes change, and it
|
// Short digest: it only has to change when the bytes change, and it
|
||||||
// travels in every logo URL.
|
// travels in every logo URL.
|
||||||
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
||||||
|
byteSize: bytes.length,
|
||||||
...size,
|
...size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -164,6 +218,150 @@ export class BrandingService {
|
|||||||
return { bytes, uploaded: false };
|
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(
|
private record(
|
||||||
admin: User,
|
admin: User,
|
||||||
asset: 'logo' | 'logoDark' | 'favicon',
|
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 { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BrandingModule } from '../branding/branding.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
BrandingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
QuotasModule,
|
QuotasModule,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { BrandingService } from '../branding/branding.service';
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
import { SearchProvider } from '../search/search.provider';
|
||||||
import { PagesService } from '../pages/pages.service';
|
import { PagesService } from '../pages/pages.service';
|
||||||
@ -32,6 +33,7 @@ export class TrashService {
|
|||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly branding: BrandingService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly watches: WatchesService,
|
private readonly watches: WatchesService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
@ -183,6 +185,9 @@ export class TrashService {
|
|||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
await this.storage.delete(pondId, attachment.id);
|
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 = (
|
const pageIds = (
|
||||||
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
||||||
).map((page) => page.id);
|
).map((page) => page.id);
|
||||||
|
|||||||
@ -1,50 +1,57 @@
|
|||||||
import { Link } from 'react-router-dom';
|
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
|
* The identity at the top of the sidebar (issues #306/#307): the pond's own
|
||||||
* logo as a link home, or the instance name as text when nothing is uploaded.
|
* 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
|
* Its accessible name follows the LEVEL the logo came from — a pond logo is
|
||||||
* this is the link home, and a link's name has to say where it goes. The
|
* named by the pond, an instance logo by the instance. For a screen reader
|
||||||
* images are therefore `alt=""` — the link is already named.
|
* 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]`),
|
* 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
|
* 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
|
* the correct logo is the one painted. A logo set belongs to ONE level and is
|
||||||
* flash. Without a dark variant the light one carries both themes — the
|
* never mixed across levels — see `resolveBranding`.
|
||||||
* operator's own asset, shown unchanged, rather than a substitute they did
|
|
||||||
* not choose (the rule #307 extends to ponds).
|
|
||||||
*/
|
*/
|
||||||
export function BrandLogo(): React.JSX.Element | null {
|
export function BrandLogo(): React.JSX.Element | null {
|
||||||
const branding = useBranding();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
if (!branding) return null;
|
const { pondId, pondName } = usePondId(pondSlug);
|
||||||
const { logo, logoDark, instanceName } = branding;
|
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 (
|
return (
|
||||||
<Link to="/" className="brand-logo" aria-label={instanceName}>
|
<Link to="/" className="brand-logo" aria-label={name}>
|
||||||
{logo ? (
|
{resolved.logo || resolved.logoDark ? (
|
||||||
<>
|
<>
|
||||||
<img
|
{resolved.logo && (
|
||||||
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 && (
|
|
||||||
<img
|
<img
|
||||||
className="brand-logo__img brand-logo__img--dark"
|
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
|
||||||
src={logoUrl('dark', logoDark.hash)}
|
src={logoUrl('light', resolved.logo.hash, logoPond)}
|
||||||
width={logoDark.width}
|
width={resolved.logo.width}
|
||||||
height={logoDark.height}
|
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=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="brand-logo__name">{instanceName}</span>
|
<span className="brand-logo__name">{name}</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { apiGet } from '../lib/api';
|
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
|
/** URL of a logo variant, with the content hash so a replaced logo is never
|
||||||
* served from cache. */
|
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
|
||||||
export function logoUrl(variant: 'light' | 'dark', hash: string): string {
|
* the route never falls back on its own — the CALLER decided which level
|
||||||
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`;
|
* 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> {
|
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 { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||||
|
import { PondBrandingSection } from '../ponds/PondBrandingSection';
|
||||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||||
import { PondThemeSection } from '../theme/PondThemeSection';
|
import { PondThemeSection } from '../theme/PondThemeSection';
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
const { t: tMembers } = useTranslation('members');
|
const { t: tMembers } = useTranslation('members');
|
||||||
const { t: tErrors } = useTranslation('errors');
|
const { t: tErrors } = useTranslation('errors');
|
||||||
const { t: tFiles } = useTranslation('files');
|
const { t: tFiles } = useTranslation('files');
|
||||||
|
const { t: tBranding } = useTranslation('branding');
|
||||||
const { t: tExport } = useTranslation('export');
|
const { t: tExport } = useTranslation('export');
|
||||||
const { t: tComments } = useTranslation('comments');
|
const { t: tComments } = useTranslation('comments');
|
||||||
const { t: tApiTokens } = useTranslation('apiTokens');
|
const { t: tApiTokens } = useTranslation('apiTokens');
|
||||||
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
theme={pond.data.settings.theme}
|
theme={pond.data.settings.theme}
|
||||||
/>
|
/>
|
||||||
|
<h3>{tBranding('admin.title')}</h3>
|
||||||
|
<PondBrandingSection pondId={pond.data.id} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
{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)",
|
"size": "Kantenlänge des Ausschnitts (px)",
|
||||||
"reset": "Ausschnitt zurücksetzen",
|
"reset": "Ausschnitt zurücksetzen",
|
||||||
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
|
"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)",
|
"size": "Crop edge length (px)",
|
||||||
"reset": "Reset the crop",
|
"reset": "Reset the crop",
|
||||||
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
|
"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}$/),
|
.regex(/^[a-f0-9]{16,64}$/),
|
||||||
width: z.number().int().min(1),
|
width: z.number().int().min(1),
|
||||||
height: 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>;
|
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
|
||||||
|
|
||||||
@ -96,3 +100,54 @@ export interface BrandingView {
|
|||||||
* screen carries the branding. */
|
* screen carries the branding. */
|
||||||
instanceName: string;
|
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 { z } from 'zod';
|
||||||
|
|
||||||
|
import { pondBrandingSchema } from './branding';
|
||||||
|
|
||||||
import { COMMENT_POLICIES } from './comments';
|
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;
|
* 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. */
|
* a dangling id (page trashed) falls back rather than erroring. */
|
||||||
startPageId: z.string().uuid().nullable().default(null),
|
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. */
|
/** Who may write comments (issue #91): every reader, or editors only. */
|
||||||
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
||||||
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user