Compare commits
2 Commits
1b992c8626
...
5c03da2ccc
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c03da2ccc | |||
| 6377faf332 |
@ -29,19 +29,19 @@ ARG APP_VERSION=0.0.0-dev
|
||||
# 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
|
||||
# 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
|
||||
COPY --from=build --chown=node:node /out /app
|
||||
# Generate the Prisma client for this image's platform.
|
||||
RUN node node_modules/prisma/build/index.js generate
|
||||
# A fresh named volume mounted at /data/uploads, /data/plugins or /data/fonts
|
||||
# is created
|
||||
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
||||
# or /data/branding is created
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
EXPOSE 3000
|
||||
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';
|
||||
import { SiteAdminGuard } from './site-admin.guard';
|
||||
|
||||
// Lifecycle markers, not configuration: never editable through this
|
||||
// endpoint (the setup lock must be irreversible, issue #80).
|
||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
||||
// Lifecycle markers and file-backed metadata, not configuration: never
|
||||
// editable through this endpoint. The setup lock must be irreversible
|
||||
// (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
|
||||
// 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 { AuthModule } from './auth/auth.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { BrandingModule } from './branding/branding.module';
|
||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { maskTokenParam } from './common/mask-token-param';
|
||||
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
||||
@ -82,6 +83,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
PublicModule,
|
||||
PublicApiModule,
|
||||
McpModule,
|
||||
BrandingModule,
|
||||
FontsModule,
|
||||
ImportExportModule,
|
||||
PluginsModule,
|
||||
|
||||
@ -40,11 +40,13 @@ export const AUDIT_EVENTS = {
|
||||
'plugin.mode_set': { severity: 'notice' },
|
||||
'plugin.pond_toggled': { severity: 'info' },
|
||||
'plugin.uninstalled': { severity: 'notice' },
|
||||
'pond.archived': { severity: 'notice' },
|
||||
'pond.purged': { severity: 'notice' },
|
||||
'quota.override_cleared': { severity: 'notice' },
|
||||
'quota.override_set': { severity: 'notice' },
|
||||
'read_trail.pruned': { severity: 'info' },
|
||||
'settings.changed': { severity: 'notice' },
|
||||
'branding.changed': { severity: 'notice' },
|
||||
'font.uploaded': { severity: 'notice' },
|
||||
'font.deleted': { 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 { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
||||
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ConversionJobView,
|
||||
PageExportInput,
|
||||
PondArchivePreview,
|
||||
pageExportInputSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
@ -7,7 +12,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { readActorOf } from '../read-trail/read-actor';
|
||||
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
|
||||
import { ExportService } from './export.service';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/**
|
||||
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
|
||||
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
|
||||
*/
|
||||
@Controller()
|
||||
export class ExportController {
|
||||
constructor(private readonly exports: ExportService) {}
|
||||
constructor(
|
||||
private readonly exports: ExportService,
|
||||
private readonly archives: PondArchiveService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* How much of the pond this requester's archive would contain (issue #305).
|
||||
* Asked before the download so the UI can name the number of omitted pages:
|
||||
* an archive silently missing content is worse than no archive, because it
|
||||
* ends the search.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full archive: every readable page, EVERY attachment, and a versioned
|
||||
* manifest with settings, labels, comments and the hierarchy (issue #305).
|
||||
* Pond-Admin, because it is the deletion flow's last resort — a reader who
|
||||
* wants their own copy has the Markdown export.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
async pondArchive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
|
||||
}
|
||||
|
||||
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
||||
* `reader` role is "may see the pond"; the service filters to readable pages,
|
||||
@ -48,3 +90,34 @@ export class ExportController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Site Admin's archive from the purge dialog (issue #305, #193).
|
||||
*
|
||||
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
|
||||
* Admin purging a trashed pond is usually not a member of it, and the last
|
||||
* archive before an irreversible purge must not depend on that. It is
|
||||
* therefore complete by construction — the read filter is skipped.
|
||||
*/
|
||||
@Controller('admin/trash')
|
||||
@UseGuards(SiteAdminGuard)
|
||||
export class PondArchiveAdminController {
|
||||
constructor(private readonly archives: PondArchiveService) {}
|
||||
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, true);
|
||||
}
|
||||
|
||||
@Get('ponds/:pondId/archive')
|
||||
async archive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import { ConversionWorker } from './conversion-worker.service';
|
||||
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||
import { DataExportController } from './data-export.controller';
|
||||
import { DataExportService } from './data-export.service';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||
import { IMPORT_PROCESSOR } from './import.constants';
|
||||
@ -23,6 +23,7 @@ import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
import { JobsController } from './jobs.controller';
|
||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
||||
* the link's own expiry check already stops downloads the moment it lapses. */
|
||||
@ -48,12 +49,19 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||
SchedulerModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
||||
controllers: [
|
||||
JobsController,
|
||||
ImportController,
|
||||
ExportController,
|
||||
PondArchiveAdminController,
|
||||
DataExportController,
|
||||
],
|
||||
providers: [
|
||||
ConversionJobService,
|
||||
ConversionWorker,
|
||||
ImportService,
|
||||
ExportService,
|
||||
PondArchiveService,
|
||||
DataExportService,
|
||||
// The worker resolves the import pipeline through this token (never the
|
||||
// class), so its file does not import the import service's (avoids a cycle).
|
||||
|
||||
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { PondArchiveManifest } from '@dorfteich/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
function entries(buffer: Buffer): Record<string, Uint8Array> {
|
||||
return unzipSync(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
/** supertest parses text by default — a ZIP has to be collected as bytes. */
|
||||
function asBinary(req: request.Test): request.Test {
|
||||
return req.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
function manifestOf(buffer: Buffer): PondArchiveManifest {
|
||||
const raw = entries(buffer)['manifest.json'];
|
||||
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pond archive (issue #305). What separates it from the Markdown
|
||||
* export is exactly what is asserted here: EVERY attachment travels, not only
|
||||
* the embedded ones, and the manifest carries what Markdown cannot — settings,
|
||||
* labels, comments and the hierarchy.
|
||||
*/
|
||||
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let files: FilesService;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'archiviere den ganzen teich 1';
|
||||
const owner = { username: `arch-${suffix}` };
|
||||
const admin = { username: `archadm-${suffix}` };
|
||||
let ownerId: string;
|
||||
let ownerCookie: string;
|
||||
let adminCookie: string;
|
||||
let pondId: string;
|
||||
let parentPageId: string;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
app = await createTestApp();
|
||||
files = app.get(FilesService);
|
||||
const users = app.get(UsersService);
|
||||
const tokens = app.get(AuthTokensService);
|
||||
|
||||
const ownerUser = await users.createUser({
|
||||
username: owner.username,
|
||||
email: `${owner.username}@example.org`,
|
||||
displayName: `Archive Owner ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
ownerId = ownerUser.id;
|
||||
await api()
|
||||
.post('/api/v1/auth/verify-email')
|
||||
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
|
||||
.expect(204);
|
||||
ownerCookie = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: owner.username, password })
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
const adminUser = await users.createUser({
|
||||
username: admin.username,
|
||||
email: `${admin.username}@example.org`,
|
||||
displayName: `Archive Admin ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await users.markEmailVerified(adminUser.id);
|
||||
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||
adminCookie = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: admin.username, password })
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
|
||||
|
||||
// A parent and a child page, so the hierarchy has something to state.
|
||||
const parent = await prisma.page.create({
|
||||
data: {
|
||||
pondId,
|
||||
title: 'Archive Parent',
|
||||
slug: 'archive-parent',
|
||||
ydocState: new Uint8Array(),
|
||||
sortKey: 'a',
|
||||
createdBy: ownerId,
|
||||
contentCache: {
|
||||
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
parentPageId = parent.id;
|
||||
await prisma.page.create({
|
||||
data: {
|
||||
pondId,
|
||||
parentId: parent.id,
|
||||
title: 'Archive Child',
|
||||
slug: 'archive-child',
|
||||
ydocState: new Uint8Array(),
|
||||
sortKey: 'b',
|
||||
createdBy: ownerId,
|
||||
contentCache: {
|
||||
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const label = await prisma.label.create({
|
||||
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
|
||||
});
|
||||
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
|
||||
await prisma.comment.create({
|
||||
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||
await prisma.comment.deleteMany({ where: { page: where } });
|
||||
await prisma.attachment.deleteMany({ where });
|
||||
await prisma.pageLabel.deleteMany({ where: { page: where } });
|
||||
await prisma.label.deleteMany({ where });
|
||||
await prisma.page.deleteMany({ where });
|
||||
await prisma.roleGrant.deleteMany({ where });
|
||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('contains EVERY attachment, not only the embedded ones', async () => {
|
||||
// The gap this whole issue exists for: an attachment nobody embedded
|
||||
// would vanish unnoticed with the Markdown export.
|
||||
const orphan = await files.upload({ id: ownerId } as never, pondId, {
|
||||
buffer: Buffer.from(PNG_BASE64, 'base64'),
|
||||
size: 70,
|
||||
originalname: 'never-embedded.png',
|
||||
} as never);
|
||||
|
||||
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(res.headers['content-type']).toContain('application/zip');
|
||||
|
||||
const names = Object.keys(entries(res.body as Buffer));
|
||||
expect(names).toContain('manifest.json');
|
||||
expect(names).toContain('README.txt');
|
||||
expect(names).toContain('pages/archive-parent.md');
|
||||
expect(names).toContain('pages/archive-child.md');
|
||||
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
|
||||
|
||||
const manifest = manifestOf(res.body as Buffer);
|
||||
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
|
||||
// The Markdown export would have shipped no media at all here.
|
||||
expect(manifest.attachments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
|
||||
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
const manifest = manifestOf(res.body as Buffer);
|
||||
|
||||
expect(manifest.kind).toBe('dorfteich-pond-archive');
|
||||
expect(manifest.formatVersion).toBe(1);
|
||||
expect(manifest.complete).toBe(true);
|
||||
expect(manifest.omittedPages).toBe(0);
|
||||
|
||||
const child = manifest.pages.find((page) => page.slug === 'archive-child');
|
||||
// The hierarchy is exactly what a folder of Markdown cannot express.
|
||||
expect(child?.parentId).toBe(parentPageId);
|
||||
|
||||
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
|
||||
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
|
||||
// A display name, not an account id — the archive outlives the account.
|
||||
expect(manifest.comments[0]?.author).toContain('Archive Owner');
|
||||
// Pond settings ride along; fonts are always present through the schema
|
||||
// defaults, so their presence proves the settings object is real.
|
||||
expect(manifest.pond.settings).toHaveProperty('fonts');
|
||||
});
|
||||
|
||||
it('tells a requester before the download how much they would get', async () => {
|
||||
const preview = await api()
|
||||
.get(`/api/v1/ponds/${pondId}/archive/preview`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(preview.body.omittedPages).toBe(0);
|
||||
expect(preview.body.includedPages).toBe(preview.body.totalPages);
|
||||
expect(preview.body.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('audits the download with counts and completeness', async () => {
|
||||
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
const entry = await prisma.auditEntry.findFirst({
|
||||
where: { action: 'pond.archived', targetId: pondId },
|
||||
orderBy: { at: 'desc' },
|
||||
});
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
|
||||
});
|
||||
|
||||
it('gives the Site Admin a complete archive without pond membership', async () => {
|
||||
// The purge dialog's archive must not depend on which ponds the operator
|
||||
// happens to be a member of — this admin is a member of none.
|
||||
const preview = await api()
|
||||
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
|
||||
.set('Cookie', adminCookie)
|
||||
.expect(200);
|
||||
expect(preview.body.complete).toBe(true);
|
||||
expect(preview.body.omittedPages).toBe(0);
|
||||
|
||||
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
|
||||
.set('Cookie', adminCookie)
|
||||
.expect(200);
|
||||
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
|
||||
});
|
||||
|
||||
it('keeps the admin archive away from an ordinary pond admin', async () => {
|
||||
await api()
|
||||
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
@ -0,0 +1,395 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
PageClassification,
|
||||
PondArchiveManifest,
|
||||
PondArchivePreview,
|
||||
POND_ARCHIVE_FORMAT_VERSION,
|
||||
classificationMarking,
|
||||
highestClassification,
|
||||
pondSettingsSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
import archiver from 'archiver';
|
||||
import type { Response } from 'express';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { FileStorageService } from '../files/file-storage.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
|
||||
|
||||
import { markClassifiedMarkdown } from './classified-markdown';
|
||||
import { imageExtension, markdownForZip } from './export-markdown';
|
||||
|
||||
/** The plain-text note that travels inside the ZIP. The manifest says the
|
||||
* same thing machine-readably, but a person unpacking a folder of Markdown
|
||||
* a year from now reads the file lying next to it — and must not believe
|
||||
* they are holding a one-click restore. */
|
||||
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
|
||||
|
||||
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
|
||||
no importer for it yet. Everything needed to write one later is here and
|
||||
documented — see manifest.json and docs/architecture/pond-archive-format.md in
|
||||
the Dorfteich repository.
|
||||
|
||||
manifest.json pond settings, labels, page hierarchy, comments, attachment
|
||||
metadata, and the classification of every file
|
||||
pages/ one Markdown file per page
|
||||
media/ EVERY attachment of the pond, not only the embedded ones
|
||||
|
||||
If manifest.json states "complete": false, the archive was produced by someone
|
||||
who could not read every page of the pond; "omittedPages" says how many are
|
||||
missing.
|
||||
`;
|
||||
|
||||
/**
|
||||
* The full pond archive offered before a pond is deleted (issue #305).
|
||||
*
|
||||
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
|
||||
* ships the pages plus the images they embed, which as a LAST resort is not
|
||||
* enough — an attachment nobody embedded would vanish unnoticed. This one adds
|
||||
* every attachment and a machine-readable sidecar of the things Markdown
|
||||
* cannot carry: settings, labels, comments and the page hierarchy.
|
||||
*
|
||||
* Re-import is deliberately out of scope. The archive is a preservation
|
||||
* format: complete, versioned and documented, so an importer can be written
|
||||
* later without guesswork.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PondArchiveService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly readTrail: ReadTrailService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(PondArchiveService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages in the pond and how many of them this requester may read.
|
||||
*
|
||||
* The UI states the difference BEFORE the download: an archive silently
|
||||
* missing content is worse than no archive, because it ends the search.
|
||||
* A site admin archiving from the purge dialog reads everything, so their
|
||||
* preview says nothing is omitted.
|
||||
*/
|
||||
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
|
||||
const pond = await this.loadPond(pondId);
|
||||
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
|
||||
return {
|
||||
totalPages: total,
|
||||
includedPages: pages.length,
|
||||
omittedPages: total - pages.length,
|
||||
// "Complete" is a statement about the RESULT, not about the route: a
|
||||
// pond admin who may read every page gets a complete archive too. Only
|
||||
// an archive that actually leaves pages out is incomplete.
|
||||
complete: total === pages.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pond, or 404 — the caller's permission is checked by the route. */
|
||||
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
|
||||
// Deliberately including trashed ponds: the purge dialog archives a pond
|
||||
// that is already in the trash, which is the last moment it exists.
|
||||
const pond = await this.prisma.pond.findUnique({
|
||||
where: { id: pondId },
|
||||
select: { id: true, slug: true, name: true },
|
||||
});
|
||||
if (!pond) throw new NotFoundException();
|
||||
return pond;
|
||||
}
|
||||
|
||||
private async readablePages(
|
||||
user: User,
|
||||
pondId: string,
|
||||
unfiltered: boolean,
|
||||
): Promise<
|
||||
{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
sortKey: string;
|
||||
classification: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
labels: { labelId: string }[];
|
||||
contentCache: { markdown: string } | null;
|
||||
}[]
|
||||
> {
|
||||
const pages = await this.prisma.page.findMany({
|
||||
where: { pondId, deletedAt: null },
|
||||
orderBy: { title: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
parentId: true,
|
||||
sortKey: true,
|
||||
classification: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
labels: { select: { labelId: true } },
|
||||
contentCache: { select: { markdown: true } },
|
||||
},
|
||||
});
|
||||
if (unfiltered) return pages;
|
||||
const readable = await this.permissions.filterPages(
|
||||
user,
|
||||
pondId,
|
||||
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
||||
'read',
|
||||
);
|
||||
return pages.filter((page) => readable.has(page.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the archive.
|
||||
*
|
||||
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
|
||||
* read filter, because the last archive before an irreversible purge must
|
||||
* not depend on which pages the operator happens to be a member of.
|
||||
* Whether the RESULT is complete is a separate question, answered by
|
||||
* comparing what went in with what exists.
|
||||
*/
|
||||
async stream(
|
||||
user: User,
|
||||
pondId: string,
|
||||
res: Response,
|
||||
read: ReadActor,
|
||||
unfiltered: boolean,
|
||||
): Promise<void> {
|
||||
const pond = await this.loadPond(pondId);
|
||||
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||
const totalPages = await this.prisma.page.count({
|
||||
where: { pondId: pond.id, deletedAt: null },
|
||||
});
|
||||
const complete = totalPages === pages.length;
|
||||
|
||||
// Read trail (ADR 0023, issue #222's property): one `export` event per
|
||||
// classified page BEFORE any classified byte enters the stream, so a
|
||||
// failed write aborts the download with the evidence intact. The added
|
||||
// attachments carry their page's classification and are covered by the
|
||||
// same events — they never travel without their page.
|
||||
for (const page of pages) {
|
||||
if (page.classification !== 'VS_NFD') continue;
|
||||
await this.readTrail.record({
|
||||
...read,
|
||||
pageId: page.id,
|
||||
pondId: pond.id,
|
||||
channel: 'export',
|
||||
details: { format: 'pond_archive' },
|
||||
});
|
||||
}
|
||||
|
||||
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
|
||||
this.prisma.pond.findUniqueOrThrow({
|
||||
where: { id: pond.id },
|
||||
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.label.findMany({
|
||||
where: { pondId: pond.id },
|
||||
select: { id: true, name: true, color: true, parentId: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
this.prisma.comment.findMany({
|
||||
where: { page: { pondId: pond.id, deletedAt: null } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
pageId: true,
|
||||
parentId: true,
|
||||
body: true,
|
||||
createdAt: true,
|
||||
editedAt: true,
|
||||
resolvedAt: true,
|
||||
author: { select: { displayName: true } },
|
||||
},
|
||||
}),
|
||||
// EVERY attachment of the pond (issue #305) — not only the embedded
|
||||
// ones the Markdown export ships.
|
||||
this.prisma.attachment.findMany({
|
||||
where: { pondId: pond.id },
|
||||
select: {
|
||||
id: true,
|
||||
pageId: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
sha256: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const includedPageIds = new Set(pages.map((page) => page.id));
|
||||
// An attachment of a page the requester cannot read stays out — the same
|
||||
// rule the pages follow. Pond-level attachments (no page) are included:
|
||||
// nothing narrower than the pond governs them.
|
||||
const visibleAttachments = attachmentRows.filter(
|
||||
(row) => !row.pageId || includedPageIds.has(row.pageId),
|
||||
);
|
||||
const onDisk = await Promise.all(
|
||||
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
|
||||
);
|
||||
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
|
||||
|
||||
const classificationByPage = new Map(
|
||||
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
|
||||
);
|
||||
const mediaName = new Map(
|
||||
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
|
||||
);
|
||||
const readableSlugs = new Set(pages.map((page) => page.slug));
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
res.set('Content-Type', 'application/zip');
|
||||
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
|
||||
res.set('X-Content-Type-Options', 'nosniff');
|
||||
archive.on('error', (error) => {
|
||||
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
|
||||
res.destroy(error);
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
const files: { path: string; classification: PageClassification }[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
const level = classificationByPage.get(page.id) ?? 'unclassified';
|
||||
const markdown = markClassifiedMarkdown(
|
||||
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
|
||||
level,
|
||||
);
|
||||
const path = `pages/${page.slug}.md`;
|
||||
archive.append(markdown, { name: path });
|
||||
files.push({ path, classification: level });
|
||||
}
|
||||
|
||||
for (const row of attachments) {
|
||||
// An attachment inherits its page's level (fail-closed, ADR 0022); one
|
||||
// that belongs to no page inherits the pond's highest, because nothing
|
||||
// narrower governs it.
|
||||
const level = row.pageId
|
||||
? (classificationByPage.get(row.pageId) ?? 'unclassified')
|
||||
: highestClassification([...classificationByPage.values()]);
|
||||
const path = `media/${mediaName.get(row.id)!}`;
|
||||
files.push({ path, classification: level });
|
||||
// Companion marking (issue #212): binaries cannot carry it themselves,
|
||||
// and the sibling file survives unpacking where a manifest may not.
|
||||
const marking = classificationMarking(level);
|
||||
if (marking) {
|
||||
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
|
||||
files.push({ path: `${path}.classification.txt`, classification: level });
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: PondArchiveManifest = {
|
||||
kind: 'dorfteich-pond-archive',
|
||||
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
complete,
|
||||
omittedPages: totalPages - pages.length,
|
||||
classification: highestClassification(files.map((file) => file.classification)),
|
||||
pond: {
|
||||
name: settingsRow.name,
|
||||
slug: settingsRow.slug,
|
||||
type: settingsRow.type,
|
||||
createdAt: settingsRow.createdAt.toISOString(),
|
||||
// The EFFECTIVE settings, defaults filled in — a preservation format
|
||||
// must not require its reader to know Dorfteich's defaults, and the
|
||||
// stored row only holds what was explicitly set.
|
||||
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
parentId: label.parentId,
|
||||
})),
|
||||
pages: pages.map((page) => ({
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
parentId: page.parentId,
|
||||
sortKey: page.sortKey,
|
||||
classification: page.classification.toLowerCase() as PageClassification,
|
||||
labelIds: page.labels.map((label) => label.labelId),
|
||||
createdAt: page.createdAt.toISOString(),
|
||||
updatedAt: page.updatedAt.toISOString(),
|
||||
file: `pages/${page.slug}.md`,
|
||||
})),
|
||||
// Comments of included pages only — a comment is content of its page.
|
||||
comments: comments
|
||||
.filter((comment) => includedPageIds.has(comment.pageId))
|
||||
.map((comment) => ({
|
||||
id: comment.id,
|
||||
pageId: comment.pageId,
|
||||
parentId: comment.parentId,
|
||||
body: comment.body,
|
||||
// The display name, not the account: the archive is a document, and
|
||||
// it should stay readable after the account is gone.
|
||||
author: comment.author?.displayName ?? null,
|
||||
createdAt: comment.createdAt.toISOString(),
|
||||
editedAt: comment.editedAt?.toISOString() ?? null,
|
||||
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
|
||||
})),
|
||||
attachments: attachments.map((row) => ({
|
||||
id: row.id,
|
||||
pageId: row.pageId,
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
sha256: row.sha256,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
file: `media/${mediaName.get(row.id)!}`,
|
||||
})),
|
||||
files,
|
||||
};
|
||||
|
||||
archive.append(README, { name: 'README.txt' });
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||||
|
||||
for (const row of attachments) {
|
||||
const stream = this.storage.createReadStream(pond.id, row.id);
|
||||
stream.on('error', (error) =>
|
||||
this.logger.warn(
|
||||
{ pondId: pond.id, fileId: row.id, err: error.message },
|
||||
'pond archive: media read failed',
|
||||
),
|
||||
);
|
||||
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
|
||||
}
|
||||
|
||||
// Audited: a whole pond leaving the instance in one file, usually right
|
||||
// before it is deleted, is exactly the event an operator wants to find
|
||||
// later. Recorded before finalize so the trail exists even if the
|
||||
// download is aborted mid-stream.
|
||||
await this.audit.record({
|
||||
action: 'pond.archived',
|
||||
actorId: user.id,
|
||||
targetType: 'pond',
|
||||
targetId: pond.id,
|
||||
details: {
|
||||
pages: pages.length,
|
||||
attachments: attachments.length,
|
||||
omittedPages: totalPages - pages.length,
|
||||
complete,
|
||||
},
|
||||
});
|
||||
|
||||
await archive.finalize();
|
||||
this.logger.info(
|
||||
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
|
||||
'pond archive streamed',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,10 @@
|
||||
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 { PinoLogger } from 'nestjs-pino';
|
||||
import { z } from 'zod';
|
||||
@ -18,6 +23,16 @@ export const INSTANCE_SETTINGS = {
|
||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
||||
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||
'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
|
||||
// in quota_overrides and win over these (QuotaService, issue #22).
|
||||
'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
|
||||
# api image's PLUGINS_DIR — issue #71's lesson).
|
||||
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
|
||||
# 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
|
||||
|
||||
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.
|
||||
*/
|
||||
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[] {
|
||||
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(
|
||||
env: Pick<
|
||||
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,
|
||||
log: RemoteLogger,
|
||||
|
||||
@ -96,6 +96,15 @@ for (const scheme of SCHEMES) {
|
||||
await page.goto('/fonts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, `/fonts (${scheme})`);
|
||||
|
||||
// Teich-Einstellungen im selben Kontext (fixture-user besitzt den
|
||||
// Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der
|
||||
// Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die
|
||||
// CI zwei Packs später am Rate-Limit (Lehre aus #301).
|
||||
await page.goto('/p/content-fixtures/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('.pond-archive__download').waitFor();
|
||||
await expectClean(page, `Teich-Einstellungen (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
@ -110,6 +119,9 @@ for (const scheme of SCHEMES) {
|
||||
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
||||
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
||||
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 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: dark)" content="#10161d" />
|
||||
<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
|
||||
first paint and before the deferred module bundle. External file
|
||||
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 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 deCommon from '@dorfteich/shared/i18n/de/common.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 enAccess from '@dorfteich/shared/i18n/en/access.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 enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||
@ -79,6 +81,7 @@ void i18n
|
||||
editor: enEditor,
|
||||
export: enExport,
|
||||
files: enFiles,
|
||||
branding: enBranding,
|
||||
font: enFont,
|
||||
graph: enGraph,
|
||||
import: enImport,
|
||||
@ -109,6 +112,7 @@ void i18n
|
||||
editor: deEditor,
|
||||
export: deExport,
|
||||
files: deFiles,
|
||||
branding: deBranding,
|
||||
font: deFont,
|
||||
graph: deGraph,
|
||||
import: deImport,
|
||||
|
||||
@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites';
|
||||
import { ImportControl } from '../import/ImportControl';
|
||||
import { LabelChips } from '../labels/LabelChips';
|
||||
import { usePondLabels } from '../labels/use-pond-labels';
|
||||
import { BrandLogo } from '../branding/BrandLogo';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
import { NewPageForm } from './NewPageForm';
|
||||
@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
|
||||
aria-label={t('layout.sidebar.label')}
|
||||
>
|
||||
{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 ? (
|
||||
<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 { useAuth } from '../auth/auth-context';
|
||||
import { useBranding } from '../branding/use-branding';
|
||||
import { IconButton, IconLink } from '../components/IconButton';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { isTypingTarget } from '../lib/keyboard';
|
||||
@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
enabled: Boolean(user && pondSlug),
|
||||
});
|
||||
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> {
|
||||
setMenuOpen(false);
|
||||
@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
>
|
||||
<Menu aria-hidden />
|
||||
</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">
|
||||
Dorfteich
|
||||
{branding?.instanceName ?? 'Dorfteich'}
|
||||
</Link>
|
||||
{user && <PondSwitcher />}
|
||||
{isPondOwner && pondSlug && (
|
||||
|
||||
@ -9,6 +9,7 @@ import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { BrandingManager } from './BrandingManager';
|
||||
import { CustomFontManager } from './CustomFontManager';
|
||||
import { PluginManager } from './PluginManager';
|
||||
import { QuotaManager } from './QuotaManager';
|
||||
@ -181,6 +182,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<LandingSettingsForm settings={settings.data} />
|
||||
<LegalSettingsForm settings={settings.data} />
|
||||
|
||||
<BrandingManager />
|
||||
<CustomFontManager />
|
||||
<PluginManager />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete } from '../lib/api';
|
||||
import { PondArchiveOffer } from './PondArchiveOffer';
|
||||
|
||||
/**
|
||||
* The pond's danger zone: move a shared pond to the site-level trash
|
||||
@ -45,6 +46,7 @@ export function DeletePondSection({
|
||||
<section className="pond-delete">
|
||||
<h2>{t('pond.delete.title')}</h2>
|
||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||
<PondArchiveOffer pondId={pondId} />
|
||||
<form onSubmit={(e) => void remove(e)}>
|
||||
<FormError error={error} />
|
||||
<label>
|
||||
|
||||
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { PondArchivePreview } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
/**
|
||||
* The last full archive, offered INSIDE the deletion flow (issue #305).
|
||||
*
|
||||
* The gap this closes is not a missing prompt — the type-to-confirm field
|
||||
* next to it is stricter than a dialog. It is that the person who deletes the
|
||||
* pond loses access the moment they do: the pond disappears from their view,
|
||||
* only a Site Admin can bring it back, and the export is no longer reachable
|
||||
* for them. So the offer has to be here, before the button, not afterwards.
|
||||
*
|
||||
* Not downloading is allowed. A pond full of test pages should not require a
|
||||
* download, and the server cannot tell whether a file actually arrived — so
|
||||
* the finality is stated in text instead of enforced.
|
||||
*/
|
||||
export function PondArchiveOffer({ pondId }: { pondId: string }): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ['pond', pondId, 'archive-preview'],
|
||||
queryFn: () => apiGet<PondArchivePreview>(`/ponds/${pondId}/archive/preview`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pond-archive">
|
||||
<p>{t('pond.archive.intro')}</p>
|
||||
{/* An archive silently missing content is worse than no archive: it ends
|
||||
the search. So the omission is named BEFORE the download, with its
|
||||
number, not implied afterwards. */}
|
||||
{preview.data && !preview.data.complete && (
|
||||
<p className="pond-archive__warning">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('pond.archive.omitted', { count: preview.data.omittedPages })}
|
||||
</p>
|
||||
)}
|
||||
<p>{t('pond.archive.noImport')}</p>
|
||||
{/*
|
||||
A plain link, not fetch-into-a-blob: the api streams the ZIP, and
|
||||
buffering a whole pond in the tab to show a progress bar would trade
|
||||
memory for cosmetics. The browser's own download UI reports progress
|
||||
and completion; what it cannot say is that the archive is being BUILT,
|
||||
so the live region below says that.
|
||||
*/}
|
||||
<a
|
||||
className="button pond-archive__download"
|
||||
href={`/api/v1/ponds/${pondId}/archive`}
|
||||
onClick={() => setStarted(true)}
|
||||
>
|
||||
{t('pond.archive.download')}
|
||||
</a>
|
||||
<p role="status" className="pond-archive__status">
|
||||
{started ? t('pond.archive.started') : ''}
|
||||
</p>
|
||||
<p className="pond-archive__finality">{t('pond.archive.finality')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4178,3 +4178,132 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
.custom-fonts input[type='file'] {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Full-archive offer inside the pond deletion flow (issue #305). Plain
|
||||
column: the section must reflow at 320px without rules of its own (#301). */
|
||||
.pond-archive {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pond-archive__warning {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.pond-archive__download {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pond-archive__finality {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@ -91,6 +91,9 @@ services:
|
||||
# uploads/plugins so all three travel in one restore set — NOT inside
|
||||
# the image-baked font catalog, which a deploy would overwrite.
|
||||
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
|
||||
# its status.json (readyz freshness #85, admin backup card #86).
|
||||
BACKUPS_DIR: /data/backups
|
||||
@ -105,6 +108,7 @@ services:
|
||||
- uploads:/data/uploads
|
||||
- plugins:/data/plugins
|
||||
- customfonts:/data/fonts
|
||||
- branding:/data/branding
|
||||
- secrets:/data/secrets
|
||||
- backups:/data/backups:ro
|
||||
depends_on:
|
||||
@ -194,6 +198,7 @@ services:
|
||||
- uploads:/data/uploads
|
||||
- plugins:/data/plugins
|
||||
- customfonts:/data/fonts
|
||||
- branding:/data/branding
|
||||
- secrets:/data/secrets:ro
|
||||
- backups:/backups
|
||||
depends_on:
|
||||
@ -280,6 +285,7 @@ volumes:
|
||||
uploads:
|
||||
plugins:
|
||||
customfonts:
|
||||
branding:
|
||||
secrets:
|
||||
backups:
|
||||
# Only used by the optional `caddy` profile (certificates + state).
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# Audit event catalogue
|
||||
|
||||
**Catalogue version 1.6 (2026-08-01; 1.6 adds `font.uploaded` and
|
||||
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`,
|
||||
issue #305; 1.7 added `branding.changed`,
|
||||
issue #306; 1.6 added `font.uploaded` and
|
||||
`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
|
||||
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||
@ -87,17 +89,18 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
||||
|
||||
### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`)
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | -------------------- |
|
||||
| `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.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
||||
| `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — |
|
||||
| `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — |
|
||||
| `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` |
|
||||
| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — |
|
||||
| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` |
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
||||
| `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — |
|
||||
| `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
### Classification (`page.classification_*`, ADR 0022, issue #205)
|
||||
|
||||
@ -111,6 +114,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| ----------------------- | -------------------------------------------------------------------- | -------- | ---------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `file.integrity_failed` | Attachment download hash mismatch — fail-closed (issue #199) | critical | `null` (any downloader; detection) | `attachment` | `pondId`, `expected` (stored sha256), `actual` (computed sha256) |
|
||||
| `pond.archived` | Full pond archive downloaded before deletion or purge (#305) | notice | the pond admin or Site Admin | `pond` | `pages`, `attachments`, `omittedPages`, `complete` |
|
||||
| `pond.purged` | Pond irreversibly destroyed (manual or trash retention, issue #193) | notice | admin, `null` when retention-run | `pond` | `trigger` (`manual` \| `retention`) plus per-object-type deletion counts (e.g. `pages`, `attachments`, … — informational, keys may grow) |
|
||||
| `audit.pruned` | Audit retention deleted rows past the period (issue #196) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
||||
| `read_trail.pruned` | Read-trail retention removed events past its own period (issue #224) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
||||
|
||||
118
docs/architecture/pond-archive-format.md
Normal file
118
docs/architecture/pond-archive-format.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Pond archive format (issue #305)
|
||||
|
||||
**Format version 1.**
|
||||
|
||||
The archive a pond admin downloads before deleting a pond, and the one a Site
|
||||
Admin downloads before purging one. It is a **preservation format, not a
|
||||
backup**: Dorfteich has no importer for it, deliberately. What this document
|
||||
buys is that one can be written later without guesswork.
|
||||
|
||||
Do not confuse it with two neighbours:
|
||||
|
||||
| | contains | purpose |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------- |
|
||||
| Markdown export (`GET /ponds/:id/export/markdown`, #65) | readable pages + the images they embed | everyday "give me my text" |
|
||||
| **Pond archive** (this document) | readable pages + **all** attachments + settings, labels, comments, hierarchy | last resort before deletion |
|
||||
| Restore set (ADR 0015) | the whole instance, database and data directories | operational recovery |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
README.txt plain-text version of this warning, for whoever unpacks it
|
||||
manifest.json everything Markdown cannot carry (see below)
|
||||
pages/<slug>.md one file per page, Markdown, wikilinks rewritten to
|
||||
relative links, media references rewritten to media/…
|
||||
media/<id>.<ext> EVERY attachment of the pond — including ones no page
|
||||
embeds, which is the whole point of this archive
|
||||
media/<id>.<ext>.classification.txt
|
||||
companion marking for a classified attachment (#212): the
|
||||
binary cannot carry it, and this file survives copying
|
||||
```
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": "dorfteich-pond-archive",
|
||||
"formatVersion": 1,
|
||||
"exportedAt": "2026-08-01T18:00:00.000Z",
|
||||
|
||||
// False when the exporter could not read every page. Stated in the archive
|
||||
// itself so a later reader is never misled about what they hold.
|
||||
"complete": true,
|
||||
"omittedPages": 0,
|
||||
|
||||
// Highest classification contained (ADR 0022), stated once.
|
||||
"classification": "unclassified",
|
||||
|
||||
"pond": { "name": "…", "slug": "…", "type": "SHARED", "createdAt": "…", "settings": { … } },
|
||||
"labels": [{ "id": "…", "name": "…", "color": "…", "parentId": null }],
|
||||
"pages": [
|
||||
{
|
||||
"id": "…", "slug": "…", "title": "…",
|
||||
"parentId": null, // the hierarchy Markdown cannot express
|
||||
"sortKey": "…", // sibling order (ADR 0012's fractional key)
|
||||
"classification": "unclassified",
|
||||
"labelIds": ["…"],
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"file": "pages/<slug>.md"
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"id": "…", "pageId": "…", "parentId": null, "body": "…",
|
||||
"author": "Display Name", // NOT the account id — see below
|
||||
"createdAt": "…", "editedAt": null, "resolvedAt": null
|
||||
}
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "…", "pageId": "…" | null, "fileName": "…", "mimeType": "…",
|
||||
"sizeBytes": 1234,
|
||||
"sha256": "…", // #199, so a reader can verify the bytes
|
||||
"createdAt": "…", "file": "media/<id>.<ext>"
|
||||
}
|
||||
],
|
||||
"files": [{ "path": "…", "classification": "unclassified" }]
|
||||
}
|
||||
```
|
||||
|
||||
## Decisions a reader should know about
|
||||
|
||||
- **`formatVersion` is a contract.** A reader that does not recognise the
|
||||
version should refuse rather than guess. Additive fields do not bump it;
|
||||
a change in meaning does.
|
||||
- **Comments name a display name, not an account.** The archive is a document
|
||||
that outlives the instance; an account id would be an unresolvable reference
|
||||
the moment the account is gone.
|
||||
- **`files` is the #210 property, kept.** Every file with its level, so the
|
||||
bulk-egress channel stays machine-checkable after the ZIP is unpacked and
|
||||
copied onward.
|
||||
- **An attachment with no page inherits the pond's highest classification.**
|
||||
Nothing narrower governs it, and fail-closed is the rule (ADR 0022).
|
||||
- **Incomplete archives are labelled, not refused.** A pond admin who cannot
|
||||
read every page still gets what they may read — with `complete: false` and
|
||||
the count of what is missing, in the manifest and in the UI before the
|
||||
download.
|
||||
|
||||
## Read trail
|
||||
|
||||
The archive is a bulk-egress channel. One `export` read event is written per
|
||||
classified page **before any classified byte enters the stream** (ADR 0023), so
|
||||
a failed write aborts the download with the evidence intact. Attachments never
|
||||
travel without their page, so they are covered by the same events.
|
||||
|
||||
The download itself is audited as `pond.archived` (catalogue v1.7) with the
|
||||
page and attachment counts, the number of omitted pages, and whether the
|
||||
archive was complete.
|
||||
|
||||
## What is NOT in it
|
||||
|
||||
- Page history and Yjs update logs. The Markdown is the current state.
|
||||
- Permissions and memberships: they name accounts of _this_ instance, which an
|
||||
archive read elsewhere cannot resolve.
|
||||
- Anything from the trash: trashed pages are not exported.
|
||||
|
||||
An importer will therefore recreate a pond's content, structure and
|
||||
discussion — not its history or its access rules. That is a deliberate scope,
|
||||
not an oversight.
|
||||
@ -139,6 +139,21 @@ or sloppy plugin authors, compromised dependencies.
|
||||
header; the path is validated against the file's own slug prefix, so it
|
||||
cannot reach another family's directory. Uploads and deletions are
|
||||
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);
|
||||
no third-party origins at all — the GDPR posture is "zero external
|
||||
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)."
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,15 @@
|
||||
"confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}",
|
||||
"submit": "Teich löschen",
|
||||
"deleted": "Teich gelöscht."
|
||||
},
|
||||
"archive": {
|
||||
"intro": "Bevor du löschst: Lade hier ein Vollarchiv dieses Teichs herunter. Es enthält alle Seiten als Markdown, ALLE Dateianhänge — auch die, die auf keiner Seite eingebunden sind — sowie Einstellungen, Labels, Kommentare und die Seitenstruktur in einer manifest.json.",
|
||||
"omitted_one": "Achtung: Eine Seite dieses Teichs kannst du nicht lesen und fehlt deshalb im Archiv.",
|
||||
"omitted_other": "Achtung: {{count}} Seiten dieses Teichs kannst du nicht lesen und fehlen deshalb im Archiv.",
|
||||
"noImport": "Das Archiv ist ein Bewahrungsformat: Es lässt sich derzeit nicht wieder einspielen. Das Format ist dokumentiert, damit ein Importer später geschrieben werden kann — verlasse dich also nicht darauf, den Teich damit auf Knopfdruck zurückzuholen.",
|
||||
"download": "Vollarchiv herunterladen",
|
||||
"started": "Das Archiv wird erstellt; der Download startet, sobald es fertig ist.",
|
||||
"finality": "Du musst nicht herunterladen. Ohne Archiv gilt aber: Nach Ablauf der Aufbewahrungsfrist im Papierkorb wird der Teich endgültig entfernt — danach bleibt nichts von ihm übrig, weder Seiten noch Dateien."
|
||||
}
|
||||
},
|
||||
"settingsNav": {
|
||||
|
||||
@ -127,5 +127,13 @@
|
||||
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
|
||||
"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_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)."
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,15 @@
|
||||
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
||||
"submit": "Delete pond",
|
||||
"deleted": "Pond deleted."
|
||||
},
|
||||
"archive": {
|
||||
"intro": "Before you delete: download a full archive of this pond here. It contains every page as Markdown, ALL attachments — including those no page embeds — plus settings, labels, comments and the page structure in a manifest.json.",
|
||||
"omitted_one": "Careful: there is one page in this pond you cannot read, so it is missing from the archive.",
|
||||
"omitted_other": "Careful: there are {{count}} pages in this pond you cannot read, so they are missing from the archive.",
|
||||
"noImport": "The archive is a preservation format: it cannot currently be imported back. The format is documented so an importer can be written later — so do not rely on it to bring the pond back at the push of a button.",
|
||||
"download": "Download the full archive",
|
||||
"started": "The archive is being built; the download starts as soon as it is ready.",
|
||||
"finality": "You do not have to download it. But without an archive: once the trash retention period is over the pond is removed for good — after that nothing of it remains, neither pages nor files."
|
||||
}
|
||||
},
|
||||
"settingsNav": {
|
||||
|
||||
@ -127,5 +127,13 @@
|
||||
"font_weight_exists": "This weight already exists for this font.",
|
||||
"font_weight_invalid": "This weight is not allowed.",
|
||||
"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`.
|
||||
*/
|
||||
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
|
||||
* `<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'),
|
||||
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
||||
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. */
|
||||
BACKUP_TIME: z
|
||||
.string()
|
||||
|
||||
@ -6,6 +6,7 @@ export * from './auth';
|
||||
export * from './backup-set';
|
||||
export * from './backup-status';
|
||||
export * from './backup-target-policy';
|
||||
export * from './branding';
|
||||
export * from './collab-token';
|
||||
export * from './comments';
|
||||
export * from './editor-schema';
|
||||
@ -30,6 +31,7 @@ export * from './search';
|
||||
export * from './secret-store';
|
||||
export * from './setup';
|
||||
export * from './system';
|
||||
export * from './pond-archive';
|
||||
export * from './ponds';
|
||||
export * from './public-api';
|
||||
export * from './quotas';
|
||||
|
||||
102
packages/shared/src/pond-archive.ts
Normal file
102
packages/shared/src/pond-archive.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { PageClassification } from './pages';
|
||||
|
||||
/**
|
||||
* The full pond archive (issue #305): the last download offered before a pond
|
||||
* is trashed, and before a Site Admin purges one for good.
|
||||
*
|
||||
* A PRESERVATION format, not a backup — there is no importer, on purpose.
|
||||
* What that buys is a documented, versioned description of everything the
|
||||
* Markdown alone cannot carry, so an importer can be written later without
|
||||
* guesswork. The format is documented in
|
||||
* `docs/architecture/pond-archive-format.md`.
|
||||
*/
|
||||
|
||||
/** Bumped whenever the manifest's shape changes in a way a reader must know
|
||||
* about. A reader that does not recognise the version should refuse rather
|
||||
* than guess. */
|
||||
export const POND_ARCHIVE_FORMAT_VERSION = 1;
|
||||
|
||||
export interface PondArchiveLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface PondArchivePage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
/** The hierarchy Markdown cannot express — null for a top-level page. */
|
||||
parentId: string | null;
|
||||
/** Sibling order, as stored (ADR 0012's fractional key). */
|
||||
sortKey: string;
|
||||
classification: PageClassification;
|
||||
labelIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Path of the page's Markdown inside the archive. */
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface PondArchiveComment {
|
||||
id: string;
|
||||
pageId: string;
|
||||
parentId: string | null;
|
||||
body: string;
|
||||
/** Display name at export time; the account may be gone by the time anyone
|
||||
* reads this, and the archive should stay readable. */
|
||||
author: string | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PondArchiveAttachment {
|
||||
id: string;
|
||||
/** Null for an attachment that belongs to the pond rather than a page. */
|
||||
pageId: string | null;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
/** SHA-256 of the stored bytes (issue #199), so a reader can verify them. */
|
||||
sha256: string | null;
|
||||
createdAt: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface PondArchiveManifest {
|
||||
kind: 'dorfteich-pond-archive';
|
||||
formatVersion: number;
|
||||
exportedAt: string;
|
||||
/** False when the exporter could not read every page — see `omittedPages`.
|
||||
* Stated in the archive itself so a later reader is never misled about
|
||||
* what they are holding. */
|
||||
complete: boolean;
|
||||
omittedPages: number;
|
||||
/** Highest classification contained (ADR 0022), stated once. */
|
||||
classification: PageClassification;
|
||||
pond: {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
labels: PondArchiveLabel[];
|
||||
pages: PondArchivePage[];
|
||||
comments: PondArchiveComment[];
|
||||
attachments: PondArchiveAttachment[];
|
||||
/** Every file in the archive with its level — the #210 manifest property,
|
||||
* kept so the bulk-egress channel stays machine-checkable after unpacking. */
|
||||
files: { path: string; classification: PageClassification }[];
|
||||
}
|
||||
|
||||
/** What the UI asks for before offering the download, so it can say how much
|
||||
* of the pond the requester would actually get. */
|
||||
export interface PondArchivePreview {
|
||||
totalPages: number;
|
||||
includedPages: number;
|
||||
omittedPages: number;
|
||||
complete: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user