Compare commits
1 Commits
ee6a11f9b0
...
7f0a86b84c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f0a86b84c |
BIN
apps/api/assets/default-favicon-180.png
Normal file
BIN
apps/api/assets/default-favicon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/api/assets/default-favicon-32.png
Normal file
BIN
apps/api/assets/default-favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 683 B |
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generates the shipped default favicons (issue #306):
|
||||||
|
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
|
||||||
|
*
|
||||||
|
* The api serves these whenever an operator has not uploaded one, so an
|
||||||
|
* instance always has a tab icon — the `<link rel="icon">` in index.html is
|
||||||
|
* static and its resource must never 404.
|
||||||
|
*
|
||||||
|
* Drawn here rather than pulled in as a binary: the whole toolchain must
|
||||||
|
* survive the `--network none` offline build (96-offline-build-protokoll.md),
|
||||||
|
* and adding an image library for one 32×32 icon would be the tail wagging
|
||||||
|
* the dog. Node's own zlib is enough to write a PNG.
|
||||||
|
*
|
||||||
|
* Motif: a pond seen from above — the accent-green disc with two ripples.
|
||||||
|
*
|
||||||
|
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
|
||||||
|
* script and binaries together.
|
||||||
|
*/
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/** Brand green — the same value as index.html's light `theme-color`. */
|
||||||
|
const GREEN = [0x2f, 0x6f, 0x4f];
|
||||||
|
const LIGHT = [0xe8, 0xf2, 0xec];
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
|
||||||
|
function encodePng(size, rgba) {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // colour type RGBA
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
raw[y * (size * 4 + 1)] = 0; // filter: none
|
||||||
|
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour at one point of the unit square, in continuous coordinates — the
|
||||||
|
* caller supersamples it, which is where the anti-aliasing comes from.
|
||||||
|
*/
|
||||||
|
function sample(x, y) {
|
||||||
|
const dx = x - 0.5;
|
||||||
|
const dy = y - 0.5;
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r > 0.48) return null; // outside the disc: transparent
|
||||||
|
// Two ripples spreading from a point struck slightly above centre — rings
|
||||||
|
// rather than a bullseye, which is why the centre stays green and the
|
||||||
|
// spacing widens outward the way real ripples do.
|
||||||
|
const rr = Math.hypot(dx, dy + 0.06);
|
||||||
|
const onRing = (radius, width) => Math.abs(rr - radius) < width;
|
||||||
|
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
|
||||||
|
return GREEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(size) {
|
||||||
|
const SS = 4; // supersampling factor
|
||||||
|
const out = Buffer.alloc(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
for (let sy = 0; sy < SS; sy += 1) {
|
||||||
|
for (let sx = 0; sx < SS; sx += 1) {
|
||||||
|
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
|
||||||
|
if (c) {
|
||||||
|
r += c[0];
|
||||||
|
g += c[1];
|
||||||
|
b += c[2];
|
||||||
|
a += 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = SS * SS;
|
||||||
|
const covered = a / 255;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
// Premultiplied average of the covered samples only, so the edge fades
|
||||||
|
// in alpha rather than towards black.
|
||||||
|
out[i] = covered ? Math.round(r / covered) : 0;
|
||||||
|
out[i + 1] = covered ? Math.round(g / covered) : 0;
|
||||||
|
out[i + 2] = covered ? Math.round(b / covered) : 0;
|
||||||
|
out[i + 3] = Math.round(a / n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
||||||
|
for (const size of [32, 180]) {
|
||||||
|
const file = join(assets, `default-favicon-${size}.png`);
|
||||||
|
writeFileSync(file, encodePng(size, render(size)));
|
||||||
|
console.log(`wrote ${file}`);
|
||||||
|
}
|
||||||
50
apps/api/src/branding/branding-storage.service.ts
Normal file
50
apps/api/src/branding/branding-storage.service.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem binding for branding assets (issue #306; pond overrides #307).
|
||||||
|
*
|
||||||
|
* One flat directory of PNGs named by a caller-supplied key
|
||||||
|
* (`instance-logo-light`, later `pond-<id>-favicon-32`). Flat because there
|
||||||
|
* are a handful of files per instance and the backup archives the directory
|
||||||
|
* as a whole — a tree would buy nothing and cost a traversal question.
|
||||||
|
*
|
||||||
|
* The key is constrained here rather than trusted from the route: it is the
|
||||||
|
* only thing between a request parameter and a path.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BrandingStorageService {
|
||||||
|
constructor(private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
/** Lowercase, digits and dashes only — no dot, so no `..`, and no slash,
|
||||||
|
* so the file cannot leave the directory whatever a caller sends. */
|
||||||
|
private pathFor(key: string): string {
|
||||||
|
if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
|
||||||
|
return join(this.config.env.BRANDING_DIR, `${key}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(key: string, bytes: Buffer): Promise<void> {
|
||||||
|
await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
|
||||||
|
await writeFile(this.pathFor(key), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes, or null when the file is absent — a missing asset is a normal
|
||||||
|
* state here (nothing uploaded, or metadata and disk drifted after a
|
||||||
|
* partial restore), and every caller has a fallback. */
|
||||||
|
async read(key: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
return await readFile(this.pathFor(key));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent: removing what is not there is success. */
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
await rm(this.pathFor(key), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apps/api/src/branding/branding.controller.ts
Normal file
135
apps/api/src/branding/branding.controller.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
BrandingView,
|
||||||
|
FAVICON_SIZES,
|
||||||
|
FaviconSize,
|
||||||
|
LOGO_VARIANTS,
|
||||||
|
LogoVariant,
|
||||||
|
MAX_BRANDING_BYTES,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
|
function parseVariant(value: unknown): LogoVariant {
|
||||||
|
if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
|
||||||
|
throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
}
|
||||||
|
return value as LogoVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public branding surface (issue #306).
|
||||||
|
*
|
||||||
|
* Unauthenticated by design and worth stating plainly in the admin UI: the
|
||||||
|
* login screen carries the branding and the browser fetches the favicon before
|
||||||
|
* anyone signs in, so an operator's logo IS visible to anonymous visitors.
|
||||||
|
*/
|
||||||
|
@Controller('branding')
|
||||||
|
export class BrandingController {
|
||||||
|
constructor(private readonly branding: BrandingService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
view(): Promise<BrandingView> {
|
||||||
|
return this.branding.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('logo')
|
||||||
|
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
|
||||||
|
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
|
||||||
|
// No shipped default: without a logo the app renders the instance NAME as
|
||||||
|
// text, so an empty answer here is the honest one.
|
||||||
|
if (!bytes) {
|
||||||
|
res.status(404).json({ code: 'not_found', message: 'no logo' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
// The caller puts the content hash in the query string, so a given URL
|
||||||
|
// never changes what it points at.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
res.send(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('favicon')
|
||||||
|
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> {
|
||||||
|
const wanted = Number(size ?? 32);
|
||||||
|
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
||||||
|
throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
}
|
||||||
|
const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize);
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
||||||
|
// cannot carry a hash — revalidation is the only way a replaced favicon
|
||||||
|
// ever reaches a browser that already has one.
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
|
||||||
|
res.send(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Site-Admin management of the instance branding (issue #306). */
|
||||||
|
@Controller('admin/branding')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class BrandingAdminController {
|
||||||
|
constructor(private readonly branding: BrandingService) {}
|
||||||
|
|
||||||
|
@Post('logo')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setLogo(
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
const file = files?.find((entry) => entry.fieldname === 'file');
|
||||||
|
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('logo')
|
||||||
|
clearLogo(
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('favicon')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setFavicon(
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
// Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
|
||||||
|
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.setFavicon(request.user!, collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('favicon')
|
||||||
|
clearFavicon(@Req() request: AuthedRequest): Promise<BrandingView> {
|
||||||
|
return this.branding.clearFavicon(request.user!);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/branding/branding.module.ts
Normal file
15
apps/api/src/branding/branding.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BrandingAdminController, BrandingController } from './branding.controller';
|
||||||
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
|
/** Instance branding — logo and favicon (issue #306). Exports the services so
|
||||||
|
* the pond-level override (#307) can build on the same storage and the same
|
||||||
|
* resolution path instead of a parallel one. */
|
||||||
|
@Module({
|
||||||
|
controllers: [BrandingController, BrandingAdminController],
|
||||||
|
providers: [BrandingService, BrandingStorageService],
|
||||||
|
exports: [BrandingService, BrandingStorageService],
|
||||||
|
})
|
||||||
|
export class BrandingModule {}
|
||||||
182
apps/api/src/branding/branding.service.ts
Normal file
182
apps/api/src/branding/branding.service.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
BrandingAsset,
|
||||||
|
BrandingView,
|
||||||
|
FaviconSize,
|
||||||
|
LogoVariant,
|
||||||
|
MAX_BRANDING_BYTES,
|
||||||
|
MAX_LOGO_EDGE,
|
||||||
|
hasPngMagic,
|
||||||
|
looksLikeSvg,
|
||||||
|
pngDimensions,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static logoKey(variant: LogoVariant): string {
|
||||||
|
return `instance-logo-${variant}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static faviconKey(size: FaviconSize): string {
|
||||||
|
return `instance-favicon-${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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
...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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
98
packages/shared/src/branding.ts
Normal file
98
packages/shared/src/branding.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Instance and pond branding assets — logo and favicon (issues #306/#307).
|
||||||
|
*
|
||||||
|
* The bytes live on disk under `BRANDING_DIR`; only metadata (present/absent,
|
||||||
|
* dimensions, a content hash for cache busting) goes into settings. Cropping,
|
||||||
|
* scaling and the conversion to PNG happen in the BROWSER on a canvas: adding
|
||||||
|
* a native image library to the api would put a decoder in front of
|
||||||
|
* attacker-supplied bytes and would have to be carried through the
|
||||||
|
* `--network none` offline build (96-offline-build-protokoll.md).
|
||||||
|
*
|
||||||
|
* The api therefore never decodes an image. It checks the PNG signature, reads
|
||||||
|
* the fixed-offset IHDR fields for the dimensions, and enforces the caps —
|
||||||
|
* which is exactly as far as one can go without a decoder.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Logo variants. A set belongs to one level and is never mixed across levels
|
||||||
|
* (#307): a pond with only a light logo shows THAT logo in dark mode rather
|
||||||
|
* than silently borrowing the instance's dark one. */
|
||||||
|
export const LOGO_VARIANTS = ['light', 'dark'] as const;
|
||||||
|
export type LogoVariant = (typeof LOGO_VARIANTS)[number];
|
||||||
|
|
||||||
|
/** Favicon sizes emitted by the browser-side crop: the tab icon and the
|
||||||
|
* home-screen icon. No `.ico` — every current browser accepts PNG. */
|
||||||
|
export const FAVICON_SIZES = [32, 180] as const;
|
||||||
|
export type FaviconSize = (typeof FAVICON_SIZES)[number];
|
||||||
|
|
||||||
|
/** Longest edge of an uploaded logo. Beyond this the browser downscales
|
||||||
|
* before uploading; the api rejects anything larger as a backstop. */
|
||||||
|
export const MAX_LOGO_EDGE = 512;
|
||||||
|
|
||||||
|
/** Per-file cap. A 512px PNG is tens of KB; 2 MiB leaves room for a
|
||||||
|
* needlessly lossless export without inviting abuse. */
|
||||||
|
export const MAX_BRANDING_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Formats a source image may have in the browser. SVG is deliberately absent:
|
||||||
|
* it can carry script, and serving it from our own origin would be a
|
||||||
|
* cross-site-scripting vector (security.md §Uploads). What leaves the canvas
|
||||||
|
* is PNG regardless. */
|
||||||
|
export const BRANDING_SOURCE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
|
||||||
|
|
||||||
|
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
|
||||||
|
/** True when `bytes` starts with the PNG signature. */
|
||||||
|
export function hasPngMagic(bytes: Uint8Array): boolean {
|
||||||
|
if (bytes.length < PNG_MAGIC.length) return false;
|
||||||
|
return PNG_MAGIC.every((byte, index) => bytes[index] === byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the bytes look like SVG (XML declaration or an `<svg` tag near
|
||||||
|
* the start). Only used to answer a rejected upload with the real reason
|
||||||
|
* instead of a generic "not a PNG". */
|
||||||
|
export function looksLikeSvg(bytes: Uint8Array): boolean {
|
||||||
|
const head = Buffer.from(bytes.subarray(0, 256)).toString('latin1').toLowerCase();
|
||||||
|
return head.includes('<svg') || (head.includes('<?xml') && head.includes('svg'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width and height from a PNG's IHDR, which is at a FIXED offset directly
|
||||||
|
* after the signature. Reading two big-endian integers is not decoding —
|
||||||
|
* nothing is decompressed and no attacker-controlled length drives a loop.
|
||||||
|
* Returns null when the bytes are not a PNG with an IHDR first.
|
||||||
|
*/
|
||||||
|
export function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
||||||
|
if (!hasPngMagic(bytes) || bytes.length < 33) return null;
|
||||||
|
const buf = Buffer.from(bytes.subarray(0, 33));
|
||||||
|
if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') return null;
|
||||||
|
const width = buf.readUInt32BE(16);
|
||||||
|
const height = buf.readUInt32BE(20);
|
||||||
|
if (width === 0 || height === 0) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is stored per asset. The bytes stay on disk; `hash` goes into the
|
||||||
|
* serving URL so a replaced logo is picked up without fighting caches. */
|
||||||
|
export const brandingAssetSchema = z.object({
|
||||||
|
hash: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^[a-f0-9]{16,64}$/),
|
||||||
|
width: z.number().int().min(1),
|
||||||
|
height: z.number().int().min(1),
|
||||||
|
});
|
||||||
|
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
|
||||||
|
|
||||||
|
/** What the api reports about the branding in force. Every field may be null —
|
||||||
|
* an instance without branding renders its name as text and the shipped
|
||||||
|
* default favicon. */
|
||||||
|
export interface BrandingView {
|
||||||
|
logo: BrandingAsset | null;
|
||||||
|
logoDark: BrandingAsset | null;
|
||||||
|
favicon: BrandingAsset | null;
|
||||||
|
/** The instance name, so the logo link has an accessible name and the
|
||||||
|
* logo-less case has something to render. Public on purpose: the login
|
||||||
|
* screen carries the branding. */
|
||||||
|
instanceName: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user