Compare commits
1 Commits
7f0a86b84c
...
ee6a11f9b0
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6a11f9b0 |
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 683 B |
@ -1,127 +0,0 @@
|
|||||||
#!/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}`);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
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!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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