Compare commits
2 Commits
5c03da2ccc
...
3310ae3926
| Author | SHA1 | Date | |
|---|---|---|---|
| 3310ae3926 | |||
| 8752cf0c5a |
@ -40,6 +40,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'plugin.mode_set': { severity: 'notice' },
|
'plugin.mode_set': { severity: 'notice' },
|
||||||
'plugin.pond_toggled': { severity: 'info' },
|
'plugin.pond_toggled': { severity: 'info' },
|
||||||
'plugin.uninstalled': { severity: 'notice' },
|
'plugin.uninstalled': { severity: 'notice' },
|
||||||
|
'pond.archived': { severity: 'notice' },
|
||||||
'pond.purged': { severity: 'notice' },
|
'pond.purged': { severity: 'notice' },
|
||||||
'quota.override_cleared': { severity: 'notice' },
|
'quota.override_cleared': { severity: 'notice' },
|
||||||
'quota.override_set': { severity: 'notice' },
|
'quota.override_set': { severity: 'notice' },
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
import {
|
||||||
|
ConversionJobView,
|
||||||
|
PageExportInput,
|
||||||
|
PondArchivePreview,
|
||||||
|
pageExportInputSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
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 { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
import { readActorOf } from '../read-trail/read-actor';
|
import { readActorOf } from '../read-trail/read-actor';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
|
||||||
import { ExportService } from './export.service';
|
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
|
* 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()
|
@Controller()
|
||||||
export class ExportController {
|
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
|
/** 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,
|
* `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 { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||||
import { DataExportController } from './data-export.controller';
|
import { DataExportController } from './data-export.controller';
|
||||||
import { DataExportService } from './data-export.service';
|
import { DataExportService } from './data-export.service';
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||||
import { IMPORT_PROCESSOR } from './import.constants';
|
import { IMPORT_PROCESSOR } from './import.constants';
|
||||||
@ -23,6 +23,7 @@ import { ImportController } from './import.controller';
|
|||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
import { JobsController } from './jobs.controller';
|
import { JobsController } from './jobs.controller';
|
||||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||||
|
import { PondArchiveService } from './pond-archive.service';
|
||||||
|
|
||||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
/** 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. */
|
* 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,
|
SchedulerModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
controllers: [
|
||||||
|
JobsController,
|
||||||
|
ImportController,
|
||||||
|
ExportController,
|
||||||
|
PondArchiveAdminController,
|
||||||
|
DataExportController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ConversionJobService,
|
ConversionJobService,
|
||||||
ConversionWorker,
|
ConversionWorker,
|
||||||
ImportService,
|
ImportService,
|
||||||
ExportService,
|
ExportService,
|
||||||
|
PondArchiveService,
|
||||||
DataExportService,
|
DataExportService,
|
||||||
// The worker resolves the import pipeline through this token (never the
|
// 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).
|
// 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.goto('/fonts');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
await expectClean(page, `/fonts (${scheme})`);
|
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();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,23 @@
|
|||||||
|
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
|
||||||
|
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
|
||||||
|
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
|
||||||
|
# SPA route — which nginx answers with index.html — would see `en` forever,
|
||||||
|
# even for German content.
|
||||||
|
#
|
||||||
|
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
|
||||||
|
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
|
||||||
|
# German; `en-US,de` does not, because that visitor asked for English first.
|
||||||
|
#
|
||||||
|
# Known limit, documented rather than worked around: nginx does not know
|
||||||
|
# `instance.defaultLocale` from the database, so a visitor with no (or an
|
||||||
|
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
|
||||||
|
# content that is not the authoritative rendering anyway — the api's server
|
||||||
|
# shell (`/api/v1/public/...`) renders those with the instance locale.
|
||||||
|
map $http_accept_language $spa_lang {
|
||||||
|
default en;
|
||||||
|
~*^de de;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA serving: static assets with long-lived caching, everything else
|
# SPA serving: static assets with long-lived caching, everything else
|
||||||
# falls back to index.html (client-side routing).
|
# falls back to index.html (client-side routing).
|
||||||
server {
|
server {
|
||||||
@ -34,6 +54,17 @@ server {
|
|||||||
# zero-third-party-request guarantee (security.md) is unaffected.
|
# zero-third-party-request guarantee (security.md) is unaffected.
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
# The shell's language (issue #179). Only the html document is
|
||||||
|
# rewritten, and only its first match — `<html lang="en">` is the
|
||||||
|
# first and only occurrence in index.html. Everything else this
|
||||||
|
# location serves passes through untouched.
|
||||||
|
sub_filter_types text/html;
|
||||||
|
sub_filter_once on;
|
||||||
|
sub_filter 'lang="en"' 'lang="$spa_lang"';
|
||||||
|
# The response now depends on a request header, so shared caches must
|
||||||
|
# not serve one language's copy to the other. This location is
|
||||||
|
# `no-cache` anyway; the header states the dependency correctly.
|
||||||
|
add_header Vary "Accept-Language" always;
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiDelete } from '../lib/api';
|
import { apiDelete } from '../lib/api';
|
||||||
|
import { PondArchiveOffer } from './PondArchiveOffer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pond's danger zone: move a shared pond to the site-level trash
|
* 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">
|
<section className="pond-delete">
|
||||||
<h2>{t('pond.delete.title')}</h2>
|
<h2>{t('pond.delete.title')}</h2>
|
||||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||||
|
<PondArchiveOffer pondId={pondId} />
|
||||||
<form onSubmit={(e) => void remove(e)}>
|
<form onSubmit={(e) => void remove(e)}>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { PondArchivePreview } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last full archive, offered INSIDE the deletion flow (issue #305).
|
||||||
|
*
|
||||||
|
* The gap this closes is not a missing prompt — the type-to-confirm field
|
||||||
|
* next to it is stricter than a dialog. It is that the person who deletes the
|
||||||
|
* pond loses access the moment they do: the pond disappears from their view,
|
||||||
|
* only a Site Admin can bring it back, and the export is no longer reachable
|
||||||
|
* for them. So the offer has to be here, before the button, not afterwards.
|
||||||
|
*
|
||||||
|
* Not downloading is allowed. A pond full of test pages should not require a
|
||||||
|
* download, and the server cannot tell whether a file actually arrived — so
|
||||||
|
* the finality is stated in text instead of enforced.
|
||||||
|
*/
|
||||||
|
export function PondArchiveOffer({ pondId }: { pondId: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
|
||||||
|
const preview = useQuery({
|
||||||
|
queryKey: ['pond', pondId, 'archive-preview'],
|
||||||
|
queryFn: () => apiGet<PondArchivePreview>(`/ponds/${pondId}/archive/preview`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pond-archive">
|
||||||
|
<p>{t('pond.archive.intro')}</p>
|
||||||
|
{/* An archive silently missing content is worse than no archive: it ends
|
||||||
|
the search. So the omission is named BEFORE the download, with its
|
||||||
|
number, not implied afterwards. */}
|
||||||
|
{preview.data && !preview.data.complete && (
|
||||||
|
<p className="pond-archive__warning">
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{t('pond.archive.omitted', { count: preview.data.omittedPages })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>{t('pond.archive.noImport')}</p>
|
||||||
|
{/*
|
||||||
|
A plain link, not fetch-into-a-blob: the api streams the ZIP, and
|
||||||
|
buffering a whole pond in the tab to show a progress bar would trade
|
||||||
|
memory for cosmetics. The browser's own download UI reports progress
|
||||||
|
and completion; what it cannot say is that the archive is being BUILT,
|
||||||
|
so the live region below says that.
|
||||||
|
*/}
|
||||||
|
<a
|
||||||
|
className="button pond-archive__download"
|
||||||
|
href={`/api/v1/ponds/${pondId}/archive`}
|
||||||
|
onClick={() => setStarted(true)}
|
||||||
|
>
|
||||||
|
{t('pond.archive.download')}
|
||||||
|
</a>
|
||||||
|
<p role="status" className="pond-archive__status">
|
||||||
|
{started ? t('pond.archive.started') : ''}
|
||||||
|
</p>
|
||||||
|
<p className="pond-archive__finality">{t('pond.archive.finality')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4281,3 +4281,29 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
8px -8px,
|
8px -8px,
|
||||||
-8px 0;
|
-8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-archive offer inside the pond deletion flow (issue #305). Plain
|
||||||
|
column: the section must reflow at 320px without rules of its own (#301). */
|
||||||
|
.pond-archive {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__warning {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__download {
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__finality {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# Audit event catalogue
|
# Audit event catalogue
|
||||||
|
|
||||||
**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`,
|
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`,
|
||||||
|
issue #305; 1.7 added `branding.changed`,
|
||||||
issue #306; 1.6 added `font.uploaded` and
|
issue #306; 1.6 added `font.uploaded` and
|
||||||
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
`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
|
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
||||||
@ -113,6 +114,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| 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) |
|
| `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) |
|
| `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` |
|
| `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` |
|
| `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}}",
|
"confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}",
|
||||||
"submit": "Teich löschen",
|
"submit": "Teich löschen",
|
||||||
"deleted": "Teich gelöscht."
|
"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": {
|
"settingsNav": {
|
||||||
|
|||||||
@ -92,6 +92,15 @@
|
|||||||
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
||||||
"submit": "Delete pond",
|
"submit": "Delete pond",
|
||||||
"deleted": "Pond deleted."
|
"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": {
|
"settingsNav": {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export * from './search';
|
|||||||
export * from './secret-store';
|
export * from './secret-store';
|
||||||
export * from './setup';
|
export * from './setup';
|
||||||
export * from './system';
|
export * from './system';
|
||||||
|
export * from './pond-archive';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './quotas';
|
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