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