#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
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.
This commit is contained in:
parent
8752cf0c5a
commit
3310ae3926
@ -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' },
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
||||
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ConversionJobView,
|
||||
PageExportInput,
|
||||
PondArchivePreview,
|
||||
pageExportInputSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
@ -7,7 +12,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { readActorOf } from '../read-trail/read-actor';
|
||||
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
|
||||
import { ExportService } from './export.service';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/**
|
||||
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
|
||||
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
|
||||
*/
|
||||
@Controller()
|
||||
export class ExportController {
|
||||
constructor(private readonly exports: ExportService) {}
|
||||
constructor(
|
||||
private readonly exports: ExportService,
|
||||
private readonly archives: PondArchiveService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* How much of the pond this requester's archive would contain (issue #305).
|
||||
* Asked before the download so the UI can name the number of omitted pages:
|
||||
* an archive silently missing content is worse than no archive, because it
|
||||
* ends the search.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full archive: every readable page, EVERY attachment, and a versioned
|
||||
* manifest with settings, labels, comments and the hierarchy (issue #305).
|
||||
* Pond-Admin, because it is the deletion flow's last resort — a reader who
|
||||
* wants their own copy has the Markdown export.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
async pondArchive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
|
||||
}
|
||||
|
||||
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
||||
* `reader` role is "may see the pond"; the service filters to readable pages,
|
||||
@ -48,3 +90,34 @@ export class ExportController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Site Admin's archive from the purge dialog (issue #305, #193).
|
||||
*
|
||||
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
|
||||
* Admin purging a trashed pond is usually not a member of it, and the last
|
||||
* archive before an irreversible purge must not depend on that. It is
|
||||
* therefore complete by construction — the read filter is skipped.
|
||||
*/
|
||||
@Controller('admin/trash')
|
||||
@UseGuards(SiteAdminGuard)
|
||||
export class PondArchiveAdminController {
|
||||
constructor(private readonly archives: PondArchiveService) {}
|
||||
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, true);
|
||||
}
|
||||
|
||||
@Get('ponds/:pondId/archive')
|
||||
async archive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import { ConversionWorker } from './conversion-worker.service';
|
||||
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||
import { DataExportController } from './data-export.controller';
|
||||
import { DataExportService } from './data-export.service';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||
import { IMPORT_PROCESSOR } from './import.constants';
|
||||
@ -23,6 +23,7 @@ import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
import { JobsController } from './jobs.controller';
|
||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
||||
* the link's own expiry check already stops downloads the moment it lapses. */
|
||||
@ -48,12 +49,19 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||
SchedulerModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
||||
controllers: [
|
||||
JobsController,
|
||||
ImportController,
|
||||
ExportController,
|
||||
PondArchiveAdminController,
|
||||
DataExportController,
|
||||
],
|
||||
providers: [
|
||||
ConversionJobService,
|
||||
ConversionWorker,
|
||||
ImportService,
|
||||
ExportService,
|
||||
PondArchiveService,
|
||||
DataExportService,
|
||||
// The worker resolves the import pipeline through this token (never the
|
||||
// class), so its file does not import the import service's (avoids a cycle).
|
||||
|
||||
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { PondArchiveManifest } from '@dorfteich/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
function entries(buffer: Buffer): Record<string, Uint8Array> {
|
||||
return unzipSync(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
/** supertest parses text by default — a ZIP has to be collected as bytes. */
|
||||
function asBinary(req: request.Test): request.Test {
|
||||
return req.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
function manifestOf(buffer: Buffer): PondArchiveManifest {
|
||||
const raw = entries(buffer)['manifest.json'];
|
||||
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pond archive (issue #305). What separates it from the Markdown
|
||||
* export is exactly what is asserted here: EVERY attachment travels, not only
|
||||
* the embedded ones, and the manifest carries what Markdown cannot — settings,
|
||||
* labels, comments and the hierarchy.
|
||||
*/
|
||||
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let files: FilesService;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'archiviere den ganzen teich 1';
|
||||
const owner = { username: `arch-${suffix}` };
|
||||
const admin = { username: `archadm-${suffix}` };
|
||||
let ownerId: string;
|
||||
let ownerCookie: string;
|
||||
let adminCookie: string;
|
||||
let pondId: string;
|
||||
let parentPageId: string;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
app = await createTestApp();
|
||||
files = app.get(FilesService);
|
||||
const users = app.get(UsersService);
|
||||
const tokens = app.get(AuthTokensService);
|
||||
|
||||
const ownerUser = await users.createUser({
|
||||
username: owner.username,
|
||||
email: `${owner.username}@example.org`,
|
||||
displayName: `Archive Owner ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
ownerId = ownerUser.id;
|
||||
await api()
|
||||
.post('/api/v1/auth/verify-email')
|
||||
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
|
||||
.expect(204);
|
||||
ownerCookie = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: owner.username, password })
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
const adminUser = await users.createUser({
|
||||
username: admin.username,
|
||||
email: `${admin.username}@example.org`,
|
||||
displayName: `Archive Admin ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await users.markEmailVerified(adminUser.id);
|
||||
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||
adminCookie = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: admin.username, password })
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
|
||||
|
||||
// A parent and a child page, so the hierarchy has something to state.
|
||||
const parent = await prisma.page.create({
|
||||
data: {
|
||||
pondId,
|
||||
title: 'Archive Parent',
|
||||
slug: 'archive-parent',
|
||||
ydocState: new Uint8Array(),
|
||||
sortKey: 'a',
|
||||
createdBy: ownerId,
|
||||
contentCache: {
|
||||
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
parentPageId = parent.id;
|
||||
await prisma.page.create({
|
||||
data: {
|
||||
pondId,
|
||||
parentId: parent.id,
|
||||
title: 'Archive Child',
|
||||
slug: 'archive-child',
|
||||
ydocState: new Uint8Array(),
|
||||
sortKey: 'b',
|
||||
createdBy: ownerId,
|
||||
contentCache: {
|
||||
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const label = await prisma.label.create({
|
||||
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
|
||||
});
|
||||
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
|
||||
await prisma.comment.create({
|
||||
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||
await prisma.comment.deleteMany({ where: { page: where } });
|
||||
await prisma.attachment.deleteMany({ where });
|
||||
await prisma.pageLabel.deleteMany({ where: { page: where } });
|
||||
await prisma.label.deleteMany({ where });
|
||||
await prisma.page.deleteMany({ where });
|
||||
await prisma.roleGrant.deleteMany({ where });
|
||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('contains EVERY attachment, not only the embedded ones', async () => {
|
||||
// The gap this whole issue exists for: an attachment nobody embedded
|
||||
// would vanish unnoticed with the Markdown export.
|
||||
const orphan = await files.upload({ id: ownerId } as never, pondId, {
|
||||
buffer: Buffer.from(PNG_BASE64, 'base64'),
|
||||
size: 70,
|
||||
originalname: 'never-embedded.png',
|
||||
} as never);
|
||||
|
||||
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(res.headers['content-type']).toContain('application/zip');
|
||||
|
||||
const names = Object.keys(entries(res.body as Buffer));
|
||||
expect(names).toContain('manifest.json');
|
||||
expect(names).toContain('README.txt');
|
||||
expect(names).toContain('pages/archive-parent.md');
|
||||
expect(names).toContain('pages/archive-child.md');
|
||||
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
|
||||
|
||||
const manifest = manifestOf(res.body as Buffer);
|
||||
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
|
||||
// The Markdown export would have shipped no media at all here.
|
||||
expect(manifest.attachments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
|
||||
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
const manifest = manifestOf(res.body as Buffer);
|
||||
|
||||
expect(manifest.kind).toBe('dorfteich-pond-archive');
|
||||
expect(manifest.formatVersion).toBe(1);
|
||||
expect(manifest.complete).toBe(true);
|
||||
expect(manifest.omittedPages).toBe(0);
|
||||
|
||||
const child = manifest.pages.find((page) => page.slug === 'archive-child');
|
||||
// The hierarchy is exactly what a folder of Markdown cannot express.
|
||||
expect(child?.parentId).toBe(parentPageId);
|
||||
|
||||
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
|
||||
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
|
||||
// A display name, not an account id — the archive outlives the account.
|
||||
expect(manifest.comments[0]?.author).toContain('Archive Owner');
|
||||
// Pond settings ride along; fonts are always present through the schema
|
||||
// defaults, so their presence proves the settings object is real.
|
||||
expect(manifest.pond.settings).toHaveProperty('fonts');
|
||||
});
|
||||
|
||||
it('tells a requester before the download how much they would get', async () => {
|
||||
const preview = await api()
|
||||
.get(`/api/v1/ponds/${pondId}/archive/preview`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(preview.body.omittedPages).toBe(0);
|
||||
expect(preview.body.includedPages).toBe(preview.body.totalPages);
|
||||
expect(preview.body.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('audits the download with counts and completeness', async () => {
|
||||
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
const entry = await prisma.auditEntry.findFirst({
|
||||
where: { action: 'pond.archived', targetId: pondId },
|
||||
orderBy: { at: 'desc' },
|
||||
});
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
|
||||
});
|
||||
|
||||
it('gives the Site Admin a complete archive without pond membership', async () => {
|
||||
// The purge dialog's archive must not depend on which ponds the operator
|
||||
// happens to be a member of — this admin is a member of none.
|
||||
const preview = await api()
|
||||
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
|
||||
.set('Cookie', adminCookie)
|
||||
.expect(200);
|
||||
expect(preview.body.complete).toBe(true);
|
||||
expect(preview.body.omittedPages).toBe(0);
|
||||
|
||||
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
|
||||
.set('Cookie', adminCookie)
|
||||
.expect(200);
|
||||
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
|
||||
});
|
||||
|
||||
it('keeps the admin archive away from an ordinary pond admin', async () => {
|
||||
await api()
|
||||
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
@ -0,0 +1,395 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
PageClassification,
|
||||
PondArchiveManifest,
|
||||
PondArchivePreview,
|
||||
POND_ARCHIVE_FORMAT_VERSION,
|
||||
classificationMarking,
|
||||
highestClassification,
|
||||
pondSettingsSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
import archiver from 'archiver';
|
||||
import type { Response } from 'express';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { FileStorageService } from '../files/file-storage.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
|
||||
|
||||
import { markClassifiedMarkdown } from './classified-markdown';
|
||||
import { imageExtension, markdownForZip } from './export-markdown';
|
||||
|
||||
/** The plain-text note that travels inside the ZIP. The manifest says the
|
||||
* same thing machine-readably, but a person unpacking a folder of Markdown
|
||||
* a year from now reads the file lying next to it — and must not believe
|
||||
* they are holding a one-click restore. */
|
||||
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
|
||||
|
||||
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
|
||||
no importer for it yet. Everything needed to write one later is here and
|
||||
documented — see manifest.json and docs/architecture/pond-archive-format.md in
|
||||
the Dorfteich repository.
|
||||
|
||||
manifest.json pond settings, labels, page hierarchy, comments, attachment
|
||||
metadata, and the classification of every file
|
||||
pages/ one Markdown file per page
|
||||
media/ EVERY attachment of the pond, not only the embedded ones
|
||||
|
||||
If manifest.json states "complete": false, the archive was produced by someone
|
||||
who could not read every page of the pond; "omittedPages" says how many are
|
||||
missing.
|
||||
`;
|
||||
|
||||
/**
|
||||
* The full pond archive offered before a pond is deleted (issue #305).
|
||||
*
|
||||
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
|
||||
* ships the pages plus the images they embed, which as a LAST resort is not
|
||||
* enough — an attachment nobody embedded would vanish unnoticed. This one adds
|
||||
* every attachment and a machine-readable sidecar of the things Markdown
|
||||
* cannot carry: settings, labels, comments and the page hierarchy.
|
||||
*
|
||||
* Re-import is deliberately out of scope. The archive is a preservation
|
||||
* format: complete, versioned and documented, so an importer can be written
|
||||
* later without guesswork.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PondArchiveService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly readTrail: ReadTrailService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(PondArchiveService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages in the pond and how many of them this requester may read.
|
||||
*
|
||||
* The UI states the difference BEFORE the download: an archive silently
|
||||
* missing content is worse than no archive, because it ends the search.
|
||||
* A site admin archiving from the purge dialog reads everything, so their
|
||||
* preview says nothing is omitted.
|
||||
*/
|
||||
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
|
||||
const pond = await this.loadPond(pondId);
|
||||
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
|
||||
return {
|
||||
totalPages: total,
|
||||
includedPages: pages.length,
|
||||
omittedPages: total - pages.length,
|
||||
// "Complete" is a statement about the RESULT, not about the route: a
|
||||
// pond admin who may read every page gets a complete archive too. Only
|
||||
// an archive that actually leaves pages out is incomplete.
|
||||
complete: total === pages.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pond, or 404 — the caller's permission is checked by the route. */
|
||||
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
|
||||
// Deliberately including trashed ponds: the purge dialog archives a pond
|
||||
// that is already in the trash, which is the last moment it exists.
|
||||
const pond = await this.prisma.pond.findUnique({
|
||||
where: { id: pondId },
|
||||
select: { id: true, slug: true, name: true },
|
||||
});
|
||||
if (!pond) throw new NotFoundException();
|
||||
return pond;
|
||||
}
|
||||
|
||||
private async readablePages(
|
||||
user: User,
|
||||
pondId: string,
|
||||
unfiltered: boolean,
|
||||
): Promise<
|
||||
{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
sortKey: string;
|
||||
classification: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
labels: { labelId: string }[];
|
||||
contentCache: { markdown: string } | null;
|
||||
}[]
|
||||
> {
|
||||
const pages = await this.prisma.page.findMany({
|
||||
where: { pondId, deletedAt: null },
|
||||
orderBy: { title: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
parentId: true,
|
||||
sortKey: true,
|
||||
classification: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
labels: { select: { labelId: true } },
|
||||
contentCache: { select: { markdown: true } },
|
||||
},
|
||||
});
|
||||
if (unfiltered) return pages;
|
||||
const readable = await this.permissions.filterPages(
|
||||
user,
|
||||
pondId,
|
||||
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
||||
'read',
|
||||
);
|
||||
return pages.filter((page) => readable.has(page.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the archive.
|
||||
*
|
||||
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
|
||||
* read filter, because the last archive before an irreversible purge must
|
||||
* not depend on which pages the operator happens to be a member of.
|
||||
* Whether the RESULT is complete is a separate question, answered by
|
||||
* comparing what went in with what exists.
|
||||
*/
|
||||
async stream(
|
||||
user: User,
|
||||
pondId: string,
|
||||
res: Response,
|
||||
read: ReadActor,
|
||||
unfiltered: boolean,
|
||||
): Promise<void> {
|
||||
const pond = await this.loadPond(pondId);
|
||||
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||
const totalPages = await this.prisma.page.count({
|
||||
where: { pondId: pond.id, deletedAt: null },
|
||||
});
|
||||
const complete = totalPages === pages.length;
|
||||
|
||||
// Read trail (ADR 0023, issue #222's property): one `export` event per
|
||||
// classified page BEFORE any classified byte enters the stream, so a
|
||||
// failed write aborts the download with the evidence intact. The added
|
||||
// attachments carry their page's classification and are covered by the
|
||||
// same events — they never travel without their page.
|
||||
for (const page of pages) {
|
||||
if (page.classification !== 'VS_NFD') continue;
|
||||
await this.readTrail.record({
|
||||
...read,
|
||||
pageId: page.id,
|
||||
pondId: pond.id,
|
||||
channel: 'export',
|
||||
details: { format: 'pond_archive' },
|
||||
});
|
||||
}
|
||||
|
||||
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
|
||||
this.prisma.pond.findUniqueOrThrow({
|
||||
where: { id: pond.id },
|
||||
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.label.findMany({
|
||||
where: { pondId: pond.id },
|
||||
select: { id: true, name: true, color: true, parentId: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
this.prisma.comment.findMany({
|
||||
where: { page: { pondId: pond.id, deletedAt: null } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
pageId: true,
|
||||
parentId: true,
|
||||
body: true,
|
||||
createdAt: true,
|
||||
editedAt: true,
|
||||
resolvedAt: true,
|
||||
author: { select: { displayName: true } },
|
||||
},
|
||||
}),
|
||||
// EVERY attachment of the pond (issue #305) — not only the embedded
|
||||
// ones the Markdown export ships.
|
||||
this.prisma.attachment.findMany({
|
||||
where: { pondId: pond.id },
|
||||
select: {
|
||||
id: true,
|
||||
pageId: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
sha256: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const includedPageIds = new Set(pages.map((page) => page.id));
|
||||
// An attachment of a page the requester cannot read stays out — the same
|
||||
// rule the pages follow. Pond-level attachments (no page) are included:
|
||||
// nothing narrower than the pond governs them.
|
||||
const visibleAttachments = attachmentRows.filter(
|
||||
(row) => !row.pageId || includedPageIds.has(row.pageId),
|
||||
);
|
||||
const onDisk = await Promise.all(
|
||||
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
|
||||
);
|
||||
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
|
||||
|
||||
const classificationByPage = new Map(
|
||||
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
|
||||
);
|
||||
const mediaName = new Map(
|
||||
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
|
||||
);
|
||||
const readableSlugs = new Set(pages.map((page) => page.slug));
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
res.set('Content-Type', 'application/zip');
|
||||
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
|
||||
res.set('X-Content-Type-Options', 'nosniff');
|
||||
archive.on('error', (error) => {
|
||||
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
|
||||
res.destroy(error);
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
const files: { path: string; classification: PageClassification }[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
const level = classificationByPage.get(page.id) ?? 'unclassified';
|
||||
const markdown = markClassifiedMarkdown(
|
||||
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
|
||||
level,
|
||||
);
|
||||
const path = `pages/${page.slug}.md`;
|
||||
archive.append(markdown, { name: path });
|
||||
files.push({ path, classification: level });
|
||||
}
|
||||
|
||||
for (const row of attachments) {
|
||||
// An attachment inherits its page's level (fail-closed, ADR 0022); one
|
||||
// that belongs to no page inherits the pond's highest, because nothing
|
||||
// narrower governs it.
|
||||
const level = row.pageId
|
||||
? (classificationByPage.get(row.pageId) ?? 'unclassified')
|
||||
: highestClassification([...classificationByPage.values()]);
|
||||
const path = `media/${mediaName.get(row.id)!}`;
|
||||
files.push({ path, classification: level });
|
||||
// Companion marking (issue #212): binaries cannot carry it themselves,
|
||||
// and the sibling file survives unpacking where a manifest may not.
|
||||
const marking = classificationMarking(level);
|
||||
if (marking) {
|
||||
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
|
||||
files.push({ path: `${path}.classification.txt`, classification: level });
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: PondArchiveManifest = {
|
||||
kind: 'dorfteich-pond-archive',
|
||||
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
complete,
|
||||
omittedPages: totalPages - pages.length,
|
||||
classification: highestClassification(files.map((file) => file.classification)),
|
||||
pond: {
|
||||
name: settingsRow.name,
|
||||
slug: settingsRow.slug,
|
||||
type: settingsRow.type,
|
||||
createdAt: settingsRow.createdAt.toISOString(),
|
||||
// The EFFECTIVE settings, defaults filled in — a preservation format
|
||||
// must not require its reader to know Dorfteich's defaults, and the
|
||||
// stored row only holds what was explicitly set.
|
||||
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
parentId: label.parentId,
|
||||
})),
|
||||
pages: pages.map((page) => ({
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
parentId: page.parentId,
|
||||
sortKey: page.sortKey,
|
||||
classification: page.classification.toLowerCase() as PageClassification,
|
||||
labelIds: page.labels.map((label) => label.labelId),
|
||||
createdAt: page.createdAt.toISOString(),
|
||||
updatedAt: page.updatedAt.toISOString(),
|
||||
file: `pages/${page.slug}.md`,
|
||||
})),
|
||||
// Comments of included pages only — a comment is content of its page.
|
||||
comments: comments
|
||||
.filter((comment) => includedPageIds.has(comment.pageId))
|
||||
.map((comment) => ({
|
||||
id: comment.id,
|
||||
pageId: comment.pageId,
|
||||
parentId: comment.parentId,
|
||||
body: comment.body,
|
||||
// The display name, not the account: the archive is a document, and
|
||||
// it should stay readable after the account is gone.
|
||||
author: comment.author?.displayName ?? null,
|
||||
createdAt: comment.createdAt.toISOString(),
|
||||
editedAt: comment.editedAt?.toISOString() ?? null,
|
||||
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
|
||||
})),
|
||||
attachments: attachments.map((row) => ({
|
||||
id: row.id,
|
||||
pageId: row.pageId,
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
sha256: row.sha256,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
file: `media/${mediaName.get(row.id)!}`,
|
||||
})),
|
||||
files,
|
||||
};
|
||||
|
||||
archive.append(README, { name: 'README.txt' });
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||||
|
||||
for (const row of attachments) {
|
||||
const stream = this.storage.createReadStream(pond.id, row.id);
|
||||
stream.on('error', (error) =>
|
||||
this.logger.warn(
|
||||
{ pondId: pond.id, fileId: row.id, err: error.message },
|
||||
'pond archive: media read failed',
|
||||
),
|
||||
);
|
||||
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
|
||||
}
|
||||
|
||||
// Audited: a whole pond leaving the instance in one file, usually right
|
||||
// before it is deleted, is exactly the event an operator wants to find
|
||||
// later. Recorded before finalize so the trail exists even if the
|
||||
// download is aborted mid-stream.
|
||||
await this.audit.record({
|
||||
action: 'pond.archived',
|
||||
actorId: user.id,
|
||||
targetType: 'pond',
|
||||
targetId: pond.id,
|
||||
details: {
|
||||
pages: pages.length,
|
||||
attachments: attachments.length,
|
||||
omittedPages: totalPages - pages.length,
|
||||
complete,
|
||||
},
|
||||
});
|
||||
|
||||
await archive.finalize();
|
||||
this.logger.info(
|
||||
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
|
||||
'pond archive streamed',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete } from '../lib/api';
|
||||
import { PondArchiveOffer } from './PondArchiveOffer';
|
||||
|
||||
/**
|
||||
* The pond's danger zone: move a shared pond to the site-level trash
|
||||
@ -45,6 +46,7 @@ export function DeletePondSection({
|
||||
<section className="pond-delete">
|
||||
<h2>{t('pond.delete.title')}</h2>
|
||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||
<PondArchiveOffer pondId={pondId} />
|
||||
<form onSubmit={(e) => void remove(e)}>
|
||||
<FormError error={error} />
|
||||
<label>
|
||||
|
||||
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { PondArchivePreview } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
/**
|
||||
* The last full archive, offered INSIDE the deletion flow (issue #305).
|
||||
*
|
||||
* The gap this closes is not a missing prompt — the type-to-confirm field
|
||||
* next to it is stricter than a dialog. It is that the person who deletes the
|
||||
* pond loses access the moment they do: the pond disappears from their view,
|
||||
* only a Site Admin can bring it back, and the export is no longer reachable
|
||||
* for them. So the offer has to be here, before the button, not afterwards.
|
||||
*
|
||||
* Not downloading is allowed. A pond full of test pages should not require a
|
||||
* download, and the server cannot tell whether a file actually arrived — so
|
||||
* the finality is stated in text instead of enforced.
|
||||
*/
|
||||
export function PondArchiveOffer({ pondId }: { pondId: string }): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ['pond', pondId, 'archive-preview'],
|
||||
queryFn: () => apiGet<PondArchivePreview>(`/ponds/${pondId}/archive/preview`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pond-archive">
|
||||
<p>{t('pond.archive.intro')}</p>
|
||||
{/* An archive silently missing content is worse than no archive: it ends
|
||||
the search. So the omission is named BEFORE the download, with its
|
||||
number, not implied afterwards. */}
|
||||
{preview.data && !preview.data.complete && (
|
||||
<p className="pond-archive__warning">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('pond.archive.omitted', { count: preview.data.omittedPages })}
|
||||
</p>
|
||||
)}
|
||||
<p>{t('pond.archive.noImport')}</p>
|
||||
{/*
|
||||
A plain link, not fetch-into-a-blob: the api streams the ZIP, and
|
||||
buffering a whole pond in the tab to show a progress bar would trade
|
||||
memory for cosmetics. The browser's own download UI reports progress
|
||||
and completion; what it cannot say is that the archive is being BUILT,
|
||||
so the live region below says that.
|
||||
*/}
|
||||
<a
|
||||
className="button pond-archive__download"
|
||||
href={`/api/v1/ponds/${pondId}/archive`}
|
||||
onClick={() => setStarted(true)}
|
||||
>
|
||||
{t('pond.archive.download')}
|
||||
</a>
|
||||
<p role="status" className="pond-archive__status">
|
||||
{started ? t('pond.archive.started') : ''}
|
||||
</p>
|
||||
<p className="pond-archive__finality">{t('pond.archive.finality')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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` |
|
||||
|
||||
118
docs/architecture/pond-archive-format.md
Normal file
118
docs/architecture/pond-archive-format.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Pond archive format (issue #305)
|
||||
|
||||
**Format version 1.**
|
||||
|
||||
The archive a pond admin downloads before deleting a pond, and the one a Site
|
||||
Admin downloads before purging one. It is a **preservation format, not a
|
||||
backup**: Dorfteich has no importer for it, deliberately. What this document
|
||||
buys is that one can be written later without guesswork.
|
||||
|
||||
Do not confuse it with two neighbours:
|
||||
|
||||
| | contains | purpose |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------- |
|
||||
| Markdown export (`GET /ponds/:id/export/markdown`, #65) | readable pages + the images they embed | everyday "give me my text" |
|
||||
| **Pond archive** (this document) | readable pages + **all** attachments + settings, labels, comments, hierarchy | last resort before deletion |
|
||||
| Restore set (ADR 0015) | the whole instance, database and data directories | operational recovery |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
README.txt plain-text version of this warning, for whoever unpacks it
|
||||
manifest.json everything Markdown cannot carry (see below)
|
||||
pages/<slug>.md one file per page, Markdown, wikilinks rewritten to
|
||||
relative links, media references rewritten to media/…
|
||||
media/<id>.<ext> EVERY attachment of the pond — including ones no page
|
||||
embeds, which is the whole point of this archive
|
||||
media/<id>.<ext>.classification.txt
|
||||
companion marking for a classified attachment (#212): the
|
||||
binary cannot carry it, and this file survives copying
|
||||
```
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": "dorfteich-pond-archive",
|
||||
"formatVersion": 1,
|
||||
"exportedAt": "2026-08-01T18:00:00.000Z",
|
||||
|
||||
// False when the exporter could not read every page. Stated in the archive
|
||||
// itself so a later reader is never misled about what they hold.
|
||||
"complete": true,
|
||||
"omittedPages": 0,
|
||||
|
||||
// Highest classification contained (ADR 0022), stated once.
|
||||
"classification": "unclassified",
|
||||
|
||||
"pond": { "name": "…", "slug": "…", "type": "SHARED", "createdAt": "…", "settings": { … } },
|
||||
"labels": [{ "id": "…", "name": "…", "color": "…", "parentId": null }],
|
||||
"pages": [
|
||||
{
|
||||
"id": "…", "slug": "…", "title": "…",
|
||||
"parentId": null, // the hierarchy Markdown cannot express
|
||||
"sortKey": "…", // sibling order (ADR 0012's fractional key)
|
||||
"classification": "unclassified",
|
||||
"labelIds": ["…"],
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"file": "pages/<slug>.md"
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"id": "…", "pageId": "…", "parentId": null, "body": "…",
|
||||
"author": "Display Name", // NOT the account id — see below
|
||||
"createdAt": "…", "editedAt": null, "resolvedAt": null
|
||||
}
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "…", "pageId": "…" | null, "fileName": "…", "mimeType": "…",
|
||||
"sizeBytes": 1234,
|
||||
"sha256": "…", // #199, so a reader can verify the bytes
|
||||
"createdAt": "…", "file": "media/<id>.<ext>"
|
||||
}
|
||||
],
|
||||
"files": [{ "path": "…", "classification": "unclassified" }]
|
||||
}
|
||||
```
|
||||
|
||||
## Decisions a reader should know about
|
||||
|
||||
- **`formatVersion` is a contract.** A reader that does not recognise the
|
||||
version should refuse rather than guess. Additive fields do not bump it;
|
||||
a change in meaning does.
|
||||
- **Comments name a display name, not an account.** The archive is a document
|
||||
that outlives the instance; an account id would be an unresolvable reference
|
||||
the moment the account is gone.
|
||||
- **`files` is the #210 property, kept.** Every file with its level, so the
|
||||
bulk-egress channel stays machine-checkable after the ZIP is unpacked and
|
||||
copied onward.
|
||||
- **An attachment with no page inherits the pond's highest classification.**
|
||||
Nothing narrower governs it, and fail-closed is the rule (ADR 0022).
|
||||
- **Incomplete archives are labelled, not refused.** A pond admin who cannot
|
||||
read every page still gets what they may read — with `complete: false` and
|
||||
the count of what is missing, in the manifest and in the UI before the
|
||||
download.
|
||||
|
||||
## Read trail
|
||||
|
||||
The archive is a bulk-egress channel. One `export` read event is written per
|
||||
classified page **before any classified byte enters the stream** (ADR 0023), so
|
||||
a failed write aborts the download with the evidence intact. Attachments never
|
||||
travel without their page, so they are covered by the same events.
|
||||
|
||||
The download itself is audited as `pond.archived` (catalogue v1.7) with the
|
||||
page and attachment counts, the number of omitted pages, and whether the
|
||||
archive was complete.
|
||||
|
||||
## What is NOT in it
|
||||
|
||||
- Page history and Yjs update logs. The Markdown is the current state.
|
||||
- Permissions and memberships: they name accounts of _this_ instance, which an
|
||||
archive read elsewhere cannot resolve.
|
||||
- Anything from the trash: trashed pages are not exported.
|
||||
|
||||
An importer will therefore recreate a pond's content, structure and
|
||||
discussion — not its history or its access rules. That is a deliberate scope,
|
||||
not an oversight.
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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';
|
||||
|
||||
102
packages/shared/src/pond-archive.ts
Normal file
102
packages/shared/src/pond-archive.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { PageClassification } from './pages';
|
||||
|
||||
/**
|
||||
* The full pond archive (issue #305): the last download offered before a pond
|
||||
* is trashed, and before a Site Admin purges one for good.
|
||||
*
|
||||
* A PRESERVATION format, not a backup — there is no importer, on purpose.
|
||||
* What that buys is a documented, versioned description of everything the
|
||||
* Markdown alone cannot carry, so an importer can be written later without
|
||||
* guesswork. The format is documented in
|
||||
* `docs/architecture/pond-archive-format.md`.
|
||||
*/
|
||||
|
||||
/** Bumped whenever the manifest's shape changes in a way a reader must know
|
||||
* about. A reader that does not recognise the version should refuse rather
|
||||
* than guess. */
|
||||
export const POND_ARCHIVE_FORMAT_VERSION = 1;
|
||||
|
||||
export interface PondArchiveLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface PondArchivePage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
/** The hierarchy Markdown cannot express — null for a top-level page. */
|
||||
parentId: string | null;
|
||||
/** Sibling order, as stored (ADR 0012's fractional key). */
|
||||
sortKey: string;
|
||||
classification: PageClassification;
|
||||
labelIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Path of the page's Markdown inside the archive. */
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface PondArchiveComment {
|
||||
id: string;
|
||||
pageId: string;
|
||||
parentId: string | null;
|
||||
body: string;
|
||||
/** Display name at export time; the account may be gone by the time anyone
|
||||
* reads this, and the archive should stay readable. */
|
||||
author: string | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PondArchiveAttachment {
|
||||
id: string;
|
||||
/** Null for an attachment that belongs to the pond rather than a page. */
|
||||
pageId: string | null;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
/** SHA-256 of the stored bytes (issue #199), so a reader can verify them. */
|
||||
sha256: string | null;
|
||||
createdAt: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface PondArchiveManifest {
|
||||
kind: 'dorfteich-pond-archive';
|
||||
formatVersion: number;
|
||||
exportedAt: string;
|
||||
/** False when the exporter could not read every page — see `omittedPages`.
|
||||
* Stated in the archive itself so a later reader is never misled about
|
||||
* what they are holding. */
|
||||
complete: boolean;
|
||||
omittedPages: number;
|
||||
/** Highest classification contained (ADR 0022), stated once. */
|
||||
classification: PageClassification;
|
||||
pond: {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
labels: PondArchiveLabel[];
|
||||
pages: PondArchivePage[];
|
||||
comments: PondArchiveComment[];
|
||||
attachments: PondArchiveAttachment[];
|
||||
/** Every file in the archive with its level — the #210 manifest property,
|
||||
* kept so the bulk-egress channel stays machine-checkable after unpacking. */
|
||||
files: { path: string; classification: PageClassification }[];
|
||||
}
|
||||
|
||||
/** What the UI asks for before offering the download, so it can say how much
|
||||
* of the pond the requester would actually get. */
|
||||
export interface PondArchivePreview {
|
||||
totalPages: number;
|
||||
includedPages: number;
|
||||
omittedPages: number;
|
||||
complete: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user