From 1b992c8626915b3ed029a61dd7dc72c836ba647d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 19:36:10 +0200 Subject: [PATCH] #305: a full pond archive before deletion and before purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/audit/audit-actions.ts | 1 + .../src/import-export/export.controller.ts | 79 +++- .../src/import-export/import-export.module.ts | 12 +- .../import-export/pond-archive.e2e.db.test.ts | 250 +++++++++++ .../src/import-export/pond-archive.service.ts | 395 ++++++++++++++++++ apps/web/e2e/a11y.spec.ts | 9 + apps/web/src/ponds/DeletePondSection.tsx | 2 + apps/web/src/ponds/PondArchiveOffer.tsx | 63 +++ apps/web/src/styles/base.css | 26 ++ docs/architecture/audit-events.md | 4 +- docs/architecture/pond-archive-format.md | 118 ++++++ packages/shared/i18n/de/common.json | 9 + packages/shared/i18n/en/common.json | 9 + packages/shared/src/index.ts | 1 + packages/shared/src/pond-archive.ts | 102 +++++ 15 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/import-export/pond-archive.e2e.db.test.ts create mode 100644 apps/api/src/import-export/pond-archive.service.ts create mode 100644 apps/web/src/ponds/PondArchiveOffer.tsx create mode 100644 docs/architecture/pond-archive-format.md create mode 100644 packages/shared/src/pond-archive.ts diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 01fa883..1958c3f 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -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' }, diff --git a/apps/api/src/import-export/export.controller.ts b/apps/api/src/import-export/export.controller.ts index a0c4450..496de5c 100644 --- a/apps/api/src/import-export/export.controller.ts +++ b/apps/api/src/import-export/export.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.archives.stream(request.user!, pondId, response, readActorOf(request), true); + } +} diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index b865c6e..bef9d37 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -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). diff --git a/apps/api/src/import-export/pond-archive.e2e.db.test.ts b/apps/api/src/import-export/pond-archive.e2e.db.test.ts new file mode 100644 index 0000000..0155e3a --- /dev/null +++ b/apps/api/src/import-export/pond-archive.e2e.db.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/import-export/pond-archive.service.ts b/apps/api/src/import-export/pond-archive.service.ts new file mode 100644 index 0000000..584e2d1 --- /dev/null +++ b/apps/api/src/import-export/pond-archive.service.ts @@ -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 { + 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 { + 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', + ); + } +} diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 2073085..ff31406 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -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(); }); diff --git a/apps/web/src/ponds/DeletePondSection.tsx b/apps/web/src/ponds/DeletePondSection.tsx index d55c0f3..7313cdb 100644 --- a/apps/web/src/ponds/DeletePondSection.tsx +++ b/apps/web/src/ponds/DeletePondSection.tsx @@ -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({

{t('pond.delete.title')}

{t('pond.delete.hint')}

+
void remove(e)}>