All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m8s
CI / Build container images (pull_request) Successful in 4m3s
CI / Auth e2e pack (pull_request) Successful in 9m1s
CI / Import/export fidelity gate (pull_request) Successful in 1m3s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m49s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m42s
CI / Import/export fidelity gate (push) Successful in 58s
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.
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import {
|
|
BrandingAsset,
|
|
BrandingView,
|
|
FAVICON_SIZES,
|
|
FaviconSize,
|
|
LOGO_VARIANTS,
|
|
LogoVariant,
|
|
PondBranding,
|
|
pondSettingsSchema,
|
|
MAX_BRANDING_BYTES,
|
|
MAX_LOGO_EDGE,
|
|
hasPngMagic,
|
|
looksLikeSvg,
|
|
pngDimensions,
|
|
} from '@dorfteich/shared';
|
|
import { User } from '@prisma/client';
|
|
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { QuotaService } from '../quotas/quota.service';
|
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
import { BrandingStorageService } from './branding-storage.service';
|
|
|
|
/** The settings key each instance asset's metadata lives under. */
|
|
const INSTANCE_KEYS = {
|
|
logoLight: 'instance.logo',
|
|
logoDark: 'instance.logoDark',
|
|
favicon: 'instance.favicon',
|
|
} as const;
|
|
|
|
/**
|
|
* Instance branding (issue #306): the logo shown at the top of the sidebar and
|
|
* the favicon served to the browser.
|
|
*
|
|
* The api stores and serves bytes; it never decodes them. Validation is the
|
|
* PNG signature, the IHDR dimensions and the size cap — see
|
|
* `packages/shared/src/branding.ts` for why that line is drawn there.
|
|
*/
|
|
@Injectable()
|
|
export class BrandingService {
|
|
constructor(
|
|
private readonly settings: InstanceSettingsService,
|
|
private readonly storage: BrandingStorageService,
|
|
private readonly audit: AuditService,
|
|
private readonly prisma: PrismaService,
|
|
private readonly quotas: QuotaService,
|
|
) {}
|
|
|
|
static logoKey(variant: LogoVariant): string {
|
|
return `instance-logo-${variant}`;
|
|
}
|
|
|
|
static faviconKey(size: FaviconSize): string {
|
|
return `instance-favicon-${size}`;
|
|
}
|
|
|
|
/** Pond assets share the directory and the naming rules (issue #307); the
|
|
* pond id keeps them apart and makes purge a prefix delete. */
|
|
static pondLogoKey(pondId: string, variant: LogoVariant): string {
|
|
return `pond-${pondId}-logo-${variant}`;
|
|
}
|
|
|
|
static pondFaviconKey(pondId: string, size: FaviconSize): string {
|
|
return `pond-${pondId}-favicon-${size}`;
|
|
}
|
|
|
|
/** Every branding file a pond can own — the purge deletes exactly this set
|
|
* (issue #307). The purge standard is absolute: after it, nothing
|
|
* referencing the pond survives, rows or files. */
|
|
static pondKeys(pondId: string): string[] {
|
|
return [
|
|
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
|
|
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Rejects anything that is not a PNG within the caps, before a byte is
|
|
* written. SVG gets its own message: an operator who tried one deserves to
|
|
* learn that it is refused on purpose, not that "the file is broken".
|
|
*/
|
|
private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
|
|
if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
|
|
if (bytes.length > MAX_BRANDING_BYTES) {
|
|
throw new BadRequestException({ code: 'branding_file_too_large' });
|
|
}
|
|
if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
|
|
if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
|
|
const size = pngDimensions(bytes);
|
|
if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
|
|
if (size.width > maxEdge || size.height > maxEdge) {
|
|
throw new BadRequestException({ code: 'branding_image_too_large' });
|
|
}
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* Reserve the pond's storage for a branding asset, releasing what the asset
|
|
* it replaces occupied. Doing it in that order means replacing a logo with
|
|
* one of the same size costs nothing — otherwise every re-upload would eat
|
|
* the quota again, which is how "a pond admin fills the disk with logos"
|
|
* happens.
|
|
*/
|
|
private async chargeQuota(
|
|
pond: { id: string; ownerId: string },
|
|
bytes: number,
|
|
previous: BrandingAsset | null,
|
|
): Promise<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,
|
|
};
|
|
}
|
|
|
|
async view(): Promise<BrandingView> {
|
|
const [logo, logoDark, favicon, instanceName] = await Promise.all([
|
|
this.settings.get(INSTANCE_KEYS.logoLight),
|
|
this.settings.get(INSTANCE_KEYS.logoDark),
|
|
this.settings.get(INSTANCE_KEYS.favicon),
|
|
this.settings.get('instance.name'),
|
|
]);
|
|
return { logo, logoDark, favicon, instanceName };
|
|
}
|
|
|
|
async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise<BrandingView> {
|
|
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
|
await this.storage.save(BrandingService.logoKey(variant), bytes);
|
|
await this.settings.set(
|
|
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
|
this.assetOf(bytes, size),
|
|
admin.id,
|
|
);
|
|
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
|
|
return this.view();
|
|
}
|
|
|
|
async clearLogo(admin: User, variant: LogoVariant): Promise<BrandingView> {
|
|
await this.storage.remove(BrandingService.logoKey(variant));
|
|
await this.settings.set(
|
|
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
|
null,
|
|
admin.id,
|
|
);
|
|
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
|
|
return this.view();
|
|
}
|
|
|
|
/**
|
|
* Both favicon sizes arrive together: the browser produced them from one
|
|
* source on the same canvas, and the api cannot resize. Storing them as a
|
|
* pair keeps the tab icon and the home-screen icon from ever showing two
|
|
* different images.
|
|
*/
|
|
async setFavicon(admin: User, files: Record<FaviconSize, Buffer>): Promise<BrandingView> {
|
|
const sizes = Object.entries(files).map(([declared, bytes]) => {
|
|
const size = this.assertUsablePng(bytes, 512);
|
|
const expected = Number(declared);
|
|
if (size.width !== expected || size.height !== expected) {
|
|
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
|
}
|
|
return { expected: expected as FaviconSize, bytes, size };
|
|
});
|
|
for (const entry of sizes) {
|
|
await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
|
|
}
|
|
// The 32px variant identifies the pair — it is what the tab shows.
|
|
const small = sizes.find((entry) => entry.expected === 32)!;
|
|
await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
|
|
await this.record(admin, 'favicon', 'set');
|
|
return this.view();
|
|
}
|
|
|
|
async clearFavicon(admin: User): Promise<BrandingView> {
|
|
await this.storage.remove(BrandingService.faviconKey(32));
|
|
await this.storage.remove(BrandingService.faviconKey(180));
|
|
await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
|
|
await this.record(admin, 'favicon', 'cleared');
|
|
return this.view();
|
|
}
|
|
|
|
/** The bytes to serve for a logo variant, or null when none is stored. */
|
|
logoBytes(variant: LogoVariant): Promise<Buffer | null> {
|
|
return this.storage.read(BrandingService.logoKey(variant));
|
|
}
|
|
|
|
/**
|
|
* The favicon bytes: the uploaded one, else the shipped default. The
|
|
* `<link rel="icon">` in index.html is static, so this route must always
|
|
* answer with an image — a 404 there would leave the browser's generic
|
|
* icon for good.
|
|
*/
|
|
async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
|
|
const stored = await this.storage.read(BrandingService.faviconKey(size));
|
|
if (stored) return { bytes: stored, uploaded: true };
|
|
const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
|
|
return { bytes, uploaded: false };
|
|
}
|
|
|
|
/** The pond's own branding, defaulted — one place reads the settings blob. */
|
|
async pondBranding(pondId: string): Promise<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',
|
|
action: 'set' | 'cleared',
|
|
): Promise<unknown> {
|
|
// `scope` is here from the start so the pond-level change (#307) is the
|
|
// same event with a different scope, not a second id in the catalogue.
|
|
return this.audit.record({
|
|
action: 'branding.changed',
|
|
actorId: admin.id,
|
|
targetType: 'setting',
|
|
targetId: `instance.${asset}`,
|
|
details: { scope: 'instance', asset, change: action },
|
|
});
|
|
}
|
|
}
|