#213: warn on uploads to classified pages; instance policy can block
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m15s
CI / Build container images (pull_request) Successful in 4m27s
CI / Auth e2e pack (pull_request) Successful in 9m10s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 20s
CI / Lint, typecheck, test (push) Successful in 5m47s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m26s
CI / Import/export fidelity gate (push) Successful in 1m0s

The attachments panel of a classified page shows a persistent notice
naming the consequence (de+en): the file inherits the page's
classification but its content carries no marking (#212). The new
instance setting classification.uploadPolicy (default warn, documented;
the VS-NfD reference configuration blocks, #227) hardens the warning
into a server-side rejection (403 classified_upload_blocked) — enforced
in the upload service, not only in the UI. Tests: warning visible in the
local attachments pack; block enforced server-side with warn/block both
ways and open pages unaffected.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-31 07:33:34 +02:00
parent e505fc74dc
commit 868b79c8bc
15 changed files with 148 additions and 10 deletions

View File

@ -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`)

View File

@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
import {
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
@ -205,6 +206,16 @@ export class FilesService {
): Promise<AttachmentView> {
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);
}

View File

@ -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

View File

@ -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();
});

View File

@ -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({
<FormError error={error} />
{canEdit && classification === 'vs_nfd' && (
<p className="attachments-panel__warning" role="note">
{t('classifiedUploadWarning')}
</p>
)}
{canEdit && (
<div className="attachments-panel__upload">
<input

View File

@ -29,6 +29,7 @@ interface InstanceSettings {
'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize';
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
'classification.uploadPolicy': 'warn' | 'block';
'legal.imprint': string;
'legal.privacyPolicy': string;
'home.content': string;
@ -101,6 +102,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select>
</Field>
<Field
label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')}
>
<select {...form.register('classification.uploadPolicy')}>
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:admin.save')}
</button>

View File

@ -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,

View File

@ -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;

View File

@ -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 · 23 AT · #211
- [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 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 · 810 AT ⟵ neu aus Roadmap

View File

@ -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."
}

View File

@ -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)."
}

View File

@ -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",

View File

@ -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."
}

View File

@ -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)."
}

View File

@ -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 environments 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 pages 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",