From 74a9e495e4ab15ee6033e103cb4b03c5d11cb24a Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 07:09:47 +0200 Subject: [PATCH 1/2] #209: pandoc reference documents carry the VS-NfD marking for DOCX/ODT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reference-vs-nfd.docx/.odt ship as derived binaries: the pinned pandoc's default reference documents plus a header and footer with the marking — part of the document's page setup, so it repeats on every page in Word and LibreOffice and is not deletable body text. Source of truth is scripts/gen-classified-reference-docs.mjs (wording from shared classificationMarking(); maintenance documented in assets/README.md). The converter passes reference docs to pandoc-server via in-request files + reference-doc; the worker attaches them for marked docx/odt jobs (job option {marking}, as in #208). Unclassified exports pass nothing and are unchanged (pinned by fake-converter test). Fidelity suite asserts against real pandoc 3.6 that marked outputs carry the header/footer parts and unmarked ones do not; per-page repetition verified via LibreOffice 25.8 headless PDF (5/5 pages, 2 markings each, both formats). Word: quick manual look pending (sample files in the workspace), procedure documented in assets/README.md. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/Dockerfile | 1 + apps/api/assets/README.md | 45 +++++++ apps/api/assets/reference-vs-nfd.docx | Bin 0 -> 11898 bytes apps/api/assets/reference-vs-nfd.odt | Bin 0 -> 7374 bytes apps/api/prisma/schema.prisma | 4 +- .../scripts/gen-classified-reference-docs.mjs | 118 ++++++++++++++++++ .../conversion-worker.service.ts | 27 ++++ .../src/import-export/export.fidelity.test.ts | 68 ++++++++++ .../import-export/export.service.db.test.ts | 26 ++++ apps/api/src/import-export/export.service.ts | 5 + .../api/src/import-export/pandoc.converter.ts | 13 ++ docs/vs-nfd/20-massnahmenplan.md | 2 +- eslint.config.mjs | 7 +- 13 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 apps/api/assets/README.md create mode 100644 apps/api/assets/reference-vs-nfd.docx create mode 100644 apps/api/assets/reference-vs-nfd.odt create mode 100644 apps/api/scripts/gen-classified-reference-docs.mjs diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f8dfe32..5631b8c 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -21,6 +21,7 @@ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ # needed for migrate-on-start) at /out. && pnpm --filter @dorfteich/api deploy --prod --legacy /out \ && cp -r apps/api/dist /out/dist \ + && cp -r apps/api/assets /out/assets \ && cp -r /repo/fonts /out/fonts FROM node:22.15.1-alpine diff --git a/apps/api/assets/README.md b/apps/api/assets/README.md new file mode 100644 index 0000000..4f99bc0 --- /dev/null +++ b/apps/api/assets/README.md @@ -0,0 +1,45 @@ +# Runtime assets + +## `reference-vs-nfd.docx` / `reference-vs-nfd.odt` (issue #209, ADR 0022) + +Pandoc reference documents for the DOCX/ODT export of a **classified** +page: their page setup defines a header and footer carrying the VS-NfD +marking, which pandoc copies into its output — so the marking repeats on +every page in Word and LibreOffice and is not deletable body text. +Unclassified exports pass no reference document and are unchanged. + +These are **derived binaries — never edit them by hand.** Source of truth +is `../scripts/gen-classified-reference-docs.mjs`: it takes the default +reference documents of the pinned sidecar (`pandoc/core:3.6`, the exact +image the stages run) and injects the header/footer, with the wording from +`classificationMarking()` in `@dorfteich/shared` (single source, ADR +0022). Regenerate — after a pandoc pin bump, a wording change, or a layout +tweak in the script — with Docker running: + +```sh +pnpm --filter @dorfteich/shared build # the script imports the wording +node apps/api/scripts/gen-classified-reference-docs.mjs +``` + +Commit script and binaries together. The fidelity suite +(`export.fidelity.test.ts`) asserts against the real pinned pandoc that a +marked export carries the header/footer parts and an unmarked one does +not. + +### Per-page verification in the office suites + +After regenerating, confirm the marking repeats on **every** page of a +multi-page export (not just structurally in the XML): + +1. Produce a marked multi-page export (any classified page with a few + screens of text, exported to `.docx` and `.odt`). +2. **LibreOffice** (scriptable): + `soffice --headless --convert-to pdf ` and check every PDF page + shows the marking twice (header + footer) — e.g. with `pypdf`. +3. **Word**: open the `.docx`, check header and footer on every page + (print preview). Word's AppleScript/sandbox makes this hard to script — + this step is a quick manual look. + +Last verified 2026-07-31 (pandoc 3.6 output): LibreOffice 25.8, both +formats, 5/5 pages with 2 markings each. Word: manual check pending — +sample files in the workspace under `doku/209-marked-sample.docx/.odt`. diff --git a/apps/api/assets/reference-vs-nfd.docx b/apps/api/assets/reference-vs-nfd.docx new file mode 100644 index 0000000000000000000000000000000000000000..40f48cab445dd49e6bc553f99aff4635d665bcc1 GIT binary patch literal 11898 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 QQlkKczTDVV%AcS92St|+BLDyZ literal 0 HcmV?d00001 diff --git a/apps/api/assets/reference-vs-nfd.odt b/apps/api/assets/reference-vs-nfd.odt new file mode 100644 index 0000000000000000000000000000000000000000..adca54da5abd48e4838901c487e131b57c982769 GIT binary patch literal 7374 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 literal 0 HcmV?d00001 diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e7003a1..da2d37c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -786,8 +786,8 @@ model ConversionJob { /// other job kind, whose payload the general retention (#233) prunes. expiresAt DateTime? @map("expires_at") /// Kind-specific job options (issue #117): a vault import carries - /// `{parentPageId, labelIds, frontmatterMode}`; a PDF export of a - /// classified page carries `{marking}` (issue #208). Null otherwise. + /// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a + /// classified page carries `{marking}` (issues #208/#209). Null otherwise. options Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/scripts/gen-classified-reference-docs.mjs b/apps/api/scripts/gen-classified-reference-docs.mjs new file mode 100644 index 0000000..fc6296f --- /dev/null +++ b/apps/api/scripts/gen-classified-reference-docs.mjs @@ -0,0 +1,118 @@ +/** + * Regenerate the classified reference documents (issue #209, ADR 0022): + * `apps/api/assets/reference-vs-nfd.docx` / `.odt`. + * + * The DOCX/ODT export of a classified page passes these to pandoc via + * `--reference-doc`; pandoc copies the reference's page setup — including + * headers and footers — into its output, which is how the VS-NfD marking + * repeats on every page in Word and LibreOffice without being deletable + * body text. + * + * The binaries are DERIVED files: base = the default reference documents of + * the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run), + * plus a header and footer carrying the marking. Never edit the binaries by + * hand — edit this script and re-run it (Docker required): + * + * node apps/api/scripts/gen-classified-reference-docs.mjs + * + * The marking wording comes from @dorfteich/shared (single source, ADR + * 0022); the shared package must be built (`pnpm --filter @dorfteich/shared + * build`). + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { classificationMarking } from '@dorfteich/shared'; +import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate'; + +const PANDOC_IMAGE = 'pandoc/core:3.6'; +const MARKING = classificationMarking('vs_nfd'); +const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets'); + +function defaultReference(name) { + return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], { + maxBuffer: 64 * 1024 * 1024, + }); +} + +function escapeXml(value) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** DOCX: add word/header1.xml + word/footer1.xml, register them in the + * content types and document relationships, and reference them from the + * document's sectPr — Word repeats them on every page. */ +function patchDocx(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + + const partXml = (root) => + `\n` + + `` + + `` + + `${marking}` + + ``; + zip['word/header1.xml'] = strToU8(partXml('hdr')); + zip['word/footer1.xml'] = strToU8(partXml('ftr')); + + const types = strFromU8(zip['[Content_Types].xml']); + zip['[Content_Types].xml'] = strToU8( + types.replace( + '', + '' + + '' + + '', + ), + ); + + const rels = strFromU8(zip['word/_rels/document.xml.rels']); + zip['word/_rels/document.xml.rels'] = strToU8( + rels.replace( + '', + '' + + '' + + '', + ), + ); + + const doc = strFromU8(zip['word/document.xml']); + if (!doc.includes('')) throw new Error('reference.docx has no sectPr'); + zip['word/document.xml'] = strToU8( + doc.replace( + '', + '' + + '' + + '', + ), + ); + + return zipSync(zip); +} + +/** ODT: give the Standard master page a header with the marking and put the + * marking next to the existing page number in its footer — LibreOffice + * repeats master-page headers/footers on every page. */ +function patchOdt(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + const styles = strFromU8(zip['styles.xml']); + if (!styles.includes('')) throw new Error('reference.odt has no footer'); + const patched = styles + .replace( + '', + `${marking}`, + ) + .replace( + '\n ', + `\n ${marking} · `, + ); + zip['styles.xml'] = strToU8(patched); + return zipSync(zip); +} + +mkdirSync(outDir, { recursive: true }); +writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx'))); +writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt'))); +console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`); diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index 82e537a..72989a9 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { ConversionJob } from '@prisma/client'; @@ -66,10 +69,34 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { to: job.targetFormat, input: Buffer.from(conversionInputOf(job)), standalone: job.standalone, + referenceDoc: await this.classifiedReferenceDoc(job), }); return { bytes: result.output, mimeType: result.mimeType }; } + /** + * The classified reference document for a marked docx/odt export (issue + * #209, ADR 0022): pandoc copies its header/footer — which carry the + * VS-NfD marking — into the output, so the marking repeats on every page + * in Word/LibreOffice and is not deletable body text. Only present when + * the enqueue put a `marking` into the job options; the binaries ship in + * `apps/api/assets/` (see `scripts/gen-classified-reference-docs.mjs`). + */ + private async classifiedReferenceDoc( + job: ConversionJob, + ): Promise<{ name: string; bytes: Buffer } | undefined> { + const marked = Boolean((job.options as { marking?: string } | null)?.marking); + if (!marked || (job.targetFormat !== 'docx' && job.targetFormat !== 'odt')) return undefined; + const name = `reference-vs-nfd.${job.targetFormat}`; + const cached = this.referenceDocs.get(name); + if (cached) return { name, bytes: cached }; + const bytes = await readFile(join(__dirname, '../../assets', name)); + this.referenceDocs.set(name, bytes); + return { name, bytes }; + } + + private readonly referenceDocs = new Map(); + onModuleInit(): void { if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly this.timer = setInterval(() => this.drainSafely(), SWEEP_MS); diff --git a/apps/api/src/import-export/export.fidelity.test.ts b/apps/api/src/import-export/export.fidelity.test.ts index 08858d7..29c8c3d 100644 --- a/apps/api/src/import-export/export.fidelity.test.ts +++ b/apps/api/src/import-export/export.fidelity.test.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { classificationMarking } from '@dorfteich/shared'; +import { strFromU8, unzipSync } from 'fflate'; import { beforeAll, describe, expect, it, TestContext } from 'vitest'; import { AppConfig } from '../config/app-config.service'; @@ -8,6 +10,8 @@ import { AppConfig } from '../config/app-config.service'; import { markdownForDocument } from './export-markdown'; import { PandocServerConverter } from './pandoc.converter'; +const MARKING = classificationMarking('vs_nfd')!; + /** * Export fidelity regression (issue #69, ADR 0009): exports the committed * Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads @@ -70,4 +74,68 @@ describe('export fidelity corpus (real pandoc, issue #69)', () => { }); } } + + // The classified reference documents (issue #209, ADR 0022): a marked + // export must carry the VS-NfD marking in the document's own header/footer + // definition (repeats per page in Word/LibreOffice, not deletable body + // text); an unmarked export must not. Asserted structurally against the + // real pinned pandoc; the body round-trip above stays untouched by the + // reference doc (headers are outside the content pandoc reads back). + it('a marked docx export carries the marking in header1.xml/footer1.xml; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.docx', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.docx')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const parts = unzipSync(new Uint8Array(marked.output)); + const header = strFromU8(parts['word/header1.xml']!); + const footer = strFromU8(parts['word/footer1.xml']!); + expect(header).toContain(MARKING); + expect(footer).toContain(MARKING); + expect(strFromU8(parts['word/document.xml']!)).toContain('headerReference'); + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openParts = unzipSync(new Uint8Array(unmarked.output)); + expect(openParts['word/header1.xml']).toBeUndefined(); + }); + + it('a marked odt export carries the marking in its master-page header/footer; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.odt', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.odt')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const styles = strFromU8(unzipSync(new Uint8Array(marked.output))['styles.xml']!); + expect(styles).toContain(''); + const occurrences = styles.split(MARKING).length - 1; + expect(occurrences).toBeGreaterThanOrEqual(2); // header + footer + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openStyles = strFromU8(unzipSync(new Uint8Array(unmarked.output))['styles.xml']!); + expect(openStyles).not.toContain(MARKING); + }); }); diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index a29e00e..c281d1c 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -31,8 +31,10 @@ const PNG_BASE64 = class RecordingConverter extends PandocConverter { lastInput = ''; + lastReferenceDoc: string | null = null; convert(request: ConversionRequest): Promise { this.lastInput = request.input.toString('utf8'); + this.lastReferenceDoc = request.referenceDoc?.name ?? null; return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' }); } reachable(): Promise { @@ -329,6 +331,30 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { .set('Cookie', ownerCookie) .expect(200); expect(result.text).toBe('OFFICE-BYTES'); + // An unclassified page converts without a reference doc (issue #209). + expect(fake.lastReferenceDoc).toBeNull(); + }); + + it('hands pandoc the classified reference doc for a marked page (issue #209)', async () => { + const slug = await seedPage(personalPondId, 'Classified Docx', '# Classified Docx\n\nbody'); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + await prisma.page.update({ where: { id: page.id }, data: { classification: 'VS_NFD' } }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + await worker.drain(); + + expect(fake.lastReferenceDoc).toBe('reference-vs-nfd.docx'); + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); }); it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index e4b7340..129ba6f 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -186,6 +186,10 @@ export class ExportService { const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); const document = markdownForDocument(markdown, dataUriById); + // A classified page's export records its marking as a job option (#209): + // the worker then hands pandoc the classified reference document whose + // header/footer carry the marking on every page in Word/LibreOffice. + const marking = classificationMarking(page.classification.toLowerCase() as PageClassification); const job = await this.jobs.enqueue({ ownerId: user.id, kind: `export_${format}`, @@ -193,6 +197,7 @@ export class ExportService { to: format, input: Buffer.from(document, 'utf8'), standalone: true, + ...(marking ? { options: { marking } } : {}), }); this.logger.info( { jobId: job.id, pageId, format, userId: user.id }, diff --git a/apps/api/src/import-export/pandoc.converter.ts b/apps/api/src/import-export/pandoc.converter.ts index ab5bc49..4162c71 100644 --- a/apps/api/src/import-export/pandoc.converter.ts +++ b/apps/api/src/import-export/pandoc.converter.ts @@ -17,6 +17,11 @@ export interface ConversionRequest { /** Line-wrapping of the writer's output. Import uses `none` so a paragraph * stays on one line (no soft breaks inside image alt text or links). */ wrap?: 'none' | 'auto' | 'preserve'; + /** Reference document for the docx/odt writers (issue #209, ADR 0022): + * pandoc copies its page setup — including the header/footer that carry + * the VS-NfD marking — into the output. Sent to pandoc-server as an + * in-request file plus the `reference-doc` option. */ + referenceDoc?: { name: string; bytes: Buffer }; } export interface ConversionResult { @@ -141,6 +146,14 @@ export class PandocServerConverter extends PandocConverter { // so these are only present when the import pipeline sets them. ...(request.embedResources ? { 'embed-resources': true } : {}), ...(request.wrap ? { wrap: request.wrap } : {}), + ...(request.referenceDoc + ? { + 'reference-doc': request.referenceDoc.name, + files: { + [request.referenceDoc.name]: request.referenceDoc.bytes.toString('base64'), + }, + } + : {}), }), signal: controller.signal, }); diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 12648d0..e67bbbb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -55,7 +55,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - [x] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 + - [x] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 diff --git a/eslint.config.mjs b/eslint.config.mjs index 8968a3f..77c0a0f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,12 @@ export default tseslint.config( prettier, { // Plain-Node maintenance/build scripts (no TypeScript, no bundler). - files: ['scripts/**/*.mjs', 'deploy/**/*.mjs', 'packages/plugins/*/build.mjs'], + files: [ + 'scripts/**/*.mjs', + 'deploy/**/*.mjs', + 'packages/plugins/*/build.mjs', + 'apps/api/scripts/**/*.mjs', + ], languageOptions: { globals: { console: 'readonly', -- 2.45.2 From 68497046e9ee1704c107514ee5c916997bfde766 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 07:13:53 +0200 Subject: [PATCH 2/2] #210: mark the Markdown ZIP export with frontmatter, imprint and manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A classified page's .md carries the level in YAML frontmatter AND the marking line at top and bottom; unclassified files are byte-identical to before. Every pond archive (incl. the per-pond folders of the account data export) ships a manifest.json listing each file with its level and stating the highest level once at archive level — media inherits the highest classification among the readable pages referencing it (fail-closed). Round trip: the importer recognizes exactly our frontmatter block, strips it plus the imprint lines, and creates the page at least at the imported level (content must not escape its marking by traveling through a ZIP) — pinned by unit and e2e round-trip tests. Foreign frontmatter passes through unchanged; the Obsidian vault import keeps its own frontmatter modes. Co-Authored-By: Claude Fable 5 (1M context) --- .../import-export/classified-markdown.test.ts | 39 +++++++++++ .../src/import-export/classified-markdown.ts | 43 ++++++++++++ .../import-export/export.service.db.test.ts | 70 +++++++++++++++++++ apps/api/src/import-export/export.service.ts | 50 +++++++++++-- apps/api/src/import-export/import.service.ts | 17 ++++- apps/api/src/pages/pages.service.ts | 16 ++++- docs/vs-nfd/20-massnahmenplan.md | 2 +- 7 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/import-export/classified-markdown.test.ts create mode 100644 apps/api/src/import-export/classified-markdown.ts diff --git a/apps/api/src/import-export/classified-markdown.test.ts b/apps/api/src/import-export/classified-markdown.test.ts new file mode 100644 index 0000000..6612fbf --- /dev/null +++ b/apps/api/src/import-export/classified-markdown.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { markClassifiedMarkdown, parseClassifiedMarkdown } from './classified-markdown'; + +const MARKING = 'VS – NUR FÜR DEN DIENSTGEBRAUCH'; + +describe('classified markdown marking (issue #210)', () => { + it('wraps a classified page in frontmatter and top+bottom imprint', () => { + const marked = markClassifiedMarkdown('# Title\n\nBody.\n', 'vs_nfd'); + expect(marked).toBe( + `---\nclassification: vs_nfd\n---\n\n${MARKING}\n\n# Title\n\nBody.\n\n${MARKING}\n`, + ); + }); + + it('leaves unclassified markdown untouched', () => { + expect(markClassifiedMarkdown('# Title\n\nBody.\n', 'unclassified')).toBe('# Title\n\nBody.\n'); + }); + + it('parse is the inverse of mark', () => { + const original = '# Title\n\nBody.\n'; + const { markdown, classification } = parseClassifiedMarkdown( + markClassifiedMarkdown(original, 'vs_nfd'), + ); + expect(classification).toBe('vs_nfd'); + expect(markdown).toBe(original); + }); + + it('passes documents without our frontmatter through unchanged', () => { + for (const raw of [ + '# Plain\n\nNo frontmatter.\n', + '---\ntitle: Foreign frontmatter\ntags: [a]\n---\n\n# Doc\n', + `${MARKING}\n\nJust an imprint line without frontmatter.\n`, + ]) { + const { markdown, classification } = parseClassifiedMarkdown(raw); + expect(classification).toBeNull(); + expect(markdown).toBe(raw); + } + }); +}); diff --git a/apps/api/src/import-export/classified-markdown.ts b/apps/api/src/import-export/classified-markdown.ts new file mode 100644 index 0000000..9db6774 --- /dev/null +++ b/apps/api/src/import-export/classified-markdown.ts @@ -0,0 +1,43 @@ +import { PageClassification, classificationMarking } from '@dorfteich/shared'; + +/** + * VS-NfD marking of exported Markdown (issue #210, ADR 0022): a classified + * page's `.md` carries the level machine-readably in YAML frontmatter AND + * human-visibly as the marking line at the top and bottom of the file. + * Unclassified pages pass through untouched — no marking, no frontmatter. + */ +export function markClassifiedMarkdown( + markdown: string, + classification: PageClassification, +): string { + const marking = classificationMarking(classification); + if (!marking) return markdown; + return `---\nclassification: ${classification}\n---\n\n${marking}\n\n${markdown.trimEnd()}\n\n${marking}\n`; +} + +/** + * Inverse of {@link markClassifiedMarkdown} for the import side: recognizes + * exactly the frontmatter block we generate (a lone `classification:` key) + * and the marking lines around the body, so a round-trip re-import yields + * the original content — and the page starts at the imported level (content + * must not escape its marking by traveling through a ZIP). Anything else — + * foreign frontmatter, hand-written documents — passes through unchanged. + */ +export function parseClassifiedMarkdown(raw: string): { + markdown: string; + classification: PageClassification | null; +} { + const match = raw.match(/^---\nclassification: (vs_nfd|unclassified)\n---\n\n/); + if (!match) return { markdown: raw, classification: null }; + const classification = match[1] as PageClassification; + let body = raw.slice(match[0].length); + const marking = classificationMarking(classification); + if (marking) { + if (body.startsWith(`${marking}\n\n`)) body = body.slice(marking.length + 2); + const trimmed = body.trimEnd(); + if (trimmed.endsWith(`\n\n${marking}`)) { + body = `${trimmed.slice(0, -(marking.length + 2)).trimEnd()}\n`; + } + } + return { markdown: body, classification }; +} diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index c281d1c..c481bae 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -193,6 +193,76 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect(target).toContain(`![dot](media/${image.id}.png)`); }); + it('marks classified pages in the pond ZIP with frontmatter+imprint, ships a manifest, and round-trips (#210)', async () => { + const marking = 'VS – NUR FÜR DEN DIENSTGEBRAUCH'; + const classifiedSlug = await seedPage( + personalPondId, + 'Zip Classified', + '# Zip Classified\n\nclassified body text', + ); + const openSlug = await seedPage(personalPondId, 'Zip Open', '# Zip Open\n\nopen body text'); + await prisma.page.updateMany({ + where: { pondId: personalPondId, slug: classifiedSlug }, + data: { classification: 'VS_NFD' }, + }); + + const res = await api() + .get(`/api/v1/ponds/${personalPondId}/export/markdown`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + const entries = zipEntries(res.body as Buffer); + + // Machine-readable frontmatter AND the visible imprint, top and bottom. + const marked = Buffer.from(entries[`${classifiedSlug}.md`]!).toString('utf8'); + expect(marked.startsWith(`---\nclassification: vs_nfd\n---\n\n${marking}\n\n`)).toBe(true); + expect(marked.trimEnd().endsWith(marking)).toBe(true); + // Unclassified files are unchanged: no frontmatter, no imprint. + const open = Buffer.from(entries[`${openSlug}.md`]!).toString('utf8'); + expect(open).not.toContain('classification:'); + expect(open).not.toContain(marking); + + // The manifest lists every file with its level and states the highest once. + const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as { + classification: string; + files: { path: string; classification: string }[]; + }; + expect(manifest.classification).toBe('vs_nfd'); + expect(manifest.files).toContainEqual({ + path: `${classifiedSlug}.md`, + classification: 'vs_nfd', + }); + expect(manifest.files).toContainEqual({ + path: `${openSlug}.md`, + classification: 'unclassified', + }); + + // Round-trip: re-importing the marked file must not confuse the importer — + // the page starts at the imported level, the body carries neither the + // frontmatter nor the imprint lines. + const imported = await api() + .post(`/api/v1/ponds/${personalPondId}/import`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from(marked, 'utf8'), 'reimported-classified.md') + .expect(201); + expect(imported.body.status).toBe('succeeded'); + const reimported = await prisma.page.findUniqueOrThrow({ + where: { id: imported.body.resultPageId as string }, + }); + expect(reimported.classification).toBe('VS_NFD'); + const cache = await prisma.pageContentCache.findUniqueOrThrow({ + where: { pageId: reimported.id }, + }); + expect(cache.markdown).toContain('classified body text'); + expect(cache.markdown).not.toContain(marking); + expect(cache.markdown).not.toContain('classification:'); + }); + it('skips an attachment whose bytes are missing on disk instead of crashing', async () => { // An attachment row with no file (data drift): upload then remove the bytes. const image = await files.upload({ id: ownerId } as never, personalPondId, { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index 129ba6f..94d4041 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -9,6 +9,8 @@ import { fontSlug, PageClassification, classificationMarking, + classificationRank, + highestClassification, pondSettingsSchema, } from '@dorfteich/shared'; import { User } from '@prisma/client'; @@ -23,6 +25,7 @@ import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; +import { markClassifiedMarkdown } from './classified-markdown'; import { ConversionJobService } from './conversion-job.service'; import { imageExtension, @@ -133,15 +136,54 @@ export class ExportService { attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]), ); + // Media inherits the highest classification among the readable pages that + // reference it (fail-closed, ADR 0022 — a shared image is as classified + // as its most classified use). + const mediaClassification = new Map(); for (const page of readablePages) { - const markdown = markdownForZip( - page.contentCache?.markdown ?? '', - readableSlugs, - mediaNameById, + const level = page.classification.toLowerCase() as PageClassification; + for (const id of imageFileIds(page.contentCache?.markdown ?? '')) { + const current = mediaClassification.get(id) ?? 'unclassified'; + if (classificationRank(level) > classificationRank(current)) { + mediaClassification.set(id, level); + } + } + } + + const manifestFiles: { path: string; classification: PageClassification }[] = []; + for (const page of readablePages) { + const level = page.classification.toLowerCase() as PageClassification; + // A classified page's file carries the level in YAML frontmatter and + // the marking line at top and bottom (#210); unclassified files are + // byte-identical to the pre-#210 export. + const markdown = markClassifiedMarkdown( + markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaNameById), + level, ); // Page slugs are unique within a pond, so `.md` never collides. archive.append(markdown, { name: `${prefix}${page.slug}.md` }); + manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level }); } + for (const attachment of attachments) { + manifestFiles.push({ + path: `${prefix}media/${mediaNameById.get(attachment.id)!}`, + classification: mediaClassification.get(attachment.id) ?? 'unclassified', + }); + } + // The archive-level manifest (#210): every file with its level, and the + // highest level contained stated once — the bulk-egress channel stays + // machine-checkable even after the ZIP is unpacked and copied onward. + archive.append( + JSON.stringify( + { + classification: highestClassification(manifestFiles.map((f) => f.classification)), + files: manifestFiles, + }, + null, + 2, + ), + { name: `${prefix}manifest.json` }, + ); for (const attachment of attachments) { const stream = this.storage.createReadStream(pond.id, attachment.id); // Defence in depth: a file removed between the existence check and the diff --git a/apps/api/src/import-export/import.service.ts b/apps/api/src/import-export/import.service.ts index 48918a2..4b9135a 100644 --- a/apps/api/src/import-export/import.service.ts +++ b/apps/api/src/import-export/import.service.ts @@ -24,6 +24,7 @@ import { PagesService } from '../pages/pages.service'; import { docToState, emptyPageState } from '../pages/yjs-content'; import { PrismaService } from '../prisma/prisma.service'; +import { parseClassifiedMarkdown } from './classified-markdown'; import { ImportProcessor } from './import.constants'; import { ASSET_PLACEHOLDER_PREFIX, @@ -544,12 +545,24 @@ export class ImportService implements ImportProcessor { // file ids); track what we create so a later failure can be rolled back. const storedFileIds: string[] = []; try { - const markdown = await this.storeEmbeddedImages(rawMarkdown, user, pondId, storedFileIds); + // Our own classified export wraps the content in frontmatter + marking + // lines (#210) — strip them and carry the level into the new page, so + // a round-trip neither duplicates the marking nor loses it. + const { markdown: unwrapped, classification } = parseClassifiedMarkdown(rawMarkdown); + const markdown = await this.storeEmbeddedImages(unwrapped, user, pondId, storedFileIds); const json = markdownToDoc(markdown).toJSON() as unknown as PmNode; const { title, doc } = this.splitTitle(json, sourceName); const state = docToState(Node.fromJSON(editorSchema, doc)); - const page = await this.pages.createWithState(user, pondId, title, state); + const page = await this.pages.createWithState( + user, + pondId, + title, + state, + null, + undefined, + classification, + ); await this.files.linkAttachmentsToPage(storedFileIds, page.id); return page; } catch (error) { diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 6020346..2e77f6e 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -274,8 +274,9 @@ export class PagesService { state: Uint8Array, parentId: string | null = null, presetSlug?: string, + atLeastClassification: PageClassification | null = null, ): Promise { - return this.insertPage(user, pondId, title, state, parentId, presetSlug); + return this.insertPage(user, pondId, title, state, parentId, presetSlug, atLeastClassification); } /** @@ -300,6 +301,7 @@ export class PagesService { state: Uint8Array, parentId: string | null = null, presetSlug?: string, + atLeastClassification: PageClassification | null = null, ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); @@ -316,11 +318,19 @@ export class PagesService { const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const content = deriveContent(state); // A new page starts at the instance-wide default level (ADR 0022, #204), - // raised to its parent's level when that is higher (#205): a subpage of - // classified content must never begin unmarked. + // raised to its parent's level when that is higher (#205) — and to an + // imported document's own level (#210): content must not escape its + // marking by traveling through an export/import. A subpage of classified + // content must never begin unmarked. let classification: PageClassification = await this.settings.get( 'classification.newPageDefault', ); + if ( + atLeastClassification && + classificationRank(atLeastClassification) > classificationRank(classification) + ) { + classification = atLeastClassification; + } if (parentId) { const parent = await this.prisma.page.findUniqueOrThrow({ where: { id: parentId }, diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index e67bbbb..9b36183 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -56,7 +56,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - [x] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - [x] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 + - [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 -- 2.45.2