diff --git a/apps/api/src/files/files.e2e.db.test.ts b/apps/api/src/files/files.e2e.db.test.ts index d29527f..be3cab9 100644 --- a/apps/api/src/files/files.e2e.db.test.ts +++ b/apps/api/src/files/files.e2e.db.test.ts @@ -394,6 +394,54 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => { expect(openOrphan.headers['content-disposition']).toContain('filename="lose-datei.pdf"'); }); + it('blocks uploads to classified pages server-side when the policy says so (issue #213)', async () => { + const settings = app.get(InstanceSettingsService); + const page = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Blocked Uploads ${suffix}` }) + .expect(201); + await prisma.page.update({ + where: { id: page.body.id as string }, + data: { classification: 'VS_NFD' }, + }); + + // Default policy `warn`: the upload is allowed (the UI shows the notice). + await api() + .post(`/api/v1/pages/${page.body.id}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('%PDF-1.4 warned upload'), 'warned.pdf') + .expect(201); + + await settings.set('classification.uploadPolicy', 'block', 'test'); + try { + // Enforced server-side, not only in the UI. + const blocked = await api() + .post(`/api/v1/pages/${page.body.id}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('%PDF-1.4 blocked upload'), 'blocked.pdf') + .expect(403); + expect((blocked.body as { code: string }).code).toBe('classified_upload_blocked'); + + // Unclassified pages stay uploadable under `block`. + const open = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Open Uploads ${suffix}` }) + .expect(201); + await api() + .post(`/api/v1/pages/${open.body.id}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('%PDF-1.4 open upload'), 'open.pdf') + .expect(201); + } finally { + await settings.set('classification.uploadPolicy', 'warn', 'test'); + await prisma.instanceSetting.deleteMany({ + where: { key: 'classification.uploadPolicy' }, + }); + } + }); + 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 7adff61..2be721e 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -3,6 +3,7 @@ import { Readable } from 'node:stream'; import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, @@ -205,6 +206,16 @@ export class FilesService { ): Promise { const page = await this.prisma.page.findFirst({ where: { id: pageId } }); if (!page) throw new NotFoundException(); + // Attaching to a classified page (issue #213, ADR 0022): the file will + // inherit a classification its content cannot carry (#212). The UI warns; + // the instance can harden the warning into a server-side block — enforced + // HERE, not only client-side. + if (page.classification === 'VS_NFD') { + const policy = await this.settings.get('classification.uploadPolicy'); + if (policy === 'block') { + throw new ForbiddenException({ code: 'classified_upload_blocked' }); + } + } return this.upload(user, page.pondId, file, page.id); } diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 67f2571..d9aa7f7 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -57,6 +57,11 @@ export const INSTANCE_SETTINGS = { // parent page (#205) wins over this default. The marking is not a // protection mechanism — permissions ignore it. 'classification.newPageDefault': z.enum(['unclassified', 'vs_nfd']).default('unclassified'), + // Attaching files to a classified page (issue #213, ADR 0022): the UI + // always warns (the file inherits a classification its content cannot + // carry, #212); `block` hardens the warning into a server-side rejection. + // Default `warn` — blocking is the reference-configuration choice (#227). + 'classification.uploadPolicy': z.enum(['warn', 'block']).default('warn'), // Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions // without the dot. Images are always allowed regardless; SVG is governed // by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so diff --git a/apps/web/e2e/attachments.spec.ts b/apps/web/e2e/attachments.spec.ts index d237726..455973d 100644 --- a/apps/web/e2e/attachments.spec.ts +++ b/apps/web/e2e/attachments.spec.ts @@ -109,3 +109,19 @@ test('pond file manager shows usage and flags an orphan (Pond Admin)', async ({ await context.close(); }); + +test('shows the classified-upload warning on a classified page (issue #213)', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const page = await context.newPage(); + await page.goto('/p/content-fixtures/classified-note'); + await openAttachments(page); + // The persistent warning names the consequence; the wording is the fixed + // marking formula (ADR 0022), not localized. + await expect(page.locator('.attachments-panel__warning')).toBeVisible(); + await expect(page.locator('.attachments-panel__warning')).toContainText( + 'VS – NUR FÜR DEN DIENSTGEBRAUCH', + ); + await context.close(); +}); diff --git a/apps/web/src/files/AttachmentsPanel.tsx b/apps/web/src/files/AttachmentsPanel.tsx index 8ba21d6..b928cb8 100644 --- a/apps/web/src/files/AttachmentsPanel.tsx +++ b/apps/web/src/files/AttachmentsPanel.tsx @@ -1,4 +1,4 @@ -import type { AttachmentListItemView } from '@dorfteich/shared'; +import type { AttachmentListItemView, PageClassification } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { Editor } from '@tiptap/react'; import { useRef, useState } from 'react'; @@ -19,11 +19,16 @@ export function AttachmentsPanel({ pageId, editor, canEdit, + classification, onClose, }: { pageId: string; editor: Editor | null; canEdit: boolean; + /** VS-NfD level of the page (issue #213): a classified page shows the + * upload warning naming the consequence — attachments inherit a level + * their content cannot carry (#212). */ + classification?: PageClassification; onClose: () => void; }): React.JSX.Element { const { t } = useTranslation('files'); @@ -96,6 +101,12 @@ export function AttachmentsPanel({ + {canEdit && classification === 'vs_nfd' && ( +

+ {t('classifiedUploadWarning')} +

+ )} + {canEdit && (
{t('settings:admin.classificationVsNfd')} + + + diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 573e7e2..3fbdd8f 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -1,5 +1,10 @@ import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared'; -import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; +import type { + PageClassification, + PageListItemView, + PageStateView, + PondView, +} from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; @@ -126,6 +131,8 @@ interface ResolvedPage { pondId: string; slug: string; title: string; + /** VS-NfD level (issue #213); undefined for the offline-cache fallback. */ + classification?: PageClassification; } function PageEditor({ @@ -305,6 +312,7 @@ function PageEditor({ pageId={page.id} editor={editor} canEdit={canEdit} + classification={page.classification} onClose={onCloseAttachments} /> )} @@ -421,7 +429,13 @@ export function PageEditorPage(): React.JSX.Element { const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null; const resolved: ResolvedPage | null = page.data - ? { id: page.data.id, pondId: page.data.pondId, slug: page.data.slug, title: page.data.title } + ? { + id: page.data.id, + pondId: page.data.pondId, + slug: page.data.slug, + title: page.data.title, + classification: page.data.classification, + } : offlineCached ? { id: offlineCached.pageId, diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 1e5fadc..eee2963 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3071,6 +3071,17 @@ ul[data-type='task_list'] li p:last-of-type { margin: 0; } +/* Upload warning on classified pages (issue #213, ADR 0022) — a persistent + note, not a dismissable toast: the consequence applies to every upload. */ +.attachments-panel__warning { + margin: 0 0 var(--space-2); + padding: var(--space-2); + border: 1px solid var(--color-border); + border-left: 3px solid currentColor; + font-size: 0.85rem; + color: var(--color-text); +} + .attachments-panel__upload { display: flex; align-items: center; diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index ee91ef2..d83c10b 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -59,7 +59,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 -- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 +- [x] 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/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 03d1b62..bd32ec2 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -110,5 +110,6 @@ "backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.", "scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", "pond_not_found": "Der Teich existiert nicht.", - "classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich)." + "classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).", + "classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert." } diff --git a/packages/shared/i18n/de/files.json b/packages/shared/i18n/de/files.json index e39f374..735e498 100644 --- a/packages/shared/i18n/de/files.json +++ b/packages/shared/i18n/de/files.json @@ -19,5 +19,6 @@ "svgSanitize": "Bereinigen (Skripte entfernen)", "svgReject": "Ablehnen", "save": "Upload-Einstellungen speichern" - } + }, + "classifiedUploadWarning": "Diese Seite ist als „VS – NUR FÜR DEN DIENSTGEBRAUCH\" eingestuft. Angehängte Dateien erben die Einstufung; die Datei selbst trägt im Inhalt keine Kennzeichnung (nur Dateinamens-Präfix und Begleitdatei beim Download)." } diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index 37ec6b6..630d92e 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -52,7 +52,11 @@ "newPageClassification": "Einstufung neuer Seiten", "newPageClassificationHelp": "Standard-Einstufung (VS-NfD-Kennzeichnung) für neu angelegte Seiten. Die Kennzeichnung ist keine Zugriffskontrolle; die Trennung von Einstufungsniveaus leistet die Umgebung (eine Instanz je Niveau).", "classificationUnclassified": "Offen — keine Kennzeichnung", - "classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH" + "classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH", + "uploadPolicy": "Datei-Uploads auf eingestufte Seiten", + "uploadPolicyHelp": "Anhänge erben die Einstufung der Seite, tragen selbst aber keine Kennzeichnung im Inhalt. „Blockieren\" lehnt Uploads auf eingestufte Seiten serverseitig ab.", + "uploadPolicyWarn": "Warnen — Upload mit deutlichem Hinweis erlauben", + "uploadPolicyBlock": "Blockieren — Uploads auf eingestufte Seiten ablehnen" }, "landing": { "title": "Startseite", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index b422c57..4d4c07c 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -110,5 +110,6 @@ "backup_set_not_found": "The selected backup set was not found.", "scope_required": "This API token does not have the required scope.", "pond_not_found": "The pond does not exist.", - "classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required)." + "classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).", + "classified_upload_blocked": "Uploads to classified pages are blocked on this instance." } diff --git a/packages/shared/i18n/en/files.json b/packages/shared/i18n/en/files.json index 2d33f97..c1f775d 100644 --- a/packages/shared/i18n/en/files.json +++ b/packages/shared/i18n/en/files.json @@ -19,5 +19,6 @@ "svgSanitize": "Sanitize (strip scripts)", "svgReject": "Reject", "save": "Save upload settings" - } + }, + "classifiedUploadWarning": "This page is classified “VS – NUR FÜR DEN DIENSTGEBRAUCH”. Attached files inherit the classification; the file content itself carries no marking (only the filename prefix and companion file on download)." } diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index ec53831..b84f7ea 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -52,7 +52,11 @@ "newPageClassification": "Classification of new pages", "newPageClassificationHelp": "Default classification (VS-NfD marking) for newly created pages. The marking is not access control; separating classification levels is the environment’s job (one instance per level).", "classificationUnclassified": "Open — no marking", - "classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH" + "classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH", + "uploadPolicy": "File uploads to classified pages", + "uploadPolicyHelp": "Attachments inherit the page’s classification but carry no marking in their content. “Block” rejects uploads to classified pages server-side.", + "uploadPolicyWarn": "Warn — allow the upload with a clear notice", + "uploadPolicyBlock": "Block — reject uploads to classified pages" }, "landing": { "title": "Landing page",