Compare commits

...

3 Commits

Author SHA1 Message Date
90748b743e #307: pond-level branding overrides the instance logo and favicon
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 7m29s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
Built on #306's storage, serving and crop control — a layer, not a parallel
implementation. `resolveBranding` in shared is the ONE place that answers
"which asset applies here?", and both the sidebar logo and the favicon swap
read it.

The decision most likely to be "fixed" by accident, so it is pinned by name
in `branding.test.ts`: **a logo set belongs to one level and variants are
never mixed across levels.** A pond that uploaded only a light logo shows THAT
logo in dark mode; it does not borrow the instance's dark variant. Decided
2026-08-01 — a logo silently swapping to a different image when the viewer
switches theme is a change nobody ordered, and a design that looks wrong is
more honest than one that is quietly substituted. Only a pond with no logo at
all inherits the instance's set, again as a set. The settings screen warns
about a missing dark variant; it never blocks.

Consequences that fall out of that rule and are easy to get wrong:

- The serving route does NOT fall back when given a pond scope. The caller
  already decided which level applies; a "helpful" fallback in the route
  would mix variants across levels behind the resolver's back.
- The logo link's accessible name follows the LEVEL: a pond logo is named by
  the pond, an instance logo by the instance. It is the link home, and a
  link's name has to say where it goes.

- **Charged to the pond's storage quota**, before the write, like attachments.
  Without it branding would be a way around the quota, and replacing a logo
  repeatedly would consume disk with no ceiling. The replaced asset's bytes
  are released FIRST, so re-uploading the same logo costs nothing — and a
  refused upload puts the released reservation back, so a rejection cannot
  leave the pond with more room than it had.
- **Purge removes the branding files.** The purge standard is absolute: after
  it nothing referencing the pond survives, rows or files. Asserted against
  the real purge path, not the new code alone.
- Security unchanged from #306 and not relaxed because the uploader is now an
  ordinary Pond Admin: SVG refused, magic bytes and IHDR checked server-side,
  size caps, content type pinned, no image parsing.
- The favicon swap is driven by the RESOLVED pond, never the raw route
  parameter — an unreadable or unknown slug must not leave a stale icon in
  the tab. That it happens after first paint is accepted and stated in the
  code and the UI: avoiding it would mean server-rendering index.html, which
  is #179's territory.

Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue
already carries the field, so no version bump.

Verified: api suite 105 files / 592 tests green; 5 pond-branding e2e tests
(pond scope serves the pond's bytes while the instance level still 404s, the
quota is charged and released exactly, SVG refused at pond level, a reader may
read but not change, purge deletes the files); 9 shared unit tests on the
resolution order including both mixing directions.
2026-08-01 20:24:56 +02:00
3310ae3926 #305: a full pond archive before deletion and before purge
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m19s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m28s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m6s
CI / Import/export fidelity gate (push) Successful in 58s
Deleting a pond already had a strict prompt — typing the pond name, stricter
than a confirm dialog. That was never the gap. The gap is that the person who
deletes it loses access the moment they do: the pond leaves their view, only a
Site Admin can bring it back, and the export is no longer reachable for them.
So the archive is offered INSIDE the deletion flow, before the button.

What it contains, and why it is not the existing export:

- Every page the requester may read, as Markdown, as before.
- **Every attachment of the pond**, not only the embedded ones. An
  attachment nobody put on a page would otherwise vanish unnoticed — which
  is the whole reason this issue exists.
- `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a
  preservation format must not require its reader to know Dorfteich's
  defaults), labels, the page hierarchy and sort keys, comments, and
  attachment metadata including the #199 hash so a reader can verify bytes.
  It extends the #210 manifest rather than adding a second descriptor, and
  carries an explicit `formatVersion`.
- `README.txt`, because the manifest is for machines: whoever unpacks a
  folder of Markdown a year from now must not believe they hold a one-click
  restore.

Decisions worth naming:

- **"Complete" describes the RESULT, not the route.** A pond admin who may
  read every page gets `complete: true`; only an archive that actually
  leaves pages out is incomplete. The Site-Admin route skips the read filter
  (an archive taken before an irreversible purge must not depend on which
  ponds the operator happens to be a member of) — those are two different
  questions and the first version of this conflated them.
- **The omission is named before the download**, with its number, in the UI
  and in the manifest. An archive silently missing content is worse than no
  archive, because it ends the search.
- **Not downloading stays allowed.** A pond of test pages should not require
  one, and the server cannot tell whether a file arrived anyway — so the
  finality is stated in text instead of enforced.
- **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering
  a whole pond in the tab to draw a progress bar would trade memory for
  cosmetics. The browser reports progress and completion; what it cannot say
  — that the archive is being BUILT — is announced in a live region.
- Read trail unchanged in kind (ADR 0023): one `export` event per classified
  page before any classified byte enters the stream. Attachments never travel
  without their page, so the same events cover them.
- New audit action `pond.archived` (catalogue v1.7) with page and attachment
  counts, omitted pages, and completeness.

Format documented in `docs/architecture/pond-archive-format.md`, including
what is deliberately NOT in it (history, permissions, trash).

Verified by hand, not only asserted: a real pond's archive downloaded and
unpacked — README, manifest, three page files, the media file; the manifest's
effective settings, per-page classification, the VS-NfD frontmatter and
marking preserved in the classified page's Markdown, and the attachment's
sha256 present. Plus six api tests (including that an unembedded attachment
travels and that a Site Admin gets a complete archive without membership) and
the a11y pack 11/11 in both schemes, which now also scans the pond settings
screen.

Not done, because there is nothing to attach it to: the Site Admin's purge
dialog (#193) exists only as an api endpoint — there is no pond-trash UI in
the web app. The api half is here and tested, so it becomes a link when that
screen is built.
2026-08-01 20:24:35 +02:00
8752cf0c5a #179: negotiate the SPA shell's lang attribute in nginx
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m13s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m40s
CD / Promote to Int (push) Successful in 15s
CI / Lint, typecheck, test (push) Successful in 7m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m45s
CI / Import/export fidelity gate (push) Successful in 55s
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at
runtime (#163), but nginx answers every SPA route with that same file, so a
crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en`
for German content, permanently. WCAG 3.1.1 is about the delivered document,
not the one JavaScript later fixes.

nginx-only, no backend involved: a `map` on `Accept-Language` and a
`sub_filter` in the index.html path. Only the FIRST tag decides, which is
what "the browser's preferred language" means and mirrors #163 — `de-CH`
counts as German, `en-US,de` does not.

`Vary: Accept-Language` is new. The response now genuinely depends on a
request header, and without it a shared cache could hand one language's copy
to the other. Everything else in the location is untouched: same CSP, same
`Cache-Control: no-cache`, same `nosniff`.

The known limit is documented in the config rather than worked around: nginx
cannot read `instance.defaultLocale`, so an unlisted or absent
Accept-Language yields `en` even on a German instance. For public content
that is not the authoritative rendering anyway — the api's server shell
(`/api/v1/public/...`) already renders those with the instance locale.

Verified against a real nginx 1.27 (the image the stage runs) with the
config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`;
`en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no
header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the
same way; the gzipped response is rewritten too, and a JavaScript asset comes
through byte-identical.
2026-08-01 20:01:53 +02:00
32 changed files with 2230 additions and 45 deletions

View File

@ -40,6 +40,7 @@ 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' },

View File

@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
@ -19,11 +21,14 @@ import {
LOGO_VARIANTS,
LogoVariant,
MAX_BRANDING_BYTES,
PondBranding,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { RequiresPondRole } from '../permissions/permission.decorators';
import { PrismaService } from '../prisma/prisma.service';
import { BrandingService } from './branding.service';
function parseVariant(value: unknown): LogoVariant {
@ -52,8 +57,19 @@ export class BrandingController {
@Public()
@Get('logo')
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
async logo(
@Query('variant') variant: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = parseVariant(variant ?? 'light');
// A pond scope serves the pond's own bytes and nothing else: the caller
// already resolved WHICH level applies (`resolveBranding`), so silently
// falling back here would mix variants across levels — exactly what #307
// forbids.
const bytes = pondId
? await this.branding.pondLogoBytes(pondId, wanted)
: await this.branding.logoBytes(wanted);
// 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) {
@ -69,12 +85,21 @@ export class BrandingController {
@Public()
@Get('favicon')
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> {
async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: 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);
const pondBytes = pondId
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
: null;
const { bytes, uploaded } = pondBytes
? { bytes: pondBytes, uploaded: true }
: 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
@ -133,3 +158,96 @@ export class BrandingAdminController {
return this.branding.clearFavicon(request.user!);
}
}
/**
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
* Admin rather than the operator, so the security rules of #306 are not
* relaxed by a single line: SVG refused, magic bytes checked server-side,
* size caps enforced, content type pinned on serving, no image parsing.
*
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
* guard, one who can see but not administer it gets 403.
*/
@Controller('ponds/:pondId/branding')
export class PondBrandingController {
constructor(
private readonly branding: BrandingService,
private readonly prisma: PrismaService,
) {}
/** The pond row the quota is charged to. */
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, ownerId: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
@Get()
@RequiresPondRole('reader', { idParam: 'pondId' })
view(@Param('pondId') pondId: string): Promise<PondBranding> {
return this.branding.pondBranding(pondId);
}
@Post('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
file.buffer,
);
}
@Delete('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
);
}
@Post('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
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.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
}
@Delete('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
}
}

View File

@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { BrandingAdminController, BrandingController } from './branding.controller';
import { PermissionsModule } from '../permissions/permissions.module';
import { QuotasModule } from '../quotas/quotas.module';
import {
BrandingAdminController,
BrandingController,
PondBrandingController,
} from './branding.controller';
import { BrandingStorageService } from './branding-storage.service';
import { BrandingService } from './branding.service';
@ -8,7 +15,8 @@ import { BrandingService } from './branding.service';
* 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],
imports: [PermissionsModule, QuotasModule],
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
providers: [BrandingService, BrandingStorageService],
exports: [BrandingService, BrandingStorageService],
})

View File

@ -2,12 +2,16 @@ import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BrandingAsset,
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
PondBranding,
pondSettingsSchema,
MAX_BRANDING_BYTES,
MAX_LOGO_EDGE,
hasPngMagic,
@ -17,6 +21,8 @@ import {
import { User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { BrandingStorageService } from './branding-storage.service';
@ -41,6 +47,8 @@ export class BrandingService {
private readonly settings: InstanceSettingsService,
private readonly storage: BrandingStorageService,
private readonly audit: AuditService,
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
) {}
static logoKey(variant: LogoVariant): string {
@ -51,6 +59,26 @@ export class BrandingService {
return `instance-favicon-${size}`;
}
/** Pond assets share the directory and the naming rules (issue #307); the
* pond id keeps them apart and makes purge a prefix delete. */
static pondLogoKey(pondId: string, variant: LogoVariant): string {
return `pond-${pondId}-logo-${variant}`;
}
static pondFaviconKey(pondId: string, size: FaviconSize): string {
return `pond-${pondId}-favicon-${size}`;
}
/** Every branding file a pond can own the purge deletes exactly this set
* (issue #307). The purge standard is absolute: after it, nothing
* referencing the pond survives, rows or files. */
static pondKeys(pondId: string): string[] {
return [
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, 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
@ -71,11 +99,37 @@ export class BrandingService {
return size;
}
/**
* Reserve the pond's storage for a branding asset, releasing what the asset
* it replaces occupied. Doing it in that order means replacing a logo with
* one of the same size costs nothing otherwise every re-upload would eat
* the quota again, which is how "a pond admin fills the disk with logos"
* happens.
*/
private async chargeQuota(
pond: { id: string; ownerId: string },
bytes: number,
previous: BrandingAsset | null,
): Promise<void> {
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
try {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
} catch (error) {
// Put the released reservation back: a refused upload must not leave
// the pond with MORE room than before.
if (previous?.byteSize) {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
}
throw error;
}
}
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),
byteSize: bytes.length,
...size,
};
}
@ -164,6 +218,150 @@ export class BrandingService {
return { bytes, uploaded: false };
}
/** The pond's own branding, defaulted — one place reads the settings blob. */
async pondBranding(pondId: string): Promise<PondBranding> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { settings: true },
});
if (!pond) throw new NotFoundException();
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
}
private async writePondBranding(
actor: User,
pondId: string,
next: PondBranding,
asset: 'logo' | 'logoDark' | 'favicon',
change: 'set' | 'cleared',
): Promise<PondBranding> {
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
const settings = pondSettingsSchema.parse(pond.settings ?? {});
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...settings, branding: next } as object },
});
await this.audit.record({
action: 'branding.changed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { scope: 'pond', pondId, asset, change },
});
return next;
}
/**
* A pond logo, charged to the pond's storage quota (issue #307).
*
* Without the charge, branding would be a way around the quota and
* replacing a logo repeatedly would let a pond admin consume disk with no
* ceiling. Charged BEFORE the write, like attachments, so a race never
* leaves bytes on the volume without a reservation; the bytes a replaced
* asset frees are released first, so re-uploading the same logo is free
* rather than cumulative.
*/
async setPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
bytes: Buffer,
): Promise<PondBranding> {
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.chargeQuota(pond, bytes.length, previous);
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
const asset = this.assetOf(bytes, size);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
variant === 'dark' ? 'logoDark' : 'logo',
'set',
);
}
async clearPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
variant === 'dark' ? 'logoDark' : 'logo',
'cleared',
);
}
async setPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
files: Record<FaviconSize, Buffer>,
): Promise<PondBranding> {
const checked = 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 };
});
const current = await this.pondBranding(pond.id);
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
await this.chargeQuota(pond, total, current.favicon);
for (const entry of checked) {
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
}
const small = checked.find((entry) => entry.expected === 32)!;
// The pair is charged together, so the stored size is the pair's — that
// is what a later release has to give back.
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
}
async clearPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
for (const size of FAVICON_SIZES) {
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
}
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
return this.writePondBranding(
actor,
pond.id,
{ ...current, favicon: null },
'favicon',
'cleared',
);
}
/** Bytes for a pond asset null when the pond has none at that slot, which
* is what makes the caller fall back to the instance level. */
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
}
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
}
/** Removes every branding file of a pond (issue #307's purge obligation). */
async removePondAssets(pondId: string): Promise<void> {
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
}
private record(
admin: User,
asset: 'logo' | 'logoDark' | 'favicon',

View File

@ -0,0 +1,256 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import {
createTestPrisma,
deletePondsWhere,
grantOwnerAdmin,
hasTestDb,
uniqueSuffix,
} from '../testing/test-db';
import { TrashService } from '../trash/trash.service';
import { UsersService } from '../users/users.service';
import { BrandingService } from './branding.service';
import { BrandingStorageService } from './branding-storage.service';
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: Buffer): number {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function 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]);
}
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
function png(size: number): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
chunk('IEND', Buffer.alloc(0)),
]);
}
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: BrandingStorageService;
let brandingDir: string;
const suffix = uniqueSuffix();
const password = 'teichmarke mit eigenem logo 1';
const owner = { username: `pb-${suffix}` };
const member = { username: `pbm-${suffix}` };
let ownerCookie: string;
let memberCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
process.env.BRANDING_DIR = brandingDir;
app = await createTestApp();
storage = app.get(BrandingStorageService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
// Verification through the endpoint, not `markEmailVerified`: only this
// path creates the personal pond these tests brand.
const verify = async (userId: string): Promise<void> => {
await api()
.post('/api/v1/auth/verify-email')
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
.expect(204);
};
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: `Pond Branding Owner ${suffix}`,
password,
locale: 'en',
});
await verify(ownerUser.id);
const memberUser = await users.createUser({
username: member.username,
email: `${member.username}@example.org`,
displayName: `Pond Branding Member ${suffix}`,
password,
locale: 'en',
});
await verify(memberUser.id);
const login = async (username: string): Promise<string> =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
ownerCookie = await login(owner.username);
memberCookie = await login(member.username);
pondId = (
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
).id;
// A reader on the same pond: may see it, may not administer it. Through
// the API, not a raw row — the permission cache would not see the row
// (the documented rule for grants in tests).
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', ownerCookie)
.send({
subjectType: 'user',
subjectId: memberUser.id,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
})
.expect(201);
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
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('stores a pond logo, reports it, and serves it under the pond scope', async () => {
const view = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(64), 'logo.png')
.expect(201);
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
const served = await api()
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
.expect(200);
expect(served.headers['content-type']).toContain('image/png');
// Without the pond scope the instance level answers — 404 here, since no
// instance logo is set. The two levels never leak into each other.
await api().get('/api/v1/branding/logo?variant=light').expect(404);
});
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
const usageOf = async (): Promise<number> =>
Number(
(
await prisma.pondUsage.findUnique({
where: { pondId },
select: { storageBytesUsed: true },
})
)?.storageBytesUsed ?? 0,
);
const before = await usageOf();
const big = png(120);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
const afterUpload = await usageOf();
expect(afterUpload).toBe(before + big.length);
// Replacing releases the old reservation first — otherwise re-uploading
// the same logo would eat the quota again and again.
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
expect(await usageOf()).toBe(afterUpload);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.expect(200);
expect(await usageOf()).toBe(before);
});
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
const res = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_svg_rejected');
});
it('lets a member read the pond branding but not change it', async () => {
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', memberCookie)
.attach('file', png(32), 'x.png')
.expect(403);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
.set('Cookie', memberCookie)
.expect(403);
});
it('purging the pond removes its branding files', async () => {
// A pond of its own, so the purge does not take the shared fixture with it.
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
const created = await prisma.pond.create({
data: {
name: `Purge Branding ${suffix}`,
slug: `purge-branding-${suffix}`,
type: 'SHARED',
ownerId: ownerRow.id,
},
});
// Raw grant row, before this pond's first permission query — the
// documented exception to "grants through the API".
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
await api()
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(48), 'logo.png')
.expect(201);
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
const trash = app.get(TrashService);
await trash.purgePondNow(ownerRow, created.id);
// The purge standard is absolute: after it nothing referencing the pond
// survives — rows OR files.
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
});
});

View File

@ -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);
}
}

View File

@ -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).

View 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);
});
});

View 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',
);
}
}

View File

@ -1,5 +1,6 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { BrandingModule } from '../branding/branding.module';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module';
import { PagesModule } from '../pages/pages.module';
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
@Module({
imports: [
BrandingModule,
CommonModule,
PondsModule,
QuotasModule,

View File

@ -4,6 +4,7 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { BrandingService } from '../branding/branding.service';
import { ClockService } from '../common/clock.service';
import { SearchProvider } from '../search/search.provider';
import { PagesService } from '../pages/pages.service';
@ -32,6 +33,7 @@ export class TrashService {
private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService,
private readonly storage: FileStorageService,
private readonly branding: BrandingService,
private readonly clock: ClockService,
private readonly watches: WatchesService,
private readonly audit: AuditService,
@ -183,6 +185,9 @@ export class TrashService {
for (const attachment of attachments) {
await this.storage.delete(pondId, attachment.id);
}
// The pond's branding files (issue #307). The purge standard is absolute:
// after it, nothing referencing the pond survives — rows OR files.
await this.branding.removePondAssets(pondId);
const pageIds = (
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
).map((page) => page.id);

View File

@ -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();
});

View File

@ -1,3 +1,23 @@
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
# SPA route which nginx answers with index.html would see `en` forever,
# even for German content.
#
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
# German; `en-US,de` does not, because that visitor asked for English first.
#
# Known limit, documented rather than worked around: nginx does not know
# `instance.defaultLocale` from the database, so a visitor with no (or an
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
# content that is not the authoritative rendering anyway the api's server
# shell (`/api/v1/public/...`) renders those with the instance locale.
map $http_accept_language $spa_lang {
default en;
~*^de de;
}
# SPA serving: static assets with long-lived caching, everything else
# falls back to index.html (client-side routing).
server {
@ -34,6 +54,17 @@ server {
# zero-third-party-request guarantee (security.md) is unaffected.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
# The shell's language (issue #179). Only the html document is
# rewritten, and only its first match `<html lang="en">` is the
# first and only occurrence in index.html. Everything else this
# location serves passes through untouched.
sub_filter_types text/html;
sub_filter_once on;
sub_filter 'lang="en"' 'lang="$spa_lang"';
# The response now depends on a request header, so shared caches must
# not serve one language's copy to the other. This location is
# `no-cache` anyway; the header states the dependency correctly.
add_header Vary "Accept-Language" always;
try_files $uri /index.html;
}
}

View File

@ -1,50 +1,57 @@
import { Link } from 'react-router-dom';
import { logoUrl, useBranding } from './use-branding';
import { useCurrentPondRoute } from '../layout/use-pond-route';
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
import { usePondId } from './use-pond-id';
/**
* 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.
* The identity at the top of the sidebar (issues #306/#307): the pond's own
* logo when it has one, else the instance's, else the instance name as text.
*
* 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.
* Its accessible name follows the LEVEL the logo came from a pond logo is
* named by the pond, an instance logo by the instance. For a screen reader
* this is the link home, and a link's name has to say where it goes; keeping
* the instance name on a pond logo would announce the wrong destination.
*
* 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).
* the correct logo is the one painted. A logo set belongs to ONE level and is
* never mixed across levels see `resolveBranding`.
*/
export function BrandLogo(): React.JSX.Element | null {
const branding = useBranding();
if (!branding) return null;
const { logo, logoDark, instanceName } = branding;
const { pondSlug } = useCurrentPondRoute();
const { pondId, pondName } = usePondId(pondSlug);
const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId);
usePondFavicon(pondId, resolved.faviconLevel === 'pond');
const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName;
if (!instanceName && resolved.logoLevel === 'none') return null;
return (
<Link to="/" className="brand-logo" aria-label={instanceName}>
{logo ? (
<Link to="/" className="brand-logo" aria-label={name}>
{resolved.logo || resolved.logoDark ? (
<>
<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 && (
{resolved.logo && (
<img
className="brand-logo__img brand-logo__img--dark"
src={logoUrl('dark', logoDark.hash)}
width={logoDark.width}
height={logoDark.height}
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', resolved.logo.hash, logoPond)}
width={resolved.logo.width}
height={resolved.logo.height}
alt=""
/>
)}
{resolved.logoDark && (
<img
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
width={resolved.logoDark.width}
height={resolved.logoDark.height}
alt=""
/>
)}
</>
) : (
<span className="brand-logo__name">{instanceName}</span>
<span className="brand-logo__name">{name}</span>
)}
</Link>
);

View File

@ -1,5 +1,6 @@
import { BrandingView } from '@dorfteich/shared';
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiGet } from '../lib/api';
@ -21,9 +22,65 @@ export function useBranding(): BrandingView | undefined {
}
/** 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}`;
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
* the route never falls back on its own the CALLER decided which level
* applies, and a silent fallback here would mix variants across levels. */
export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string {
const pond = pondId ? `&pond=${pondId}` : '';
return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`;
}
/**
* The branding in force here: the pond's own, else the instance's, else the
* default (issue #307). One helper, in shared, so api and web cannot drift.
*/
export function useResolvedBranding(pondId?: string): {
resolved: ResolvedBranding;
instanceName: string;
/** Which level the logo came from the asset URLs need the pond scope
* exactly when the pond supplied it. */
pondId?: string;
} {
const instance = useBranding();
const pond = useQuery({
queryKey: ['pond', pondId, 'branding'],
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId!}/branding`),
enabled: Boolean(pondId),
staleTime: 5 * 60 * 1000,
});
const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' };
const resolved = resolveBranding(base, pond.data ?? null);
return {
resolved,
instanceName: base.instanceName,
pondId: resolved.logoLevel === 'pond' ? pondId : undefined,
};
}
/**
* Points the document's icon at the pond's favicon while a pond is open, and
* back at the instance's on leaving (issue #307).
*
* Accepted and worth stating: the swap necessarily happens AFTER first paint,
* so opening a pond link directly shows the instance favicon briefly before it
* changes. Avoiding that would mean server-rendering index.html, which is
* #179's territory and deliberately out of scope here. In a pinned tab where
* telling ponds apart matters most the tab is already open, so the swap is
* the normal case rather than the exception.
*
* Driven by the RESOLVED pond, never by the raw route parameter: an unreadable
* or non-existent pond slug must not leave a stale icon in the tab.
*/
export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void {
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return undefined;
const instanceHref = '/api/v1/branding/favicon';
link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref;
return () => {
link.href = instanceHref;
};
}, [pondId, hasPondFavicon]);
}
export function useInvalidateBranding(): () => Promise<void> {

View File

@ -0,0 +1,21 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../lib/api';
/**
* The current pond's id and name from its slug (issue #307).
*
* Shares the sidebar's query key, so the pond is fetched once. Returns
* nothing for an unreadable or unknown slug which is exactly why the
* favicon swap is driven by this and not by the raw route parameter: a bad
* slug must not leave a stale icon in the tab.
*/
export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } {
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug!}`),
enabled: Boolean(pondSlug),
});
return { pondId: pond.data?.id, pondName: pond.data?.name };
}

View File

@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondBrandingSection } from '../ponds/PondBrandingSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings';
import { PondThemeSection } from '../theme/PondThemeSection';
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tMembers } = useTranslation('members');
const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files');
const { t: tBranding } = useTranslation('branding');
const { t: tExport } = useTranslation('export');
const { t: tComments } = useTranslation('comments');
const { t: tApiTokens } = useTranslation('apiTokens');
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
pondSlug={pondSlug}
theme={pond.data.settings.theme}
/>
<h3>{tBranding('admin.title')}</h3>
<PondBrandingSection pondId={pond.data.id} />
</section>
)}
{canModify && <PondPluginSettings pondId={pond.data.id} />}

View File

@ -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>

View 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>
);
}

View File

@ -0,0 +1,234 @@
import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } 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 } from '../branding/use-branding';
import { FormError, FormSuccess } from '../components/forms';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
const FAVICON_SIZES = [32, 180] as const;
/**
* A pond's own logo and favicon (issue #307).
*
* Reuses the instance screen's crop control rather than growing a second,
* drag-only one: the uploader here is an ordinary Pond Admin, and the
* keyboard operability is not theirs to lose.
*/
export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element {
const { t } = useTranslation('branding');
const queryClient = useQueryClient();
const branding = useQuery({
queryKey: ['pond', pondId, 'branding'],
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId}/branding`),
});
const view = branding.data;
const refresh = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
};
return (
<div className="branding pond-branding">
<p>{t('pond.intro')}</p>
<p>{t('pond.quotaNote')}</p>
<PondLogoSlot
variant="light"
pondId={pondId}
asset={view?.logo ?? null}
onChanged={refresh}
/>
<PondLogoSlot
variant="dark"
pondId={pondId}
asset={view?.logoDark ?? null}
onChanged={refresh}
/>
{view?.logo && !view.logoDark && (
// Advisory, exactly as on the instance screen: a pond that uploads
// only a light logo shows THAT logo in dark mode — it does not borrow
// the instance's dark one. Nothing is blocked.
<p className="branding__warning" role="note">
<span aria-hidden="true"> </span>
{t('pond.darkMissing')}
</p>
)}
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
</div>
);
}
function PondLogoSlot({
pondId,
variant,
asset,
onChanged,
}: {
pondId: string;
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<PondBranding>(`/ponds/${pondId}/branding/logo?variant=${variant}`, form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const reset = useMutation({
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-pond-logo-variant={variant}>
<h3>{t(`admin.logo.${variant}`)}</h3>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.logo.saved') : null} />
{asset ? (
<div className="branding__current">
<img
src={logoUrl(variant, asset.hash, pondId)}
alt={t('admin.logo.currentAlt')}
className={`branding__preview branding__preview--${variant}`}
/>
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
{/* A real, labelled control not an empty file field standing in
for "remove". */}
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
</div>
) : (
<p>{t('pond.inherited')}</p>
)}
<CropField
idPrefix={`pond-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 PondFaviconSlot({
pondId,
present,
onChanged,
}: {
pondId: string;
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();
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<PondBranding>(`/ponds/${pondId}/branding/favicon`, form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const reset = useMutation({
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-branding-slot="pond-favicon">
<h3>{t('admin.favicon.title')}</h3>
<p>{t('pond.faviconHint')}</p>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
{present && (
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
)}
<CropField
idPrefix="pond-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>
);
}

View File

@ -4281,3 +4281,29 @@ ul[data-type='task_list'] li p:last-of-type {
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;
}

View File

@ -1,6 +1,7 @@
# Audit event catalogue
**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`,
**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
@ -113,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` |

View 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.

View File

@ -37,5 +37,13 @@
"size": "Kantenlänge des Ausschnitts (px)",
"reset": "Ausschnitt zurücksetzen",
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
},
"pond": {
"intro": "Dieser Teich kann ein eigenes Logo und ein eigenes Favicon führen. Beides überschreibt das der Instanz — nur für diesen Teich.",
"quotaNote": "Die Dateien zählen auf das Speicher-Kontingent dieses Teichs, wie Anhänge auch.",
"inherited": "Nichts hinterlegt — es gilt die Einstellung der Instanz.",
"reset": "Auf die Instanz-Einstellung zurücksetzen",
"darkMissing": "Für den Dunkelmodus ist kein eigenes Teich-Logo hinterlegt. Dann wird dort das helle Logo dieses Teichs verwendet — NICHT das dunkle Logo der Instanz. Ein Logo-Satz gehört zu einer Ebene und wird nie ebenenübergreifend gemischt. Das ist ein Hinweis, keine Sperre.",
"faviconHint": "Das Favicon wird beim Betreten des Teichs im Tab gesetzt und beim Verlassen wieder zurückgestellt. Beim direkten Öffnen eines Teich-Links erscheint kurz das Instanz-Favicon, bevor es wechselt."
}
}

View File

@ -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": {

View File

@ -37,5 +37,13 @@
"size": "Crop edge length (px)",
"reset": "Reset the crop",
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
},
"pond": {
"intro": "This pond can carry its own logo and favicon. Both override the instance's — for this pond only.",
"quotaNote": "The files count against this pond's storage quota, just like attachments.",
"inherited": "Nothing set — the instance's setting applies.",
"reset": "Reset to the instance setting",
"darkMissing": "No separate dark-mode logo is set for this pond. The pond's light logo is then used there — NOT the instance's dark one. A logo set belongs to one level and is never mixed across levels. This is advice, not a block.",
"faviconHint": "The favicon is applied to the tab when the pond is entered and restored on leaving. Opening a pond link directly shows the instance favicon briefly before it changes."
}
}

View File

@ -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": {

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { pngDimensions, hasPngMagic, looksLikeSvg, resolveBranding } from './branding';
const asset = (hash: string) => ({ hash, width: 10, height: 10 });
const NONE = { logo: null, logoDark: null, favicon: null };
/**
* The resolution order (issues #306/#307) lives in one function, so this is
* where the decision that most easily gets "fixed" by accident is pinned:
* a logo set belongs to ONE level and variants are never mixed across levels.
*/
describe('resolveBranding', () => {
it('prefers the pond over the instance', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
);
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
expect(resolved.logoLevel).toBe('pond');
});
it('keeps a pond on ITS OWN light logo in dark mode, never the instance dark one', () => {
// Decided 2026-08-01: a logo silently swapping to a different image when
// the viewer switches theme is a change nobody ordered. A design that
// looks wrong is more honest than one that is quietly substituted.
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
);
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
expect(resolved.logoDark).toBeNull();
});
it('inherits BOTH instance variants when the pond has no logo at all', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
NONE,
);
expect(resolved.logo?.hash).toBe('aaaaaaaaaaaaaaaa');
expect(resolved.logoDark?.hash).toBe('cccccccccccccccc');
expect(resolved.logoLevel).toBe('instance');
});
it('treats a pond with only a DARK logo as a set of its own too', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
{ ...NONE, logoDark: asset('dddddddddddddddd') },
);
expect(resolved.logo).toBeNull();
expect(resolved.logoDark?.hash).toBe('dddddddddddddddd');
});
it('falls through to nothing, which is the render-the-name case', () => {
expect(resolveBranding(NONE, NONE).logoLevel).toBe('none');
expect(resolveBranding(NONE, null).logo).toBeNull();
});
it('resolves the favicon per level, down to the shipped default', () => {
expect(resolveBranding(NONE, { ...NONE, favicon: asset('e'.repeat(16)) }).faviconLevel).toBe(
'pond',
);
expect(resolveBranding({ ...NONE, favicon: asset('f'.repeat(16)) }, NONE).faviconLevel).toBe(
'instance',
);
expect(resolveBranding(NONE, NONE).faviconLevel).toBe('default');
});
});
describe('image checks (no decoding)', () => {
const png = (width: number, height: number): Uint8Array => {
const buf = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
buf.write('IHDR', 12, 'latin1');
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
};
it('reads the dimensions out of the IHDR', () => {
expect(pngDimensions(png(512, 128))).toEqual({ width: 512, height: 128 });
});
it('refuses anything that is not a PNG with an IHDR first', () => {
expect(hasPngMagic(Buffer.from('GIF89a'))).toBe(false);
expect(pngDimensions(Buffer.from('GIF89a'))).toBeNull();
// Truncated: a reader that trusted the signature alone would run off the
// end here.
expect(pngDimensions(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
});
it('recognises SVG so the rejection can say why', () => {
expect(looksLikeSvg(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe(true);
expect(looksLikeSvg(Buffer.from('<?xml version="1.0"?>\n<svg>'))).toBe(true);
expect(looksLikeSvg(png(1, 1))).toBe(false);
});
});

View File

@ -81,6 +81,10 @@ export const brandingAssetSchema = z.object({
.regex(/^[a-f0-9]{16,64}$/),
width: z.number().int().min(1),
height: z.number().int().min(1),
/** Stored bytes. Needed so a pond's quota can be released exactly when the
* asset is replaced or removed (issue #307) optional because instance
* assets predating it are not charged to anything. */
byteSize: z.number().int().min(0).optional(),
});
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
@ -96,3 +100,54 @@ export interface BrandingView {
* screen carries the branding. */
instanceName: string;
}
/** A pond's own branding (issue #307), stored in its settings. Null per slot
* means "not set at this level". */
export const pondBrandingSchema = z.object({
logo: brandingAssetSchema.nullable().default(null),
logoDark: brandingAssetSchema.nullable().default(null),
favicon: brandingAssetSchema.nullable().default(null),
});
export type PondBranding = z.infer<typeof pondBrandingSchema>;
/** What a pond page should actually show. */
export interface ResolvedBranding {
logo: BrandingAsset | null;
logoDark: BrandingAsset | null;
favicon: BrandingAsset | null;
/** Which level the LOGO came from the link's accessible name follows it:
* a pond logo is named by the pond, an instance logo by the instance. */
logoLevel: 'pond' | 'instance' | 'none';
faviconLevel: 'pond' | 'instance' | 'default';
}
/**
* The single place that decides which asset applies (issues #306/#307):
* the pond's own, else the instance's, else the shipped default (favicon) or
* the instance name as text (logo).
*
* **A logo set belongs to one level variants are NEVER mixed across
* levels.** A pond that uploaded only a light logo shows THAT logo in dark
* mode; it does not fall back to the instance's dark variant. Decided
* 2026-08-01: a logo silently swapping to a different image when the viewer
* switches theme is a change nobody ordered, and a design that looks wrong is
* more honest than one that is quietly substituted the pond admin can see
* it and fix it. Only a pond with NO logo at all inherits the instance's set,
* again as a set.
*/
export function resolveBranding(
instance: Pick<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
pond?: PondBranding | null,
): ResolvedBranding {
const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark));
const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none';
const source = pondHasLogo ? pond! : instance;
const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default';
return {
logo: logoLevel === 'none' ? null : source.logo,
logoDark: logoLevel === 'none' ? null : source.logoDark,
favicon: pond?.favicon ?? instance.favicon ?? null,
logoLevel,
faviconLevel,
};
}

View File

@ -31,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';

View 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;
}

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { pondBrandingSchema } from './branding';
import { COMMENT_POLICIES } from './comments';
/**
@ -66,6 +68,12 @@ export const pondSettingsSchema = z.object({
* as an id, not a slug, so renaming or moving the page does not break it;
* a dangling id (page trashed) falls back rather than erroring. */
startPageId: z.string().uuid().nullable().default(null),
/** The pond's own logo and favicon (issue #307), overriding the instance's.
* Metadata only the PNG bytes live under `BRANDING_DIR`, like the
* instance's. Written through the branding endpoints, never through
* `PATCH /ponds/:id`: it describes bytes on disk, and hand-writing it would
* claim an asset that is not there. */
branding: pondBrandingSchema.default({ logo: null, logoDark: null, favicon: null }),
/** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off):