#213: upload warning/block for classified pages #272
@ -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`)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user