From e505fc74dc5cb407f989aab1a87dc8f6f72bab10 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 07:29:16 +0200 Subject: [PATCH] #212: mark attachment downloads by filename prefix and companion file Downloads whose effective classification is vs_nfd carry the documented VS-NfD_ filename prefix (single source classificationFilenamePrefix() in shared; ADR 0022 records the short form for file names). Effective classification: the linked page's level; an attachment with unset pageId (paste-then-insert, pond-level) FAILS CLOSED to the highest level of any live page in its pond. The pond export ZIP adds a sibling .classification.txt companion with the full marking for classified media, next to the manifest entry (#210). Documented in operations.md, incl. the deliberate residual risk: the file's own content carries no marking (recorded on #231, not hidden). Tests: prefixed classified download, unchanged open download, fail-closed orphan both ways, ZIP companion + manifest level. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/src/files/files.controller.ts | 7 +- apps/api/src/files/files.e2e.db.test.ts | 67 +++++++++++++++++++ apps/api/src/files/files.service.ts | 34 ++++++++++ .../import-export/export.service.db.test.ts | 47 +++++++++++++ apps/api/src/import-export/export.service.ts | 12 +++- .../adr/0022-page-classification.md | 5 +- docs/architecture/operations.md | 22 ++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/src/pages.ts | 10 +++ 9 files changed, 201 insertions(+), 5 deletions(-) diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts index 1df84b3..d9d6683 100644 --- a/apps/api/src/files/files.controller.ts +++ b/apps/api/src/files/files.controller.ts @@ -90,14 +90,17 @@ export class FilesController { @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { - const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId); + const { attachment, stream, inline, downloadName } = await this.files.download( + request.user ?? null, + fileId, + ); response.set('X-Content-Type-Options', 'nosniff'); // Attachments are immutable — a new upload always gets a new id. response.set('Cache-Control', 'private, max-age=31536000, immutable'); const kind = inline ? 'inline' : 'attachment'; return new StreamableFile(stream, { type: attachment.mimeType, - disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`, + disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`, }); } diff --git a/apps/api/src/files/files.e2e.db.test.ts b/apps/api/src/files/files.e2e.db.test.ts index 1e5fe07..d29527f 100644 --- a/apps/api/src/files/files.e2e.db.test.ts +++ b/apps/api/src/files/files.e2e.db.test.ts @@ -327,6 +327,73 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => { expect(item.pageTitle).toBe(`Page Files ${suffix}`); }); + it('prefixes downloads of classified attachments; unset pageId fails closed (issue #212)', async () => { + const page = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Classified Files ${suffix}` }) + .expect(201); + + const uploaded = await api() + .post(`/api/v1/pages/${page.body.id}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('%PDF-1.4 classified content'), 'geheim.pdf') + .expect(201); + + // Unclassified page: unchanged filename. + const openServed = await api() + .get(`/api/v1/media/${uploaded.body.id}`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse(binaryParser as unknown as ParseCallback) + .expect(200); + expect(openServed.headers['content-disposition']).toContain('filename="geheim.pdf"'); + + // Classified page: the documented VS-NfD_ prefix. + await prisma.page.update({ + where: { id: page.body.id as string }, + data: { classification: 'VS_NFD' }, + }); + const served = await api() + .get(`/api/v1/media/${uploaded.body.id}`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse(binaryParser as unknown as ParseCallback) + .expect(200); + expect(served.headers['content-disposition']).toContain('filename="VS-NfD_geheim.pdf"'); + + // pageId unset (paste-then-insert): fails closed to the pond's highest + // level — the pond now contains a classified page, so the orphan upload + // is served with the prefix too. + const orphan = await api() + .post(`/api/v1/ponds/${pondId}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('%PDF-1.4 orphan bytes'), 'lose-datei.pdf') + .expect(201); + const orphanServed = await api() + .get(`/api/v1/media/${orphan.body.id}`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse(binaryParser as unknown as ParseCallback) + .expect(200); + expect(orphanServed.headers['content-disposition']).toContain( + 'filename="VS-NfD_lose-datei.pdf"', + ); + + // Back to all-open: the orphan serves unprefixed again. + await prisma.page.update({ + where: { id: page.body.id as string }, + data: { classification: 'UNCLASSIFIED' }, + }); + const openOrphan = await api() + .get(`/api/v1/media/${orphan.body.id}`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse(binaryParser as unknown as ParseCallback) + .expect(200); + expect(openOrphan.headers['content-disposition']).toContain('filename="lose-datei.pdf"'); + }); + it('pond file manager reports usage, orphans, and page links (#61)', async () => { const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 5e502c3..7adff61 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -13,7 +13,9 @@ import { AttachmentListItemView, AttachmentView, PondFilesView, + PageClassification, SVG_MIME_TYPE, + classificationFilenamePrefix, fileExtension, isImageMimeType, } from '@dorfteich/shared'; @@ -36,6 +38,11 @@ export interface FileDownload { * else — office files, PDFs, and SVG — is always sent as a download so it * can never execute inline (ADR 0011, security.md §Uploads). */ inline: boolean; + /** The filename for the Content-Disposition (issue #212, ADR 0022): the + * original name, prefixed `VS-NfD_` when the attachment's effective + * classification is vs_nfd — the one marker an arbitrary binary can + * carry. The file's CONTENT stays unmarked (documented residual risk). */ + downloadName: string; } /** What the upload bytes resolved to after allowlist + SVG handling. */ @@ -229,13 +236,40 @@ export class FilesService { throw new InternalServerErrorException({ code: 'attachment_integrity_failure' }); } } + const classification = await this.effectiveClassification(attachment); return { attachment, stream: Readable.from(buffer), inline: isImageMimeType(attachment.mimeType), + downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`, }; } + /** + * The classification an attachment inherits (issue #212, ADR 0022): its + * page's level. An attachment whose `pageId` is still unset + * (paste-then-insert, pond-level files) FAILS CLOSED to the highest level + * of any live page in its pond — it could belong to any of them, so it is + * treated as classified as the most classified candidate. In an all-open + * pond that is `unclassified`, so nothing gets marked noise. + */ + private async effectiveClassification(attachment: Attachment): Promise { + if (attachment.pageId) { + const page = await this.prisma.page.findUnique({ + where: { id: attachment.pageId }, + select: { classification: true }, + }); + if (page) return page.classification.toLowerCase() as PageClassification; + // Page row gone but link set (race with purge): fall through to the + // pond-wide fail-closed answer below. + } + const classified = await this.prisma.page.findFirst({ + where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' }, + select: { id: true }, + }); + return classified ? 'vs_nfd' : 'unclassified'; + } + /** * Hash attachments that predate #199 (sha256 null), a bounded batch per * nightly run until none remain — idempotent by construction (hashed rows 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 c481bae..85a440a 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -263,6 +263,53 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect(cache.markdown).not.toContain('classification:'); }); + it('adds a classification companion for classified media in the ZIP (issue #212)', async () => { + const image = await files.upload({ id: ownerId } as never, personalPondId, { + buffer: Buffer.from(PNG_BASE64, 'base64'), + size: 70, + originalname: 'secret-dot.png', + }); + const slug = await seedPage( + personalPondId, + 'Zip Media Classified', + `# Zip Media Classified\n\n![dot](${image.id})`, + ); + await prisma.page.updateMany({ + where: { pondId: personalPondId, slug }, + 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); + + // Media inherits the highest referencing page's level: sibling companion + // carries the full marking; the manifest lists the media file's level. + const companion = entries[`media/${image.id}.png.classification.txt`]; + expect(companion).toBeDefined(); + expect(Buffer.from(companion!).toString('utf8')).toContain('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as { + files: { path: string; classification: string }[]; + }; + expect(manifest.files).toContainEqual({ + path: `media/${image.id}.png`, + classification: 'vs_nfd', + }); + + await prisma.page.updateMany({ + where: { pondId: personalPondId, slug }, + data: { classification: 'UNCLASSIFIED' }, + }); + }); + 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 94d4041..31053b7 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -165,10 +165,20 @@ export class ExportService { manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level }); } for (const attachment of attachments) { + const mediaLevel = mediaClassification.get(attachment.id) ?? 'unclassified'; manifestFiles.push({ path: `${prefix}media/${mediaNameById.get(attachment.id)!}`, - classification: mediaClassification.get(attachment.id) ?? 'unclassified', + classification: mediaLevel, }); + // Companion file for classified media (issue #212): the binary itself + // cannot carry the marking, so a sibling text file states it — it + // survives unpacking and copying, where the manifest may be dropped. + const mediaMarking = classificationMarking(mediaLevel); + if (mediaMarking) { + archive.append(`${mediaMarking}\n`, { + name: `${prefix}media/${mediaNameById.get(attachment.id)!}.classification.txt`, + }); + } } // The archive-level manifest (#210): every file with its level, and the // highest level contained stated once — the bulk-egress channel stays diff --git a/docs/architecture/adr/0022-page-classification.md b/docs/architecture/adr/0022-page-classification.md index 5ba8a03..cc18c13 100644 --- a/docs/architecture/adr/0022-page-classification.md +++ b/docs/architecture/adr/0022-page-classification.md @@ -62,7 +62,10 @@ guardrails). Separation is a platform property. labels _around_ it (e.g. an accessibility label naming the element) are i18n'd. The single source is `classificationMarking()` in `@dorfteich/shared` (`packages/shared/src/pages.ts`); no output channel - hard-codes the string. + hard-codes the string. Where a full wording cannot live — file NAMES of + attachment downloads (#212) — the established short form `VS-NfD` is + used as the prefix `VS-NfD_`, single source + `classificationFilenamePrefix()` in the same module. ## Consequences diff --git a/docs/architecture/operations.md b/docs/architecture/operations.md index ad06098..015b80c 100644 --- a/docs/architecture/operations.md +++ b/docs/architecture/operations.md @@ -192,3 +192,25 @@ not a copy of the purged page. | max upload size | 25 MiB (quota ladder, ADR 0011) | | collab connections per instance | 500 concurrent | | rate limits | login 10/min/IP, signup 5/h/IP, API 100/min/user | + +## Classified attachment downloads (issue #212, ADR 0022) + +An attachment is an opaque binary — the application cannot write the +VS-NfD marking into arbitrary file formats. The marking therefore lives +**around** the file: + +- **Filename prefix `VS-NfD_`** on every download whose effective + classification is `vs_nfd` (single source: + `classificationFilenamePrefix()` in `@dorfteich/shared`). +- **Effective classification**: the linked page's level. An attachment + whose `pageId` is not (yet) set — paste-then-insert, pond-level files — + **fails closed** to the highest level of any live page in its pond. +- **Containing archive**: the pond export ZIP states each media file's + level in `manifest.json` and adds a sibling + `.classification.txt` companion carrying the full marking for + classified media. + +Residual risk, deliberately documented rather than hidden (recorded on +issue #231): the file's own **content** carries no marking — a user who +renames the file has an unmarked classified binary. Marking file contents +would require rewriting arbitrary formats, which ADR 0019 rules out. diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index bd929b7..ee91ef2 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -58,7 +58,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 + - [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 ### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index f1d03a1..0e76f27 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -26,6 +26,16 @@ export function classificationMarking(classification: PageClassification): strin return classification === 'vs_nfd' ? 'VS – NUR FÜR DEN DIENSTGEBRAUCH' : null; } +/** + * File-name-safe short marker for downloads (issue #212, ADR 0022): an + * arbitrary binary cannot carry the marking inside, so its NAME does. The + * established short form of the German marking is `VS-NfD`; the full + * wording stays the on-screen/companion form. Empty for unclassified. + */ +export function classificationFilenamePrefix(classification: PageClassification): string { + return classification === 'vs_nfd' ? 'VS-NfD_' : ''; +} + /** Ordering of levels: the index in {@link PAGE_CLASSIFICATIONS} (lowest * first) — the tree invariant (#205) and archive-level statements (#210) * compare through this, never through string comparison. */ -- 2.45.2