#305: a full pond archive before deletion and before purge #316

Merged
opus-5 merged 1 commits from issue-305-pond-archive into main 2026-08-01 20:43:42 +02:00
15 changed files with 1074 additions and 6 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

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

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

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

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

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

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

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