diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index fdd7ebf..699cd65 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 af43106..605a08b 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)}>