Compare commits
1 Commits
5c03da2ccc
...
1b992c8626
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b992c8626 |
@ -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>
|
||||
);
|
||||
}
|
||||
@ -4178,3 +4178,29 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
.custom-fonts input[type='file'] {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 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.6 (2026-08-01; 1.6 adds `font.uploaded` and
|
||||
**Catalogue version 1.7 (2026-08-01; 1.7 adds `pond.archived`,
|
||||
issue #305; 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
|
||||
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||
@ -111,6 +112,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": {
|
||||
|
||||
@ -30,6 +30,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