#306: instance branding - logo and favicon, cropped in the browser #314
@ -29,19 +29,19 @@ ARG APP_VERSION=0.0.0-dev
|
|||||||
# Default the data dirs to the writable, node-owned locations created below, so
|
# Default the data dirs to the writable, node-owned locations created below, so
|
||||||
# the image works out of the box even where compose does not set them; compose
|
# the image works out of the box even where compose does not set them; compose
|
||||||
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
||||||
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# Generate the Prisma client for this image's platform.
|
||||||
RUN node node_modules/prisma/build/index.js generate
|
RUN node node_modules/prisma/build/index.js generate
|
||||||
# A fresh named volume mounted at /data/uploads, /data/plugins or /data/fonts
|
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
||||||
# is created
|
# or /data/branding is created
|
||||||
# root-owned; pre-creating them here (Docker copies an image directory's
|
# root-owned; pre-creating them here (Docker copies an image directory's
|
||||||
# ownership into a new volume on first mount) lets the non-root `node` user
|
# ownership into a new volume on first mount) lets the non-root `node` user
|
||||||
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
||||||
# node-owned keeps the shared `backups` volume writable for the backup
|
# node-owned keeps the shared `backups` volume writable for the backup
|
||||||
# sidecar even when the api container is the one that initializes it.
|
# sidecar even when the api container is the one that initializes it.
|
||||||
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/secrets /data/backups
|
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
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}`);
|
||||||
|
}
|
||||||
@ -11,9 +11,17 @@ import {
|
|||||||
} from '../settings/instance-settings.service';
|
} from '../settings/instance-settings.service';
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
import { SiteAdminGuard } from './site-admin.guard';
|
||||||
|
|
||||||
// Lifecycle markers, not configuration: never editable through this
|
// Lifecycle markers and file-backed metadata, not configuration: never
|
||||||
// endpoint (the setup lock must be irreversible, issue #80).
|
// editable through this endpoint. The setup lock must be irreversible
|
||||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
// (issue #80), and the branding entries only describe bytes on disk
|
||||||
|
// (issue #306) — writing one by hand would claim an asset that is not
|
||||||
|
// there. Both have their own write paths.
|
||||||
|
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
|
||||||
|
'setup.completedAt',
|
||||||
|
'instance.logo',
|
||||||
|
'instance.logoDark',
|
||||||
|
'instance.favicon',
|
||||||
|
]);
|
||||||
|
|
||||||
// Partial update: any subset of the known settings, each validated by
|
// Partial update: any subset of the known settings, each validated by
|
||||||
// its own schema inside the service (double validation is fine — this
|
// its own schema inside the service (double validation is fine — this
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BackupModule } from './backup/backup.module';
|
import { BackupModule } from './backup/backup.module';
|
||||||
|
import { BrandingModule } from './branding/branding.module';
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { maskTokenParam } from './common/mask-token-param';
|
import { maskTokenParam } from './common/mask-token-param';
|
||||||
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
||||||
@ -82,6 +83,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
|
BrandingModule,
|
||||||
FontsModule,
|
FontsModule,
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'quota.override_set': { severity: 'notice' },
|
'quota.override_set': { severity: 'notice' },
|
||||||
'read_trail.pruned': { severity: 'info' },
|
'read_trail.pruned': { severity: 'info' },
|
||||||
'settings.changed': { severity: 'notice' },
|
'settings.changed': { severity: 'notice' },
|
||||||
|
'branding.changed': { severity: 'notice' },
|
||||||
'font.uploaded': { severity: 'notice' },
|
'font.uploaded': { severity: 'notice' },
|
||||||
'font.deleted': { severity: 'notice' },
|
'font.deleted': { severity: 'notice' },
|
||||||
'setup.admin_created': { severity: 'notice' },
|
'setup.admin_created': { severity: 'notice' },
|
||||||
|
|||||||
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real PNG of `size`×`size`, built the same way the shipped default is —
|
||||||
|
* the api reads the IHDR, so the header has to be genuine.
|
||||||
|
*/
|
||||||
|
async function png(size: number): Promise<Buffer> {
|
||||||
|
const { deflateSync } = await import('node:zlib');
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
const crc32 = (buf: Buffer): number => {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
};
|
||||||
|
const chunk = (type: string, data: Buffer): Buffer => {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
};
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6;
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw)),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let brandingDir: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'markenzeichen mit teich 1';
|
||||||
|
const admin = { username: `ba-${suffix}` };
|
||||||
|
const plain = { username: `bp-${suffix}` };
|
||||||
|
let adminCookie: string;
|
||||||
|
let plainCookie: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
// A real directory: the point is that bytes land somewhere and come back.
|
||||||
|
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
|
||||||
|
process.env.BRANDING_DIR = brandingDir;
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: `Branding Admin ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
|
||||||
|
const plainUser = await users.createUser({
|
||||||
|
username: plain.username,
|
||||||
|
email: `${plain.username}@example.org`,
|
||||||
|
displayName: `Branding Plain ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(plainUser.id);
|
||||||
|
|
||||||
|
const login = async (username: string): Promise<string> =>
|
||||||
|
sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
adminCookie = await login(admin.username);
|
||||||
|
plainCookie = await login(plain.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.instanceSetting.deleteMany({
|
||||||
|
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
await rm(brandingDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BRANDING_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the shipped default favicon before anything is uploaded', async () => {
|
||||||
|
// The `<link rel="icon">` in index.html is a constant — this route must
|
||||||
|
// never 404, or the browser keeps its generic icon for good.
|
||||||
|
const res = await api().get('/api/v1/branding/favicon').expect(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/png');
|
||||||
|
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a logo, reports it, and serves the bytes without a session', async () => {
|
||||||
|
const bytes = await png(64);
|
||||||
|
const view = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', bytes, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||||||
|
expect(view.body.logoDark).toBeNull();
|
||||||
|
|
||||||
|
// On disk, under the key the pond override (#307) will extend.
|
||||||
|
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
|
||||||
|
expect(onDisk.length).toBe(bytes.length);
|
||||||
|
|
||||||
|
// Anonymous: the login screen carries the branding.
|
||||||
|
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
|
||||||
|
expect(served.headers['content-type']).toContain('image/png');
|
||||||
|
const anon = await api().get('/api/v1/branding').expect(200);
|
||||||
|
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
|
||||||
|
expect(anon.body.instanceName).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for a logo variant that was never uploaded', async () => {
|
||||||
|
// No shipped default for the logo: without one the app renders the
|
||||||
|
// instance NAME, so an empty answer is the honest one.
|
||||||
|
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an SVG with its own message, not a generic one', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_svg_rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bytes that are not a PNG at all', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_not_a_png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a logo larger than the maximum edge', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', await png(600), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_image_too_large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes both favicon sizes together and serves each back', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/favicon')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('png-32', await png(32), 'f32.png')
|
||||||
|
.attach('png-180', await png(180), 'f180.png')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
for (const size of [32, 180]) {
|
||||||
|
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
|
||||||
|
expect(res.body.length).toBe((await png(size)).length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a favicon whose bytes do not match the size they claim', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/favicon')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('png-32', await png(64), 'f32.png')
|
||||||
|
.attach('png-180', await png(180), 'f180.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_favicon_not_square');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears an asset and falls back again', async () => {
|
||||||
|
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
|
||||||
|
const view = await api().get('/api/v1/branding').expect(200);
|
||||||
|
expect(view.body.favicon).toBeNull();
|
||||||
|
// Back to the shipped default rather than a 404.
|
||||||
|
await api().get('/api/v1/branding/favicon').expect(200);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.delete('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps management away from a non-admin, but not reading', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', plainCookie)
|
||||||
|
.attach('file', await png(32), 'x.png')
|
||||||
|
.expect(403);
|
||||||
|
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
|
||||||
|
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits every branding change with scope, asset and direction', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=dark')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', await png(48), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
const entry = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
|
||||||
|
orderBy: { at: 'desc' },
|
||||||
|
});
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write branding metadata through the settings endpoint', async () => {
|
||||||
|
// The metadata describes bytes on disk; hand-writing it would claim an
|
||||||
|
// asset that is not there, so the settings PATCH does not accept it.
|
||||||
|
const res = await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('bad_request');
|
||||||
|
});
|
||||||
|
});
|
||||||
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
|
import {
|
||||||
|
DEFAULT_ATTACHMENT_EXTENSIONS,
|
||||||
|
VS_NFD_PROFILE,
|
||||||
|
brandingAssetSchema,
|
||||||
|
isVsNfdCompliant,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -18,6 +23,16 @@ export const INSTANCE_SETTINGS = {
|
|||||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
||||||
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||||
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
||||||
|
// Branding assets (issue #306). Metadata only — the PNG bytes live under
|
||||||
|
// BRANDING_DIR and travel in the restore set; `hash` goes into the serving
|
||||||
|
// URL so a replaced asset is picked up without cache trouble. Null = not
|
||||||
|
// uploaded: the instance name renders as text, the favicon falls back to
|
||||||
|
// the shipped default. `logoDark` is optional by design — without it the
|
||||||
|
// LIGHT logo is used in both themes, because showing the operator's own
|
||||||
|
// asset unchanged beats substituting one they did not choose (#307).
|
||||||
|
'instance.logo': brandingAssetSchema.nullable().default(null),
|
||||||
|
'instance.logoDark': brandingAssetSchema.nullable().default(null),
|
||||||
|
'instance.favicon': brandingAssetSchema.nullable().default(null),
|
||||||
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
|
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
|
||||||
// in quota_overrides and win over these (QuotaService, issue #22).
|
// in quota_overrides and win over these (QuotaService, issue #22).
|
||||||
'quota.editorsPerPond': z.number().int().min(0).default(5),
|
'quota.editorsPerPond': z.number().int().min(0).default(5),
|
||||||
|
|||||||
@ -20,7 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \
|
|||||||
# Baked-in volume paths (self-sufficient without compose env, like the
|
# Baked-in volume paths (self-sufficient without compose env, like the
|
||||||
# api image's PLUGINS_DIR — issue #71's lesson).
|
# api image's PLUGINS_DIR — issue #71's lesson).
|
||||||
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
|
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
|
||||||
CUSTOM_FONTS_DIR=/data/fonts \
|
CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding \
|
||||||
SECRETS_FILE=/data/secrets/secrets.env
|
SECRETS_FILE=/data/secrets/secrets.env
|
||||||
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
|
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
|
||||||
# volume archives, tzdata so BACKUP_TIME honors a configured TZ, and
|
# volume archives, tzdata so BACKUP_TIME honors a configured TZ, and
|
||||||
|
|||||||
30
apps/backup/src/data-dirs.test.ts
Normal file
30
apps/backup/src/data-dirs.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { backupEnvSchema } from '@dorfteich/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { dataDirs } from './data-dirs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fence against the failure #303 hit and #306 could repeat: a new data
|
||||||
|
* directory gets its env entry but not its line here, and the nightly archive
|
||||||
|
* skips it WORDLESSLY (`createArchive` tolerates missing directories on
|
||||||
|
* purpose). Nobody notices until a restore comes up short.
|
||||||
|
*
|
||||||
|
* Every `*_DIR` the backup sidecar knows must therefore travel in the archive.
|
||||||
|
* `BACKUPS_DIR` is the exception by definition — it is where the archive is
|
||||||
|
* written, not something archived into it.
|
||||||
|
*/
|
||||||
|
const NOT_DATA = new Set(['BACKUPS_DIR']);
|
||||||
|
|
||||||
|
describe('data directories (issues #303/#306)', () => {
|
||||||
|
it('archives every *_DIR the backup env declares', () => {
|
||||||
|
const env = backupEnvSchema.parse({ DATABASE_URL: 'postgresql://x/y' });
|
||||||
|
const values = env as unknown as Record<string, unknown>;
|
||||||
|
const declared = Object.keys(env).filter((key) => key.endsWith('_DIR') && !NOT_DATA.has(key));
|
||||||
|
const archived = dataDirs(env);
|
||||||
|
|
||||||
|
expect(declared.length).toBeGreaterThan(0);
|
||||||
|
for (const key of declared) {
|
||||||
|
expect(archived, `${key} is missing from dataDirs()`).toContain(values[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,7 +12,7 @@ import type { BackupEnv } from '@dorfteich/shared';
|
|||||||
* root from that and throws otherwise.
|
* root from that and throws otherwise.
|
||||||
*/
|
*/
|
||||||
export function dataDirs(
|
export function dataDirs(
|
||||||
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'>,
|
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' | 'BRANDING_DIR'>,
|
||||||
): string[] {
|
): string[] {
|
||||||
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR];
|
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR, env.BRANDING_DIR];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,12 @@ import type { RemoteLogger } from './remote.js';
|
|||||||
export async function performRestore(
|
export async function performRestore(
|
||||||
env: Pick<
|
env: Pick<
|
||||||
BackupEnv,
|
BackupEnv,
|
||||||
'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'
|
| 'BACKUPS_DIR'
|
||||||
|
| 'DATABASE_URL'
|
||||||
|
| 'UPLOADS_DIR'
|
||||||
|
| 'PLUGINS_DIR'
|
||||||
|
| 'CUSTOM_FONTS_DIR'
|
||||||
|
| 'BRANDING_DIR'
|
||||||
>,
|
>,
|
||||||
backupId: string,
|
backupId: string,
|
||||||
log: RemoteLogger,
|
log: RemoteLogger,
|
||||||
|
|||||||
@ -110,6 +110,9 @@ for (const scheme of SCHEMES) {
|
|||||||
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
||||||
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
||||||
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
|
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
|
||||||
|
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
|
||||||
|
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||||||
|
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||||||
await expectClean(page, `/admin (${scheme})`);
|
await expectClean(page, `/admin (${scheme})`);
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,6 +10,13 @@
|
|||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
|
||||||
<title>Dorfteich</title>
|
<title>Dorfteich</title>
|
||||||
|
<!-- Static link, dynamic resource (issue #306): the api answers with the
|
||||||
|
operator's favicon or the shipped default, so this href never has to
|
||||||
|
change and index.html stays a static file. An attribute like `lang`
|
||||||
|
cannot be indirected this way — that is #179's problem, not this
|
||||||
|
one's. -->
|
||||||
|
<link rel="icon" type="image/png" href="/api/v1/branding/favicon" />
|
||||||
|
<link rel="apple-touch-icon" href="/api/v1/branding/favicon?size=180" />
|
||||||
<!-- Classic (non-module) script: executes during head parsing, before
|
<!-- Classic (non-module) script: executes during head parsing, before
|
||||||
first paint and before the deferred module bundle. External file
|
first paint and before the deferred module bundle. External file
|
||||||
because the prod CSP forbids inline scripts (issue #180). -->
|
because the prod CSP forbids inline scripts (issue #180). -->
|
||||||
|
|||||||
51
apps/web/src/branding/BrandLogo.tsx
Normal file
51
apps/web/src/branding/BrandLogo.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { logoUrl, useBranding } from './use-branding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance identity at the top of the sidebar (issue #306): the uploaded
|
||||||
|
* logo as a link home, or the instance name as text when nothing is uploaded.
|
||||||
|
*
|
||||||
|
* Its accessible name is the INSTANCE NAME, never "logo": for a screen reader
|
||||||
|
* this is the link home, and a link's name has to say where it goes. The
|
||||||
|
* images are therefore `alt=""` — the link is already named.
|
||||||
|
*
|
||||||
|
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
|
||||||
|
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
|
||||||
|
* the correct logo is the one painted rather than the one that appears after a
|
||||||
|
* flash. Without a dark variant the light one carries both themes — the
|
||||||
|
* operator's own asset, shown unchanged, rather than a substitute they did
|
||||||
|
* not choose (the rule #307 extends to ponds).
|
||||||
|
*/
|
||||||
|
export function BrandLogo(): React.JSX.Element | null {
|
||||||
|
const branding = useBranding();
|
||||||
|
if (!branding) return null;
|
||||||
|
const { logo, logoDark, instanceName } = branding;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/" className="brand-logo" aria-label={instanceName}>
|
||||||
|
{logo ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
|
||||||
|
src={logoUrl('light', logo.hash)}
|
||||||
|
width={logo.width}
|
||||||
|
height={logo.height}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{logoDark && (
|
||||||
|
<img
|
||||||
|
className="brand-logo__img brand-logo__img--dark"
|
||||||
|
src={logoUrl('dark', logoDark.hash)}
|
||||||
|
width={logoDark.width}
|
||||||
|
height={logoDark.height}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="brand-logo__name">{instanceName}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/web/src/branding/CropField.tsx
Normal file
173
apps/web/src/branding/CropField.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Field } from '../components/forms';
|
||||||
|
import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an image, crop it, see the result (issue #306).
|
||||||
|
*
|
||||||
|
* The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper
|
||||||
|
* excludes keyboard and switch users outright, and a number input is
|
||||||
|
* arrow-key operable, screen-reader readable and announces its value without
|
||||||
|
* any custom aria plumbing — the accessible option is also the simpler one.
|
||||||
|
* The preview canvas is a picture of the result, never the control.
|
||||||
|
*
|
||||||
|
* The resulting pixel dimensions are stated in TEXT next to it, so the outcome
|
||||||
|
* does not depend on seeing the frame.
|
||||||
|
*/
|
||||||
|
export function CropField({
|
||||||
|
idPrefix,
|
||||||
|
square,
|
||||||
|
maxEdge,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
idPrefix: string;
|
||||||
|
/** Favicons are square by construction; a logo keeps its own proportions. */
|
||||||
|
square: boolean;
|
||||||
|
maxEdge: number;
|
||||||
|
/** Called with the rendering canvas whenever the crop changes, so the
|
||||||
|
* parent can encode PNGs from it on submit. Null = nothing selected. */
|
||||||
|
onChange: (canvas: HTMLCanvasElement | null) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||||
|
const [crop, setCrop] = useState<CropRect | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const out = image && crop ? outputSize(crop, maxEdge) : null;
|
||||||
|
|
||||||
|
// Held in a ref so the redraw depends on the crop alone: callers pass an
|
||||||
|
// inline arrow, whose identity changes every render and would otherwise
|
||||||
|
// repaint the canvas on every keystroke in the surrounding form.
|
||||||
|
const notifyRef = useRef(onChange);
|
||||||
|
notifyRef.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !image || !crop) {
|
||||||
|
notifyRef.current(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Derived inside the effect: `outputSize` returns a fresh object every
|
||||||
|
// render, so as a dependency it would never compare equal.
|
||||||
|
drawCrop(image, crop, outputSize(crop, maxEdge), canvas);
|
||||||
|
notifyRef.current(canvas);
|
||||||
|
}, [image, crop, maxEdge]);
|
||||||
|
|
||||||
|
async function choose(file: File | undefined): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
if (!file) {
|
||||||
|
setImage(null);
|
||||||
|
setCrop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) {
|
||||||
|
setImage(null);
|
||||||
|
setCrop(null);
|
||||||
|
// SVG is the one an operator is most likely to try, and it is refused
|
||||||
|
// on purpose (it can carry script) — say which types work instead.
|
||||||
|
setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const loaded = await loadImage(file);
|
||||||
|
setImage(loaded);
|
||||||
|
setCrop(initialCrop(loaded, square));
|
||||||
|
} catch {
|
||||||
|
setError('branding_not_an_image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch: Partial<CropRect>): void {
|
||||||
|
if (!image || !crop) return;
|
||||||
|
const next = { ...crop, ...patch };
|
||||||
|
// A square crop has one size, so width and height move together.
|
||||||
|
if (square && patch.width !== undefined) next.height = patch.width;
|
||||||
|
setCrop(clampCrop(next, image));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crop-field">
|
||||||
|
<Field label={t('crop.file')} hint={t('crop.fileHint')} error={error ?? undefined}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={BRANDING_SOURCE_TYPES.join(',')}
|
||||||
|
onChange={(event) => void choose(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{image && crop && out && (
|
||||||
|
<>
|
||||||
|
<div className="crop-field__controls">
|
||||||
|
<Field label={t('crop.x')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-x`}
|
||||||
|
min={0}
|
||||||
|
max={image.width - crop.width}
|
||||||
|
value={crop.x}
|
||||||
|
onChange={(event) => update({ x: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('crop.y')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-y`}
|
||||||
|
min={0}
|
||||||
|
max={image.height - crop.height}
|
||||||
|
value={crop.y}
|
||||||
|
onChange={(event) => update({ y: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={square ? t('crop.size') : t('crop.width')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-w`}
|
||||||
|
min={1}
|
||||||
|
max={square ? Math.min(image.width, image.height) : image.width}
|
||||||
|
value={crop.width}
|
||||||
|
onChange={(event) => update({ width: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!square && (
|
||||||
|
<Field label={t('crop.height')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-h`}
|
||||||
|
min={1}
|
||||||
|
max={image.height}
|
||||||
|
value={crop.height}
|
||||||
|
onChange={(event) => update({ height: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() => setCrop(initialCrop(image, square))}
|
||||||
|
>
|
||||||
|
{t('crop.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="crop-field__preview">
|
||||||
|
<canvas ref={canvasRef} className="crop-field__canvas" />
|
||||||
|
{/* The outcome in words: the frame alone would leave a
|
||||||
|
keyboard-only or screen-reader user guessing. */}
|
||||||
|
<p className="crop-field__result" role="status">
|
||||||
|
{t('crop.result', {
|
||||||
|
width: out.width,
|
||||||
|
height: out.height,
|
||||||
|
sourceWidth: image.width,
|
||||||
|
sourceHeight: image.height,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/web/src/branding/crop.test.ts
Normal file
73
apps/web/src/branding/crop.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { clampCrop, initialCrop, outputSize } from './crop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The crop arithmetic (issue #306). Pure functions on purpose: the canvas
|
||||||
|
* work is a thin shell around these, and getting the bounds wrong is what
|
||||||
|
* would let a number input produce a rectangle outside the image.
|
||||||
|
*/
|
||||||
|
describe('initialCrop', () => {
|
||||||
|
it('takes the whole image when the aspect is free', () => {
|
||||||
|
expect(initialCrop({ width: 900, height: 300 }, false)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 900,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres the largest square that fits', () => {
|
||||||
|
expect(initialCrop({ width: 900, height: 300 }, true)).toEqual({
|
||||||
|
x: 300,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('outputSize', () => {
|
||||||
|
it('scales the long edge down to the bound and keeps the ratio', () => {
|
||||||
|
expect(outputSize({ x: 0, y: 0, width: 900, height: 300 }, 512)).toEqual({
|
||||||
|
width: 512,
|
||||||
|
height: 171,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never scales UP — enlarging would only invent pixels', () => {
|
||||||
|
expect(outputSize({ x: 0, y: 0, width: 120, height: 40 }, 512)).toEqual({
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampCrop', () => {
|
||||||
|
const source = { width: 200, height: 100 };
|
||||||
|
|
||||||
|
it('keeps the rectangle inside the image', () => {
|
||||||
|
expect(clampCrop({ x: 190, y: 90, width: 50, height: 50 }, source)).toEqual({
|
||||||
|
x: 150,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a size fall below one pixel or exceed the source', () => {
|
||||||
|
expect(clampCrop({ x: 0, y: 0, width: 0, height: 999 }, source)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a negative offset by pulling it back to the edge', () => {
|
||||||
|
expect(clampCrop({ x: -30, y: -5, width: 20, height: 20 }, source)).toMatchObject({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
106
apps/web/src/branding/crop.ts
Normal file
106
apps/web/src/branding/crop.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Client-side image preparation for branding uploads (issue #306).
|
||||||
|
*
|
||||||
|
* Cropping, scaling and the conversion to PNG happen here on a canvas; the
|
||||||
|
* api receives finished bytes and never decodes an image. That keeps a
|
||||||
|
* decoder away from attacker-supplied bytes and keeps `sharp` (and its
|
||||||
|
* platform binaries) out of the `--network none` offline build.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CropRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a file into an `HTMLImageElement`, rejecting what the browser cannot
|
||||||
|
* decode — the first line of defence, before anything reaches the api. */
|
||||||
|
export function loadImage(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('image_undecodable'));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crop the editor starts with: the largest centred rectangle of the
|
||||||
|
* wanted aspect that fits the source. */
|
||||||
|
export function initialCrop(source: { width: number; height: number }, square: boolean): CropRect {
|
||||||
|
if (!square) return { x: 0, y: 0, width: source.width, height: source.height };
|
||||||
|
const size = Math.min(source.width, source.height);
|
||||||
|
return {
|
||||||
|
x: Math.round((source.width - size) / 2),
|
||||||
|
y: Math.round((source.height - size) / 2),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output size for a crop: scaled down so the longest edge fits `maxEdge`,
|
||||||
|
* never scaled UP — enlarging would only invent pixels. */
|
||||||
|
export function outputSize(crop: CropRect, maxEdge: number): { width: number; height: number } {
|
||||||
|
const longest = Math.max(crop.width, crop.height);
|
||||||
|
const factor = longest > maxEdge ? maxEdge / longest : 1;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(crop.width * factor)),
|
||||||
|
height: Math.max(1, Math.round(crop.height * factor)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a crop inside the source and above 1px, so number inputs cannot
|
||||||
|
* produce a rectangle the canvas would refuse. */
|
||||||
|
export function clampCrop(crop: CropRect, source: { width: number; height: number }): CropRect {
|
||||||
|
const width = Math.min(Math.max(1, Math.round(crop.width)), source.width);
|
||||||
|
const height = Math.min(Math.max(1, Math.round(crop.height)), source.height);
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x: Math.min(Math.max(0, Math.round(crop.x)), source.width - width),
|
||||||
|
y: Math.min(Math.max(0, Math.round(crop.y)), source.height - height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the crop into a canvas at the given output size. */
|
||||||
|
export function drawCrop(
|
||||||
|
image: CanvasImageSource,
|
||||||
|
crop: CropRect,
|
||||||
|
out: { width: number; height: number },
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
): void {
|
||||||
|
canvas.width = out.width;
|
||||||
|
canvas.height = out.height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
context.clearRect(0, 0, out.width, out.height);
|
||||||
|
context.imageSmoothingQuality = 'high';
|
||||||
|
context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, out.width, out.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canvas contents as PNG bytes.
|
||||||
|
*
|
||||||
|
* PNG regardless of the source format — which is why the form states that a
|
||||||
|
* JPEG source cannot gain transparency: the alpha channel exists in the
|
||||||
|
* output, but every pixel of a JPEG is opaque, so the background stays.
|
||||||
|
* Conversion cannot invent what was never in the file.
|
||||||
|
*/
|
||||||
|
export function canvasToPngFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('canvas_encode_failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(new File([blob], name, { type: 'image/png' }));
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/web/src/branding/use-branding.ts
Normal file
34
apps/web/src/branding/use-branding.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { BrandingView } from '@dorfteich/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
export const BRANDING_KEY = ['branding'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance branding in force (issue #306).
|
||||||
|
*
|
||||||
|
* Public, so the login screen carries it too — an operator's logo IS visible
|
||||||
|
* to anonymous visitors, which the admin screen says out loud.
|
||||||
|
*/
|
||||||
|
export function useBranding(): BrandingView | undefined {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: BRANDING_KEY,
|
||||||
|
queryFn: () => apiGet<BrandingView>('/branding'),
|
||||||
|
// Branding changes are rare and the manager invalidates the key itself.
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
}).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL of a logo variant, with the content hash so a replaced logo is never
|
||||||
|
* served from cache. */
|
||||||
|
export function logoUrl(variant: 'light' | 'dark', hash: string): string {
|
||||||
|
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvalidateBranding(): () => Promise<void> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import deAccess from '@dorfteich/shared/i18n/de/access.json';
|
import deAccess from '@dorfteich/shared/i18n/de/access.json';
|
||||||
import deAuth from '@dorfteich/shared/i18n/de/auth.json';
|
import deAuth from '@dorfteich/shared/i18n/de/auth.json';
|
||||||
|
import deBranding from '@dorfteich/shared/i18n/de/branding.json';
|
||||||
import deComments from '@dorfteich/shared/i18n/de/comments.json';
|
import deComments from '@dorfteich/shared/i18n/de/comments.json';
|
||||||
import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
||||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||||
@ -28,6 +29,7 @@ import deWatches from '@dorfteich/shared/i18n/de/watches.json';
|
|||||||
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
||||||
|
import enBranding from '@dorfteich/shared/i18n/en/branding.json';
|
||||||
import enComments from '@dorfteich/shared/i18n/en/comments.json';
|
import enComments from '@dorfteich/shared/i18n/en/comments.json';
|
||||||
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||||
@ -79,6 +81,7 @@ void i18n
|
|||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
export: enExport,
|
export: enExport,
|
||||||
files: enFiles,
|
files: enFiles,
|
||||||
|
branding: enBranding,
|
||||||
font: enFont,
|
font: enFont,
|
||||||
graph: enGraph,
|
graph: enGraph,
|
||||||
import: enImport,
|
import: enImport,
|
||||||
@ -109,6 +112,7 @@ void i18n
|
|||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
export: deExport,
|
export: deExport,
|
||||||
files: deFiles,
|
files: deFiles,
|
||||||
|
branding: deBranding,
|
||||||
font: deFont,
|
font: deFont,
|
||||||
graph: deGraph,
|
graph: deGraph,
|
||||||
import: deImport,
|
import: deImport,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites';
|
|||||||
import { ImportControl } from '../import/ImportControl';
|
import { ImportControl } from '../import/ImportControl';
|
||||||
import { LabelChips } from '../labels/LabelChips';
|
import { LabelChips } from '../labels/LabelChips';
|
||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
|
import { BrandLogo } from '../branding/BrandLogo';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
import { usePersistentState } from '../lib/use-persistent-state';
|
import { usePersistentState } from '../lib/use-persistent-state';
|
||||||
import { NewPageForm } from './NewPageForm';
|
import { NewPageForm } from './NewPageForm';
|
||||||
@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
|
|||||||
aria-label={t('layout.sidebar.label')}
|
aria-label={t('layout.sidebar.label')}
|
||||||
>
|
>
|
||||||
{resizer}
|
{resizer}
|
||||||
|
{/* The instance identity sits ABOVE the pond section, not inside it:
|
||||||
|
the sidebar has no pond header outside a pond, and the logo is the
|
||||||
|
link home — it must not disappear on /admin or the start page
|
||||||
|
(issue #306). The pond name below stays the heading. */}
|
||||||
|
<BrandLogo />
|
||||||
{!pond.data ? (
|
{!pond.data ? (
|
||||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
|
import { useBranding } from '../branding/use-branding';
|
||||||
import { IconButton, IconLink } from '../components/IconButton';
|
import { IconButton, IconLink } from '../components/IconButton';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { isTypingTarget } from '../lib/keyboard';
|
import { isTypingTarget } from '../lib/keyboard';
|
||||||
@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
enabled: Boolean(user && pondSlug),
|
enabled: Boolean(user && pondSlug),
|
||||||
});
|
});
|
||||||
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
||||||
|
// Instance identity (issue #306) — shared query, also read by the sidebar
|
||||||
|
// logo and reachable without a session (the login screen carries it).
|
||||||
|
const branding = useBranding();
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
>
|
>
|
||||||
<Menu aria-hidden />
|
<Menu aria-hidden />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{/* The operator's instance name, not the product name (issue #306):
|
||||||
|
an operator who uploaded their own logo does not expect "Dorfteich"
|
||||||
|
to stay in the chrome. `instance.name` defaults to "Dorfteich", so
|
||||||
|
an untouched instance looks exactly as before. */}
|
||||||
<Link to="/" className="topbar__brand">
|
<Link to="/" className="topbar__brand">
|
||||||
Dorfteich
|
{branding?.instanceName ?? 'Dorfteich'}
|
||||||
</Link>
|
</Link>
|
||||||
{user && <PondSwitcher />}
|
{user && <PondSwitcher />}
|
||||||
{isPondOwner && pondSlug && (
|
{isPondOwner && pondSlug && (
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Field, FormError, FormSuccess } from '../components/forms';
|
|||||||
import { SettingsLayout } from '../components/SettingsLayout';
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
|
import { BrandingManager } from './BrandingManager';
|
||||||
import { CustomFontManager } from './CustomFontManager';
|
import { CustomFontManager } from './CustomFontManager';
|
||||||
import { PluginManager } from './PluginManager';
|
import { PluginManager } from './PluginManager';
|
||||||
import { QuotaManager } from './QuotaManager';
|
import { QuotaManager } from './QuotaManager';
|
||||||
@ -181,6 +182,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<LandingSettingsForm settings={settings.data} />
|
<LandingSettingsForm settings={settings.data} />
|
||||||
<LegalSettingsForm settings={settings.data} />
|
<LegalSettingsForm settings={settings.data} />
|
||||||
|
|
||||||
|
<BrandingManager />
|
||||||
<CustomFontManager />
|
<CustomFontManager />
|
||||||
<PluginManager />
|
<PluginManager />
|
||||||
<QuotaManager />
|
<QuotaManager />
|
||||||
|
|||||||
228
apps/web/src/pages/BrandingManager.tsx
Normal file
228
apps/web/src/pages/BrandingManager.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { BrandingView, LogoVariant, MAX_LOGO_EDGE } from '@dorfteich/shared';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { CropField } from '../branding/CropField';
|
||||||
|
import { canvasToPngFile, drawCrop } from '../branding/crop';
|
||||||
|
import { logoUrl, useInvalidateBranding } from '../branding/use-branding';
|
||||||
|
import { FormError, FormSuccess } from '../components/forms';
|
||||||
|
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||||
|
|
||||||
|
/** The favicon is uploaded as the pair the browser rendered — see the api's
|
||||||
|
* reasoning: it cannot resize, and one source must not become two icons. */
|
||||||
|
const FAVICON_SIZES = [32, 180] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-Admin branding management (issue #306): the instance logo (light and
|
||||||
|
* an optional dark variant) and the favicon.
|
||||||
|
*
|
||||||
|
* The images are prepared in the browser — see `branding/crop.ts` for why the
|
||||||
|
* api never decodes one.
|
||||||
|
*/
|
||||||
|
export function BrandingManager(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const invalidate = useInvalidateBranding();
|
||||||
|
|
||||||
|
const branding = useQuery({
|
||||||
|
queryKey: ['admin', 'branding'],
|
||||||
|
queryFn: () => apiGet<BrandingView>('/branding'),
|
||||||
|
});
|
||||||
|
const view = branding.data;
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
await branding.refetch();
|
||||||
|
await invalidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section branding">
|
||||||
|
<h2>{t('admin.title')}</h2>
|
||||||
|
<p>{t('admin.intro')}</p>
|
||||||
|
{/* An operator may not expect their logo to be readable by anyone who
|
||||||
|
opens the login page — so say it, rather than let them find out. */}
|
||||||
|
<p>{t('admin.publicNote')}</p>
|
||||||
|
|
||||||
|
<LogoSection variant="light" asset={view?.logo ?? null} onChanged={refresh} />
|
||||||
|
<LogoSection variant="dark" asset={view?.logoDark ?? null} onChanged={refresh} />
|
||||||
|
{view?.logo && !view.logoDark && (
|
||||||
|
// Advisory, never blocking (issue #306): it names the consequence and
|
||||||
|
// the operator may decide their logo works on both surfaces.
|
||||||
|
<p className="branding__warning" role="note">
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{t('admin.darkMissing')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FaviconSection present={Boolean(view?.favicon)} onChanged={refresh} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoSection({
|
||||||
|
variant,
|
||||||
|
asset,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
variant: LogoVariant;
|
||||||
|
asset: { hash: string; width: number; height: number } | null;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) throw new Error('no image');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
|
||||||
|
return apiPostForm<BrandingView>(`/admin/branding/logo?variant=${variant}`, form);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(true);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => apiDelete(`/admin/branding/logo?variant=${variant}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(false);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branding__slot" data-logo-variant={variant}>
|
||||||
|
<h3>{t(`admin.logo.${variant}`)}</h3>
|
||||||
|
<p>{t(`admin.logo.${variant}Hint`)}</p>
|
||||||
|
<FormError error={upload.error ?? remove.error} />
|
||||||
|
<FormSuccess message={done ? t('admin.logo.saved') : null} />
|
||||||
|
|
||||||
|
{asset ? (
|
||||||
|
<div className="branding__current">
|
||||||
|
<img
|
||||||
|
src={logoUrl(variant, asset.hash)}
|
||||||
|
alt={t('admin.logo.currentAlt')}
|
||||||
|
className={`branding__preview branding__preview--${variant}`}
|
||||||
|
/>
|
||||||
|
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--outline"
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.logo.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>{t('admin.logo.none')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CropField
|
||||||
|
idPrefix={`logo-${variant}`}
|
||||||
|
square={false}
|
||||||
|
maxEdge={MAX_LOGO_EDGE}
|
||||||
|
onChange={(canvas) => {
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
setDone(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={() => upload.mutate()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.logo.submit')}
|
||||||
|
</button>
|
||||||
|
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaviconSection({
|
||||||
|
present,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
present: boolean;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const source = canvasRef.current;
|
||||||
|
if (!source) throw new Error('no image');
|
||||||
|
const form = new FormData();
|
||||||
|
// One source, both sizes, rendered from the same crop — the tab icon
|
||||||
|
// and the home-screen icon can then never disagree.
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
const scratch = document.createElement('canvas');
|
||||||
|
drawCrop(
|
||||||
|
source,
|
||||||
|
{ x: 0, y: 0, width: source.width, height: source.height },
|
||||||
|
{ width: size, height: size },
|
||||||
|
scratch,
|
||||||
|
);
|
||||||
|
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
|
||||||
|
}
|
||||||
|
return apiPostForm<BrandingView>('/admin/branding/favicon', form);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(true);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => apiDelete('/admin/branding/favicon'),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(false);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branding__slot" data-branding-slot="favicon">
|
||||||
|
<h3>{t('admin.favicon.title')}</h3>
|
||||||
|
<p>{t('admin.favicon.hint')}</p>
|
||||||
|
<FormError error={upload.error ?? remove.error} />
|
||||||
|
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
|
||||||
|
<p>{present ? t('admin.favicon.present') : t('admin.favicon.default')}</p>
|
||||||
|
{present && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--outline"
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.favicon.remove')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<CropField
|
||||||
|
idPrefix="favicon"
|
||||||
|
square
|
||||||
|
maxEdge={180}
|
||||||
|
onChange={(canvas) => {
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
setDone(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={() => upload.mutate()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.favicon.submit')}
|
||||||
|
</button>
|
||||||
|
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4178,3 +4178,106 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
.custom-fonts input[type='file'] {
|
.custom-fonts input[type='file'] {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
/* Instance branding (issue #306). The logo sits above the pond name in the
|
||||||
|
sidebar; the two variants are both rendered and one is hidden here rather
|
||||||
|
than in JavaScript, so the right one is the one PAINTED — theme-init.js has
|
||||||
|
already resolved data-theme when this applies. */
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo__img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
/* A tall logo must not push the pond name out of view. */
|
||||||
|
max-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo__name {
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without a dark variant the light logo carries both themes (#306/#307:
|
||||||
|
variants are never mixed across levels). */
|
||||||
|
.brand-logo__img--dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .brand-logo__img--light:not(.brand-logo__img--both) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .brand-logo__img--dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding__slot {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding__preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 6rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dark logo is meant for a dark surface — previewing it on the light
|
||||||
|
admin background would misrepresent it. */
|
||||||
|
.branding__preview--dark {
|
||||||
|
background: #10161d;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding__warning {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-field input[type='file'] {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-field__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-field__controls .field {
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-field__canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
/* A transparent PNG on a transparent page shows nothing — the checkerboard
|
||||||
|
is how an operator sees that the background really is transparent. */
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, var(--color-surface-muted) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, var(--color-surface-muted) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, var(--color-surface-muted) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, var(--color-surface-muted) 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 8px,
|
||||||
|
8px -8px,
|
||||||
|
-8px 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -91,6 +91,9 @@ services:
|
|||||||
# uploads/plugins so all three travel in one restore set — NOT inside
|
# uploads/plugins so all three travel in one restore set — NOT inside
|
||||||
# the image-baked font catalog, which a deploy would overwrite.
|
# the image-baked font catalog, which a deploy would overwrite.
|
||||||
CUSTOM_FONTS_DIR: /data/fonts
|
CUSTOM_FONTS_DIR: /data/fonts
|
||||||
|
# Instance and pond branding assets (issues #306/#307) — same reasoning
|
||||||
|
# as the fonts above: operator data, so its own volume in the restore set.
|
||||||
|
BRANDING_DIR: /data/branding
|
||||||
# Read-only view of the backup sidecar's volume — the api only consumes
|
# Read-only view of the backup sidecar's volume — the api only consumes
|
||||||
# its status.json (readyz freshness #85, admin backup card #86).
|
# its status.json (readyz freshness #85, admin backup card #86).
|
||||||
BACKUPS_DIR: /data/backups
|
BACKUPS_DIR: /data/backups
|
||||||
@ -105,6 +108,7 @@ services:
|
|||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
- plugins:/data/plugins
|
- plugins:/data/plugins
|
||||||
- customfonts:/data/fonts
|
- customfonts:/data/fonts
|
||||||
|
- branding:/data/branding
|
||||||
- secrets:/data/secrets
|
- secrets:/data/secrets
|
||||||
- backups:/data/backups:ro
|
- backups:/data/backups:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -194,6 +198,7 @@ services:
|
|||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
- plugins:/data/plugins
|
- plugins:/data/plugins
|
||||||
- customfonts:/data/fonts
|
- customfonts:/data/fonts
|
||||||
|
- branding:/data/branding
|
||||||
- secrets:/data/secrets:ro
|
- secrets:/data/secrets:ro
|
||||||
- backups:/backups
|
- backups:/backups
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -280,6 +285,7 @@ volumes:
|
|||||||
uploads:
|
uploads:
|
||||||
plugins:
|
plugins:
|
||||||
customfonts:
|
customfonts:
|
||||||
|
branding:
|
||||||
secrets:
|
secrets:
|
||||||
backups:
|
backups:
|
||||||
# Only used by the optional `caddy` profile (certificates + state).
|
# Only used by the optional `caddy` profile (certificates + state).
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# Audit event catalogue
|
# Audit event catalogue
|
||||||
|
|
||||||
**Catalogue version 1.6 (2026-08-01; 1.6 adds `font.uploaded` and
|
**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`,
|
||||||
|
issue #306; 1.6 added `font.uploaded` and
|
||||||
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
||||||
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
||||||
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||||
@ -88,7 +89,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`)
|
### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`)
|
||||||
|
|
||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | -------------------- |
|
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
|
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
|
||||||
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
|
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
|
||||||
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
||||||
@ -97,6 +98,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` |
|
| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` |
|
||||||
| `quota.override_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` |
|
| `quota.override_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` |
|
||||||
| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — |
|
| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — |
|
||||||
|
| `branding.changed` | Logo or favicon uploaded or removed (#306/#307) | notice | the admin | `setting` (key) | `scope` (`instance`/`pond`), `asset` (`logo`/`logoDark`/`favicon`), `change` (`set`/`cleared`), `pondId` (pond scope) |
|
||||||
| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` |
|
| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` |
|
||||||
|
|
||||||
### Classification (`page.classification_*`, ADR 0022, issue #205)
|
### Classification (`page.classification_*`, ADR 0022, issue #205)
|
||||||
|
|||||||
@ -139,6 +139,21 @@ or sloppy plugin authors, compromised dependencies.
|
|||||||
header; the path is validated against the file's own slug prefix, so it
|
header; the path is validated against the file's own slug prefix, so it
|
||||||
cannot reach another family's directory. Uploads and deletions are
|
cannot reach another family's directory. Uploads and deletions are
|
||||||
audited (`font.uploaded`, `font.deleted`).
|
audited (`font.uploaded`, `font.deleted`).
|
||||||
|
- **Branding upload (issue #306)**: Site Admins upload an instance logo and
|
||||||
|
favicon; the pond-level override (#307) puts the same surface in the hands
|
||||||
|
of ordinary Pond Admins, so the rules are identical at both levels. **SVG is
|
||||||
|
refused** — it can carry script, and serving it from our own origin would be
|
||||||
|
a cross-site-scripting vector. Cropping, scaling and the conversion to PNG
|
||||||
|
happen in the BROWSER on a canvas; the api validates the PNG signature, the
|
||||||
|
IHDR dimensions (fixed offsets — no decoding) and a size cap, then stores
|
||||||
|
the bytes. No image library runs in the api: it would put a decoder in front
|
||||||
|
of attacker-supplied bytes and would have to be carried through the
|
||||||
|
`--network none` offline build. Assets are served from
|
||||||
|
`/api/v1/branding/…` with a pinned `image/png` content type under the
|
||||||
|
instance-wide `nosniff` header. The serving routes are **unauthenticated by
|
||||||
|
design** — the login screen carries the branding and the browser fetches the
|
||||||
|
favicon before anyone signs in; the admin UI states this. Changes are
|
||||||
|
audited (`branding.changed`).
|
||||||
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
|
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
|
||||||
no third-party origins at all — the GDPR posture is "zero external
|
no third-party origins at all — the GDPR posture is "zero external
|
||||||
requests". Operator-uploaded fonts are served from the instance itself
|
requests". Operator-uploaded fonts are served from the instance itself
|
||||||
|
|||||||
41
packages/shared/i18n/de/branding.json
Normal file
41
packages/shared/i18n/de/branding.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"admin": {
|
||||||
|
"title": "Erscheinungsbild der Instanz",
|
||||||
|
"intro": "Lade ein Logo und ein Favicon hoch. Das Logo steht in der Seitenleiste oben und verlinkt auf die Startseite; das Favicon zeigt der Browser im Tab. Ohne Logo erscheint dort der Name der Instanz als Text.",
|
||||||
|
"publicNote": "Beides ist ohne Anmeldung sichtbar: der Anmeldebildschirm trägt das Logo, und das Favicon lädt der Browser, bevor sich jemand anmeldet.",
|
||||||
|
"darkMissing": "Für den Dunkelmodus ist kein eigenes Logo hinterlegt. Dann wird dort das helle Logo verwendet — auf dunklem Grund kann das schlecht aussehen. Das ist ein Hinweis, keine Sperre.",
|
||||||
|
"uploading": "Das Bild wird hochgeladen …",
|
||||||
|
"logo": {
|
||||||
|
"light": "Logo (Hellmodus)",
|
||||||
|
"lightHint": "Empfohlen: PNG mit transparentem Hintergrund. Wird auch im Dunkelmodus verwendet, solange dort kein eigenes Logo hinterlegt ist.",
|
||||||
|
"dark": "Logo (Dunkelmodus, optional)",
|
||||||
|
"darkHint": "Nur nötig, wenn das helle Logo auf dunklem Grund nicht funktioniert.",
|
||||||
|
"current": "Hinterlegt: {{width}} × {{height}} px.",
|
||||||
|
"currentAlt": "Vorschau des hinterlegten Logos",
|
||||||
|
"none": "Es ist kein Logo hinterlegt.",
|
||||||
|
"remove": "Logo entfernen",
|
||||||
|
"submit": "Logo speichern",
|
||||||
|
"saved": "Das Logo wurde gespeichert."
|
||||||
|
},
|
||||||
|
"favicon": {
|
||||||
|
"title": "Favicon",
|
||||||
|
"hint": "Ein quadratischer Ausschnitt; daraus entstehen die Größen 32 × 32 px (Browser-Tab) und 180 × 180 px (Startbildschirm).",
|
||||||
|
"present": "Ein eigenes Favicon ist hinterlegt.",
|
||||||
|
"default": "Es ist kein eigenes Favicon hinterlegt — ausgeliefert wird das mitgelieferte Standard-Favicon.",
|
||||||
|
"remove": "Favicon entfernen",
|
||||||
|
"submit": "Favicon speichern",
|
||||||
|
"saved": "Das Favicon wurde gespeichert."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"file": "Bilddatei",
|
||||||
|
"fileHint": "PNG, JPEG oder WebP. SVG wird nicht angenommen, weil darin Skripte stecken können. Ausgeliefert wird immer PNG — Achtung: ein JPEG hat keinen Transparenzkanal, aus einem JPEG entsteht also ein PNG mit deckendem Hintergrund. Zuschneiden und Umwandeln geht, Transparenz lässt sich nicht nachträglich erzeugen.",
|
||||||
|
"x": "Ausschnitt von links (px)",
|
||||||
|
"y": "Ausschnitt von oben (px)",
|
||||||
|
"width": "Breite des Ausschnitts (px)",
|
||||||
|
"height": "Höhe des Ausschnitts (px)",
|
||||||
|
"size": "Kantenlänge des Ausschnitts (px)",
|
||||||
|
"reset": "Ausschnitt zurücksetzen",
|
||||||
|
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -127,5 +127,13 @@
|
|||||||
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
|
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
|
||||||
"font_weight_invalid": "Dieser Schnitt ist nicht zulässig.",
|
"font_weight_invalid": "Dieser Schnitt ist nicht zulässig.",
|
||||||
"font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.",
|
"font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.",
|
||||||
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld."
|
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld.",
|
||||||
|
"branding_file_empty": "Die Bilddatei ist leer.",
|
||||||
|
"branding_file_too_large": "Die Bilddatei ist zu groß.",
|
||||||
|
"branding_file_missing": "Es wurde keine Bilddatei übermittelt.",
|
||||||
|
"branding_not_a_png": "Es konnte kein PNG erzeugt werden — bitte eine andere Bilddatei wählen.",
|
||||||
|
"branding_not_an_image": "Diese Datei ist kein unterstütztes Bild (PNG, JPEG oder WebP).",
|
||||||
|
"branding_svg_rejected": "SVG wird nicht angenommen: eine SVG-Datei kann Skripte enthalten. Bitte PNG, JPEG oder WebP verwenden.",
|
||||||
|
"branding_image_too_large": "Das Bild ist zu groß — bitte den Ausschnitt verkleinern.",
|
||||||
|
"branding_favicon_not_square": "Das Favicon muss quadratisch sein."
|
||||||
}
|
}
|
||||||
|
|||||||
41
packages/shared/i18n/en/branding.json
Normal file
41
packages/shared/i18n/en/branding.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"admin": {
|
||||||
|
"title": "Instance appearance",
|
||||||
|
"intro": "Upload a logo and a favicon. The logo sits at the top of the sidebar and links to the start page; the favicon is what the browser shows in the tab. Without a logo the instance name is rendered there as text.",
|
||||||
|
"publicNote": "Both are visible without signing in: the login screen carries the logo, and the browser fetches the favicon before anyone signs in.",
|
||||||
|
"darkMissing": "No separate dark-mode logo is set. The light logo is then used there too — which can look wrong on a dark surface. This is advice, not a block.",
|
||||||
|
"uploading": "Uploading the image …",
|
||||||
|
"logo": {
|
||||||
|
"light": "Logo (light mode)",
|
||||||
|
"lightHint": "Recommended: PNG with a transparent background. Also used in dark mode as long as no separate logo is set there.",
|
||||||
|
"dark": "Logo (dark mode, optional)",
|
||||||
|
"darkHint": "Only needed when the light logo does not work on a dark surface.",
|
||||||
|
"current": "Stored: {{width}} × {{height}} px.",
|
||||||
|
"currentAlt": "Preview of the stored logo",
|
||||||
|
"none": "No logo is stored.",
|
||||||
|
"remove": "Remove the logo",
|
||||||
|
"submit": "Save the logo",
|
||||||
|
"saved": "The logo was saved."
|
||||||
|
},
|
||||||
|
"favicon": {
|
||||||
|
"title": "Favicon",
|
||||||
|
"hint": "A square crop; it produces the 32 × 32 px (browser tab) and 180 × 180 px (home screen) sizes.",
|
||||||
|
"present": "A custom favicon is stored.",
|
||||||
|
"default": "No custom favicon is stored — the shipped default is served.",
|
||||||
|
"remove": "Remove the favicon",
|
||||||
|
"submit": "Save the favicon",
|
||||||
|
"saved": "The favicon was saved."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"file": "Image file",
|
||||||
|
"fileHint": "PNG, JPEG or WebP. SVG is not accepted because it can carry script. The result is always PNG — note that a JPEG has no alpha channel, so converting one produces a PNG with an opaque background. Cropping and conversion are offered; transparency cannot be invented.",
|
||||||
|
"x": "Crop from the left (px)",
|
||||||
|
"y": "Crop from the top (px)",
|
||||||
|
"width": "Crop width (px)",
|
||||||
|
"height": "Crop height (px)",
|
||||||
|
"size": "Crop edge length (px)",
|
||||||
|
"reset": "Reset the crop",
|
||||||
|
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -127,5 +127,13 @@
|
|||||||
"font_weight_exists": "This weight already exists for this font.",
|
"font_weight_exists": "This weight already exists for this font.",
|
||||||
"font_weight_invalid": "This weight is not allowed.",
|
"font_weight_invalid": "This weight is not allowed.",
|
||||||
"font_one_weight_expected": "Only one weight can be added at a time.",
|
"font_one_weight_expected": "Only one weight can be added at a time.",
|
||||||
"font_unexpected_field": "The request contains an unexpected field."
|
"font_unexpected_field": "The request contains an unexpected field.",
|
||||||
|
"branding_file_empty": "The image file is empty.",
|
||||||
|
"branding_file_too_large": "The image file is too large.",
|
||||||
|
"branding_file_missing": "No image file was submitted.",
|
||||||
|
"branding_not_a_png": "No PNG could be produced — please choose a different image file.",
|
||||||
|
"branding_not_an_image": "This file is not a supported image (PNG, JPEG or WebP).",
|
||||||
|
"branding_svg_rejected": "SVG is not accepted: an SVG file can carry script. Please use PNG, JPEG or WebP.",
|
||||||
|
"branding_image_too_large": "The image is too large — please reduce the crop.",
|
||||||
|
"branding_favicon_not_square": "The favicon must be square."
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -127,6 +127,14 @@ export const apiEnvSchema = z.object({
|
|||||||
* Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`.
|
* Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`.
|
||||||
*/
|
*/
|
||||||
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
||||||
|
/**
|
||||||
|
* Directory of the operator's branding assets — instance logo and favicon
|
||||||
|
* (issue #306), pond overrides (issue #307). Like the uploaded fonts these
|
||||||
|
* are data, not image content: a sibling of the uploads and plugins
|
||||||
|
* directories, registered in `apps/backup/src/data-dirs.ts` so a restore
|
||||||
|
* puts the operator's identity back with everything else.
|
||||||
|
*/
|
||||||
|
BRANDING_DIR: z.string().min(1).default('./data/branding'),
|
||||||
/**
|
/**
|
||||||
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
||||||
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
|
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
|
||||||
@ -263,6 +271,7 @@ export const backupEnvSchema = z.object({
|
|||||||
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
||||||
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
||||||
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
||||||
|
BRANDING_DIR: z.string().min(1).default('./data/branding'),
|
||||||
/** Daily run time as HH:MM, interpreted in the container's TZ. */
|
/** Daily run time as HH:MM, interpreted in the container's TZ. */
|
||||||
BACKUP_TIME: z
|
BACKUP_TIME: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export * from './auth';
|
|||||||
export * from './backup-set';
|
export * from './backup-set';
|
||||||
export * from './backup-status';
|
export * from './backup-status';
|
||||||
export * from './backup-target-policy';
|
export * from './backup-target-policy';
|
||||||
|
export * from './branding';
|
||||||
export * from './collab-token';
|
export * from './collab-token';
|
||||||
export * from './comments';
|
export * from './comments';
|
||||||
export * from './editor-schema';
|
export * from './editor-schema';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user