Compare commits
7 Commits
7fe3ffd936
...
87c1c5ee88
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c1c5ee88 | |||
| 2c6eff85f3 | |||
| 040f3fbeae | |||
| f0c6af4412 | |||
| 868b79c8bc | |||
| e505fc74dc | |||
| 521ea514b4 |
@ -674,13 +674,16 @@ jobs:
|
||||
# image has no iproute2). Sharing the netns means no published ports.
|
||||
- name: Start pinned pandoc + Gotenberg sidecars
|
||||
run: |
|
||||
# Clear any leftovers from an earlier interrupted run so the named
|
||||
# containers never collide, and nothing leaks on the shared host.
|
||||
docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
||||
# Sidecar names carry THIS job container's id: parallel runs on the
|
||||
# shared host must not collide on a fixed name (a fixed-name rm -f
|
||||
# here even killed a sibling run's live sidecars — run 547).
|
||||
JOB_ID=$(cat /etc/hostname)
|
||||
docker run -d --name fidelity-pandoc \
|
||||
echo "PANDOC_NAME=fidelity-pandoc-${JOB_ID}" >> "$GITHUB_ENV"
|
||||
echo "GOTENBERG_NAME=fidelity-gotenberg-${JOB_ID}" >> "$GITHUB_ENV"
|
||||
docker rm -f "fidelity-pandoc-${JOB_ID}" "fidelity-gotenberg-${JOB_ID}" 2>/dev/null || true
|
||||
docker run -d --name "fidelity-pandoc-${JOB_ID}" \
|
||||
--network "container:${JOB_ID}" pandoc/core:3.6 server
|
||||
docker run -d --name fidelity-gotenberg \
|
||||
docker run -d --name "fidelity-gotenberg-${JOB_ID}" \
|
||||
--network "container:${JOB_ID}" gotenberg/gotenberg:8
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:3030/version >/dev/null && break
|
||||
@ -704,15 +707,15 @@ jobs:
|
||||
- name: Dump sidecar logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
|
||||
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
|
||||
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true
|
||||
echo '--- gotenberg ---'; docker logs "$GOTENBERG_NAME" 2>&1 | tail -30 || true
|
||||
|
||||
# Always tear the sidecars down — they run on the shared runner host, so a
|
||||
# leaked (especially Chromium-backed Gotenberg) container would waste its
|
||||
# memory until the next run and break re-runs on the container name.
|
||||
# memory until the next run.
|
||||
- name: Stop sidecars
|
||||
if: always()
|
||||
run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
||||
run: docker rm -f "$PANDOC_NAME" "$GOTENBERG_NAME" 2>/dev/null || true
|
||||
|
||||
images:
|
||||
name: Build container images
|
||||
|
||||
@ -90,14 +90,17 @@ export class FilesController {
|
||||
@Req() request: AuthedRequest,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<StreamableFile> {
|
||||
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)}"`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -327,6 +327,121 @@ 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('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,
|
||||
@ -13,7 +14,9 @@ import {
|
||||
AttachmentListItemView,
|
||||
AttachmentView,
|
||||
PondFilesView,
|
||||
PageClassification,
|
||||
SVG_MIME_TYPE,
|
||||
classificationFilenamePrefix,
|
||||
fileExtension,
|
||||
isImageMimeType,
|
||||
} from '@dorfteich/shared';
|
||||
@ -36,6 +39,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. */
|
||||
@ -198,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);
|
||||
}
|
||||
|
||||
@ -229,13 +247,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<PageClassification> {
|
||||
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
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
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, {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -101,6 +101,13 @@ export function buildOpenApiDocument(): object {
|
||||
properties: {
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
classification: {
|
||||
type: 'string',
|
||||
enum: ['unclassified', 'vs_nfd'],
|
||||
description:
|
||||
'VS-NfD marking level (ADR 0022). A marking, not access control; ' +
|
||||
'consumers re-publishing content are expected to carry it onward.',
|
||||
},
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
@ -118,6 +125,14 @@ export function buildOpenApiDocument(): object {
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
pondSlug: { type: 'string' },
|
||||
classification: {
|
||||
type: 'string',
|
||||
enum: ['unclassified', 'vs_nfd'],
|
||||
description:
|
||||
'VS-NfD marking level (ADR 0022). A marking, not access control; ' +
|
||||
'consumers re-publishing content are expected to carry it onward.',
|
||||
},
|
||||
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Parent page slug; see PageListItem.parent.',
|
||||
|
||||
@ -327,6 +327,35 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('includes the classification in page representations (issue #211)', async () => {
|
||||
const created = await pub()
|
||||
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ title: `Classified Api Page ${suffix}`, markdown: 'classified body' })
|
||||
.expect(201);
|
||||
const slug = (created.body as { slug: string }).slug;
|
||||
expect((created.body as { classification: string }).classification).toBe('unclassified');
|
||||
await prisma.page.updateMany({
|
||||
where: { slug, pond: { slug: pondSlug } },
|
||||
data: { classification: 'VS_NFD' },
|
||||
});
|
||||
|
||||
const fetched = await pub()
|
||||
.get(`/api/public/v1/ponds/${pondSlug}/pages/${slug}`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.expect(200);
|
||||
expect((fetched.body as { classification: string }).classification).toBe('vs_nfd');
|
||||
|
||||
const list = await pub()
|
||||
.get(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.expect(200);
|
||||
const listed = (list.body as { slug: string; classification: string }[]).find(
|
||||
(p) => p.slug === slug,
|
||||
);
|
||||
expect(listed?.classification).toBe('vs_nfd');
|
||||
});
|
||||
|
||||
it('round-trips a page through Markdown, replaces content via the collab path', async () => {
|
||||
const markdown = '# Heading\n\nHello **world** from the API.\n';
|
||||
const created = await pub()
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
pondFeatureEnabled,
|
||||
pondSettingsSchema,
|
||||
type CommentListFilter,
|
||||
type PageClassification,
|
||||
type PageListQuery,
|
||||
type CreateCommentInput,
|
||||
type CreateLabelInput,
|
||||
@ -110,6 +111,7 @@ export class PublicApiService {
|
||||
return items.map((item) => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
classification: item.classification,
|
||||
parent: (item.parentId && slugById.get(item.parentId)) || null,
|
||||
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
|
||||
createdAt: item.createdAt,
|
||||
@ -129,6 +131,9 @@ export class PublicApiService {
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
pondSlug,
|
||||
// VS-NfD level (#211): part of the versioned representation so API
|
||||
// consumers can carry the marking onward.
|
||||
classification: page.classification.toLowerCase() as PageClassification,
|
||||
parent,
|
||||
markdown: cache?.markdown ?? '',
|
||||
html: cache?.html ?? '',
|
||||
|
||||
@ -145,6 +145,40 @@ describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => {
|
||||
expect(res.text).toContain(`/api/v1/public/${pondSlug}/newer-${suffix}`);
|
||||
});
|
||||
|
||||
it('marks classified entries and states the highest level at feed level (issue #211)', async () => {
|
||||
// Unclassified feed: no category element at all (ADR 0022 — no noise).
|
||||
const open = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200);
|
||||
expect(open.text).not.toContain('urn:dorfteich:classification');
|
||||
|
||||
await prisma.page.updateMany({
|
||||
where: { pondId, slug: `newer-${suffix}` },
|
||||
data: { classification: 'VS_NFD' },
|
||||
});
|
||||
try {
|
||||
const res = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200);
|
||||
// The classified entry carries the documented category element…
|
||||
expect(res.text).toContain(
|
||||
'<category term="vs_nfd" scheme="urn:dorfteich:classification" ' +
|
||||
'label="VS – NUR FÜR DEN DIENSTGEBRAUCH"/>',
|
||||
);
|
||||
// …and the feed document states the highest contained level once:
|
||||
// 1 feed-level + 1 entry-level = exactly two categories (the open
|
||||
// entry carries none).
|
||||
expect(res.text.split('urn:dorfteich:classification').length - 1).toBe(2);
|
||||
|
||||
// The page feed of a classified page marks its entries and itself too.
|
||||
const pageFeed = await api()
|
||||
.get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`)
|
||||
.expect(200);
|
||||
expect(pageFeed.text).toContain('urn:dorfteich:classification');
|
||||
} finally {
|
||||
await prisma.page.updateMany({
|
||||
where: { pondId, slug: `newer-${suffix}` },
|
||||
data: { classification: 'UNCLASSIFIED' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a page feed built from the version history', async () => {
|
||||
const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(200);
|
||||
expect(res.text).toContain('<title>Newer Page — Feed Pond</title>');
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
PageClassification,
|
||||
classificationMarking,
|
||||
highestClassification,
|
||||
} from '@dorfteich/shared';
|
||||
import { Pond, User } from '@prisma/client';
|
||||
|
||||
import { PagesService } from '../pages/pages.service';
|
||||
@ -10,12 +15,18 @@ import { escapeHtml } from './html-shell';
|
||||
/** How many entries a feed carries — plenty for readers polling regularly. */
|
||||
const FEED_ENTRIES = 30;
|
||||
|
||||
/** The documented scheme URI of the classification `<category>` element
|
||||
* (issue #211, ADR 0022; see docs/self-hosting/public-api.md §Feeds). */
|
||||
const CLASSIFICATION_SCHEME = 'urn:dorfteich:classification';
|
||||
|
||||
interface FeedEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
link: string;
|
||||
updated: Date;
|
||||
summary?: string;
|
||||
/** VS-NfD level (#211) — rendered as an Atom `<category>` when classified. */
|
||||
classification?: PageClassification;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,6 +71,7 @@ export class FeedService {
|
||||
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
|
||||
: `${baseUrl}/p/${pond.slug}/${page.slug}`,
|
||||
updated: new Date(page.updatedAt),
|
||||
classification: page.classification,
|
||||
}));
|
||||
return atomDocument({
|
||||
id: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`,
|
||||
@ -79,7 +91,7 @@ export class FeedService {
|
||||
const pond = await this.requireVisiblePond(user, pondSlug);
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
|
||||
select: { id: true, pondId: true, slug: true, title: true },
|
||||
select: { id: true, pondId: true, slug: true, title: true, classification: true },
|
||||
});
|
||||
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
|
||||
throw new NotFoundException();
|
||||
@ -94,11 +106,13 @@ export class FeedService {
|
||||
user === null
|
||||
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
|
||||
: `${baseUrl}/p/${pond.slug}/${page.slug}`;
|
||||
const pageLevel = page.classification.toLowerCase() as PageClassification;
|
||||
const entries = versions.map((version) => ({
|
||||
id: `urn:dorfteich:version:${version.id}`,
|
||||
title: version.label ?? version.trigger.toLowerCase(),
|
||||
link,
|
||||
updated: version.createdAt,
|
||||
classification: pageLevel,
|
||||
}));
|
||||
return atomDocument({
|
||||
id: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}/feed.xml`,
|
||||
@ -118,6 +132,18 @@ export class FeedService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The classification as a standard Atom `<category>` (issue #211): `term` =
|
||||
* the machine-readable level, `label` = the fixed marking wording. Only
|
||||
* classified content carries one (ADR 0022: unclassified shows no marking). */
|
||||
function categoryTag(classification: PageClassification | undefined, indent: string): string {
|
||||
const marking = classification ? classificationMarking(classification) : null;
|
||||
if (!classification || !marking) return '';
|
||||
return (
|
||||
`${indent}<category term="${escapeHtml(classification)}" ` +
|
||||
`scheme="${CLASSIFICATION_SCHEME}" label="${escapeHtml(marking)}"/>\n`
|
||||
);
|
||||
}
|
||||
|
||||
function atomDocument(feed: {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -125,6 +151,10 @@ function atomDocument(feed: {
|
||||
entries: FeedEntry[];
|
||||
}): string {
|
||||
const updated = feed.entries[0]?.updated ?? new Date();
|
||||
// The feed document states the highest level it contains (issue #211).
|
||||
const highest = highestClassification(
|
||||
feed.entries.map((entry) => entry.classification ?? 'unclassified'),
|
||||
);
|
||||
const entries = feed.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
@ -133,6 +163,7 @@ function atomDocument(feed: {
|
||||
` <title>${escapeHtml(entry.title)}</title>\n` +
|
||||
` <link href="${escapeHtml(entry.link)}"/>\n` +
|
||||
` <updated>${entry.updated.toISOString()}</updated>\n` +
|
||||
categoryTag(entry.classification, ' ') +
|
||||
(entry.summary ? ` <summary>${escapeHtml(entry.summary)}</summary>\n` : '') +
|
||||
` </entry>`,
|
||||
)
|
||||
@ -143,6 +174,7 @@ function atomDocument(feed: {
|
||||
` <id>${escapeHtml(feed.id)}</id>\n` +
|
||||
` <title>${escapeHtml(feed.title)}</title>\n` +
|
||||
` <link rel="self" href="${escapeHtml(feed.selfLink)}"/>\n` +
|
||||
categoryTag(highest === 'unclassified' ? undefined : highest, ' ') +
|
||||
` <updated>${updated.toISOString()}</updated>\n` +
|
||||
`${entries}\n` +
|
||||
`</feed>\n`
|
||||
|
||||
@ -42,6 +42,12 @@ export function htmlDocument({
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
.public-page__pond { color: #64748b; font-size: 0.9rem; }
|
||||
/* VS-NfD marking (issue #211, ADR 0022): same convention as the SPA —
|
||||
bold, centered, ruled band above and below the content. currentColor
|
||||
keeps full contrast in both color schemes. */
|
||||
.classification-banner { margin: 0.75rem 0; padding: 0.25rem 0.5rem;
|
||||
border-top: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
||||
font-weight: 700; letter-spacing: 0.08em; text-align: center; font-size: 0.9rem; }
|
||||
pre { overflow-x: auto; }
|
||||
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
|
||||
font-size: 0.9rem; }
|
||||
|
||||
@ -109,6 +109,34 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
|
||||
expect((json.body as { html: string }).html).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('renders the VS-NfD marking top and bottom in the no-JS shell (issue #211)', async () => {
|
||||
const marking = 'VS – NUR FÜR DEN DIENSTGEBRAUCH';
|
||||
// Unclassified: the shell carries no marking at all.
|
||||
const open = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
|
||||
expect(open.text).not.toContain(marking);
|
||||
|
||||
await prisma.page.updateMany({
|
||||
where: { pondId, slug: pageSlug },
|
||||
data: { classification: 'VS_NFD' },
|
||||
});
|
||||
try {
|
||||
const html = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
|
||||
// Above AND below the content — the shell is its own render path.
|
||||
expect(html.text.split(`<p class="classification-banner">${marking}</p>`).length - 1).toBe(2);
|
||||
const [before, after] = html.text.split('Hello world from a public page.');
|
||||
expect(before).toContain(marking);
|
||||
expect(after).toContain(marking);
|
||||
// The JSON the SPA renders carries the level too (#206).
|
||||
const json = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(200);
|
||||
expect((json.body as { classification: string }).classification).toBe('vs_nfd');
|
||||
} finally {
|
||||
await prisma.page.updateMany({
|
||||
where: { pondId, slug: pageSlug },
|
||||
data: { classification: 'UNCLASSIFIED' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('never resolves a non-public page for an anonymous visitor', async () => {
|
||||
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}`).expect(404);
|
||||
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { classificationMarking } from '@dorfteich/shared';
|
||||
import type { PageClassification, PageCommentsView } from '@dorfteich/shared';
|
||||
import { Page, Pond, User } from '@prisma/client';
|
||||
|
||||
@ -194,6 +195,12 @@ export class PublicService {
|
||||
canonical: string,
|
||||
): Promise<string> {
|
||||
const content = await this.content(user, pondSlug, pageSlug);
|
||||
// The VS-NfD marking renders in the same places as the SPA — above and
|
||||
// below the content (issue #211, ADR 0022). The no-JS shell is its own
|
||||
// render path, so it carries its own banner markup; unclassified pages
|
||||
// get none.
|
||||
const marking = classificationMarking(content.classification);
|
||||
const banner = marking ? `<p class="classification-banner">${escapeHtml(marking)}</p>\n` : '';
|
||||
// No session-dependent content: this document is identical for every viewer
|
||||
// who may read the page (crawler-safe, cacheable). The shared shell adds
|
||||
// the legal footer links (issue #82) in the instance default locale.
|
||||
@ -206,9 +213,10 @@ export class PublicService {
|
||||
`/api/v1/public/${encodeURIComponent(pondSlug)}/feed.xml`,
|
||||
canonical,
|
||||
).toString(),
|
||||
bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
|
||||
bodyHtml: `${banner}<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
|
||||
<h1>${escapeHtml(content.title)}</h1>
|
||||
${content.html}`,
|
||||
${content.html}
|
||||
${banner}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PageClassification,
|
||||
SEARCH_HIGHLIGHT_END,
|
||||
SEARCH_HIGHLIGHT_START,
|
||||
SEARCH_RESULT_LIMIT,
|
||||
@ -34,6 +35,7 @@ interface SearchRow {
|
||||
pondName: string;
|
||||
labelIds: string[];
|
||||
snippet: string;
|
||||
classification: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -159,6 +161,7 @@ export class PostgresSearchProvider extends SearchProvider {
|
||||
|
||||
const rows = await this.prisma.$queryRaw<SearchRow[]>(Prisma.sql`
|
||||
SELECT p.id AS "pageId", p.title, p.slug, p.pond_id AS "pondId",
|
||||
p.classification::text AS classification,
|
||||
po.slug AS "pondSlug", po.name AS "pondName",
|
||||
COALESCE(
|
||||
ARRAY(SELECT pl.label_id FROM page_labels pl WHERE pl.page_id = p.id),
|
||||
@ -210,6 +213,8 @@ export class PostgresSearchProvider extends SearchProvider {
|
||||
pondName: row.pondName,
|
||||
labelIds: row.labelIds,
|
||||
snippet: row.snippet,
|
||||
// A hit on a classified page is never shown unmarked (#211).
|
||||
classification: row.classification.toLowerCase() as PageClassification,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ describe('SearchProvider DI seam (issue #49)', () => {
|
||||
pondSlug: 'pond',
|
||||
pondName: 'Pond',
|
||||
labelIds: [],
|
||||
classification: 'unclassified',
|
||||
snippet: 'a snippet',
|
||||
};
|
||||
const fake: SearchProvider = {
|
||||
|
||||
@ -90,6 +90,20 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
||||
expect(results[0]!.pageId).toBe(titleHit);
|
||||
});
|
||||
|
||||
it('carries the classification with every hit — a classified snippet is never unmarked (issue #211)', async () => {
|
||||
const classifiedId = await makePage(`classified ${term} note`, `secret ${term} content`);
|
||||
await prisma.page.update({
|
||||
where: { id: classifiedId },
|
||||
data: { classification: 'VS_NFD' },
|
||||
});
|
||||
const results = await search.search({ q: term }, owner);
|
||||
const classified = results.find((r) => r.pageId === classifiedId);
|
||||
expect(classified?.classification).toBe('vs_nfd');
|
||||
// Every other hit carries the field too, as `unclassified`.
|
||||
const other = results.find((r) => r.pageId !== classifiedId);
|
||||
expect(other?.classification).toBe('unclassified');
|
||||
});
|
||||
|
||||
it('highlights the match in the snippet', async () => {
|
||||
const results = await search.search({ q: term }, owner);
|
||||
const bodyHit = results.find((r) => r.snippet.includes(SEARCH_HIGHLIGHT_START));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { classificationMarking } from '@dorfteich/shared';
|
||||
import type { PondView, SearchResultView } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@ -43,6 +44,7 @@ function saveRecent(query: string): string[] {
|
||||
*/
|
||||
export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('search');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const navigate = useNavigate();
|
||||
const { pondSlug } = useCurrentPondRoute();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@ -220,6 +222,14 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
||||
onClick={() => open(hit)}
|
||||
>
|
||||
<span className="search-result__title">{hit.title}</span>
|
||||
{/* A hit on a classified page is never shown unmarked
|
||||
(issue #211, ADR 0022) — fixed wording, not localized. */}
|
||||
{classificationMarking(hit.classification) && (
|
||||
<span className="search-result__classification">
|
||||
<span className="visually-hidden">{tCommon('classification.label')}: </span>
|
||||
{classificationMarking(hit.classification)}
|
||||
</span>
|
||||
)}
|
||||
<span className="search-result__pond">{t('inPond', { pond: hit.pondName })}</span>
|
||||
<LabelChips labelIds={hit.labelIds} byId={byId} />
|
||||
<span className="search-result__snippet">
|
||||
|
||||
@ -2809,6 +2809,16 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* VS-NfD marking on a search hit (issue #211, ADR 0022): compact form of the
|
||||
banner — text token only, full contrast in both themes. */
|
||||
.search-result__classification {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.search-result__snippet {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
@ -3061,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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
`<file>.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.
|
||||
|
||||
@ -52,6 +52,28 @@ curl -H "Authorization: Bearer dt_pat_..." \
|
||||
Deliberately not in v1 (stage 2): attachment upload, version endpoints,
|
||||
webhooks.
|
||||
|
||||
### Classification (VS-NfD marking, issue #211 / ADR 0022)
|
||||
|
||||
Every page representation (`GET …/pages`, `GET …/pages/{pageSlug}`)
|
||||
carries a `classification` field: `"unclassified"` or `"vs_nfd"`. It is a
|
||||
**marking, not access control** — permissions are unchanged by it. API
|
||||
consumers that render or re-publish page content are expected to carry
|
||||
the marking onward (the fixed wording is
|
||||
`VS – NUR FÜR DEN DIENSTGEBRAUCH`).
|
||||
|
||||
The Atom feeds mark classified content with a standard `<category>`
|
||||
element on both levels:
|
||||
|
||||
```xml
|
||||
<category term="vs_nfd" scheme="urn:dorfteich:classification"
|
||||
label="VS – NUR FÜR DEN DIENSTGEBRAUCH"/>
|
||||
```
|
||||
|
||||
Each classified entry carries one, and the feed document itself carries
|
||||
one stating the **highest** level it contains. Unclassified entries and
|
||||
all-open feeds carry none (marking everything trains readers to ignore
|
||||
markings, ADR 0022).
|
||||
|
||||
## Connect Claude Code / MCP clients
|
||||
|
||||
The instance ships its own MCP endpoint (Streamable HTTP) at `/api/mcp` —
|
||||
|
||||
@ -57,9 +57,9 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
|
||||
- [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
|
||||
- [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
|
||||
- [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
||||
- [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212
|
||||
- [x] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
|
||||
|
||||
### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap
|
||||
|
||||
@ -184,16 +184,17 @@ nach sich zieht.
|
||||
- [x] **Abgrenzungserklärung §52 VSA** — welche Sicherheitsgrundfunktionen die
|
||||
Anwendung _nicht_ erbringt und wem sie zufallen. Wichtigstes
|
||||
Einzeldokument. · 3 AT · #226 → `40-abgrenzungserklaerung.md`
|
||||
- [ ] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
|
||||
lokale Auth aus, Public-API aus, MCP aus, Feeds aus, Plugins aus,
|
||||
Backup nur lokal · 3 AT · #227
|
||||
- [ ] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
|
||||
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228
|
||||
- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
|
||||
Löschung und Vernichtung, Rollentrennung · 4–5 AT · #229
|
||||
- [x] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
|
||||
lokale Auth aus (Zeile ⏳ bis #216), Public-API aus, MCP aus, Feeds aus,
|
||||
Plugins aus, Backup nur lokal · 3 AT · #227 → \`50-haertungsleitfaden.md\`
|
||||
- [x] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
|
||||
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228 → `60-sicherheitsdokumentation.md`
|
||||
- [x] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
|
||||
Löschung und Vernichtung, Rollentrennung · 4–5 AT · #229 → `70-betriebshandbuch.md`
|
||||
(Airgap-Kapitel verweist auf offene #218–#221)
|
||||
- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung
|
||||
„Produkt / Betreiber / nicht anwendbar" · 3–4 AT · #230
|
||||
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231
|
||||
- [x] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231 → `90-restrisiken.md`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -313,5 +313,8 @@ Slug-Residuum falls in #235 so entschieden), führt die Restrisikoliste
|
||||
gekürzt; die Erklärung ist erst dann uneingeschränkt gültig, wenn die
|
||||
Delta-Liste leer ist oder alle Restpositionen in der Restrisikoliste
|
||||
(#231) vom Betreiber gebilligt sind.
|
||||
- Querbezüge: Härtungsleitfaden (#227), Sicherheitsdokumentation (#228),
|
||||
Betriebshandbuch (#229), IT-Grundschutz-Zuarbeit (#230).
|
||||
- Querbezüge: Härtungsleitfaden (#227, `50-haertungsleitfaden.md`),
|
||||
Sicherheitsdokumentation (#228, `60-sicherheitsdokumentation.md`),
|
||||
Betriebshandbuch (#229, `70-betriebshandbuch.md`),
|
||||
IT-Grundschutz-Zuarbeit (#230, `80-grundschutz-mapping.md`),
|
||||
Restrisikoliste (#231, `90-restrisiken.md`).
|
||||
|
||||
94
docs/vs-nfd/50-haertungsleitfaden.md
Normal file
94
docs/vs-nfd/50-haertungsleitfaden.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Härtungsleitfaden — Referenzkonfiguration „VS-NfD-Betrieb" (Issue #227)
|
||||
|
||||
Zweck: **eine** benannte Konfiguration, die ein Betreiber als Ganzes
|
||||
übernehmen kann. Jeder Eintrag nennt den exakten Schalter, den Wert und
|
||||
das **Warum** — wer abweicht, tut es wissentlich. Der Leitfaden macht
|
||||
zugleich die in M1/M2/M4 gebauten Schalter prüfbar.
|
||||
|
||||
**Pflegeregel (verbindlich):** Jeder PR, der einen neuen Instanz- oder
|
||||
Deploy-Schalter einführt, ergänzt diesen Leitfaden **im selben PR** um
|
||||
dessen Referenzwert. Ein Schalter ohne Leitfaden-Zeile gilt im Review
|
||||
als unvollständig. (Gleiches Muster wie der Ereigniskatalog-Zaun #201.)
|
||||
|
||||
Geltungsbereich: Konfiguration der Anwendung. Die Härtung der Plattform
|
||||
(Betriebssystem, Netz, Reverse Proxy, Datenträger) ist Betreibersache
|
||||
(Abgrenzungserklärung `40-abgrenzungserklaerung.md` — von dort wird
|
||||
hierher verwiesen; das IT-Grundschutz-Mapping
|
||||
`80-grundschutz-mapping.md` nimmt diese Referenzkonfiguration als
|
||||
Produkt-Beleg).
|
||||
|
||||
---
|
||||
|
||||
## 1 Referenzkonfiguration
|
||||
|
||||
### 1.1 Instanz-Settings (Site-Admin → Einstellungen; Tabelle `instance_settings`)
|
||||
|
||||
Nach jeder Änderung an Instanz-Settings die api neu starten — der
|
||||
Settings-Cache ist in-process (operations.md).
|
||||
|
||||
| Setting | Referenzwert | Default | Warum |
|
||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
||||
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
|
||||
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
|
||||
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
|
||||
| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. |
|
||||
| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. |
|
||||
| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. |
|
||||
| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. |
|
||||
| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). |
|
||||
| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). |
|
||||
| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). |
|
||||
| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. |
|
||||
|
||||
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
||||
|
||||
| Variable | Referenzwert | Warum |
|
||||
| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
|
||||
| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
|
||||
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
|
||||
| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). |
|
||||
| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
|
||||
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
|
||||
|
||||
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
||||
|
||||
| Schalter | Referenzwert (geplant) | Status |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.local.enabled` | `false` — lokale Passwort-Auth aus, Anmeldung nur über die Fremdauthentisierung der Behörde | ⏳ kommt mit #216 (M27); bis dahin bleibt lokale Auth der einzige Anmeldeweg und `auth.registrationMode=closed` + Session-Verkürzung sind die Kompensation. Zeile wird im #216-PR scharfgestellt. |
|
||||
|
||||
## 2 Verifikations-Checkliste
|
||||
|
||||
Auf der laufenden Instanz (ersetze `HOST`); Erwartung jeweils dahinter.
|
||||
Die vier 404-Prüfungen laufen unauthentifiziert:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/public/v1/ponds # 404 (api.enabled=false)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session
|
||||
curl -s https://HOST/api/v1/readyz # status ok
|
||||
```
|
||||
|
||||
Als Site-Admin (UI → Administration bzw. `GET /api/v1/admin/settings`):
|
||||
|
||||
- [ ] Registrierung „geschlossen"; neue Seite entsteht mit Kennzeichnung
|
||||
(Einstufung neuer Seiten = VS-NfD); Upload auf eingestufte Seite
|
||||
wird abgelehnt (403).
|
||||
- [ ] Plugin-Verwaltung antwortet 404 (Kill-Switch aktiv).
|
||||
- [ ] Backup-Panel zeigt keine aktiven Fernziele; auf dem Host ist
|
||||
`BACKUP_ALLOWED_TARGETS` leer bzw. exakt der freigegebene Spiegel.
|
||||
- [ ] Eine Testsitzung läuft nach `SESSION_IDLE_HOURS` Inaktivität ab.
|
||||
- [ ] Kennzeichnungs-Stichprobe: eingestufte Seite zeigt den Aufdruck in
|
||||
Web, Druckvorschau und PDF-Export (Konventionen: Kommentare auf
|
||||
Issue #228 bzw. `60-sicherheitsdokumentation.md` §3.5).
|
||||
|
||||
## 3 Querbezüge
|
||||
|
||||
Abgrenzungserklärung (`40-abgrenzungserklaerung.md`, verweist hierher);
|
||||
IT-Grundschutz-Mapping (`80-grundschutz-mapping.md`, nutzt dieses
|
||||
Profil als Produkt-Beleg); Betriebshandbuch (`70-betriebshandbuch.md`,
|
||||
Installation §1 wendet dieses Profil an); Restrisikoliste
|
||||
(`90-restrisiken.md` — Abweichungen von der Referenzkonfiguration
|
||||
gehören dorthin, wenn sie dauerhaft sind).
|
||||
277
docs/vs-nfd/60-sicherheitsdokumentation.md
Normal file
277
docs/vs-nfd/60-sicherheitsdokumentation.md
Normal file
@ -0,0 +1,277 @@
|
||||
# Sicherheitsdokumentation (VS-NfD, Issue #228)
|
||||
|
||||
Zweck: das Dokument, das ein Prüfer zuerst liest — Architektur,
|
||||
Datenflüsse, Netzplan, Vertrauensgrenzen und die vollständige Liste
|
||||
aller Inhaltskopien. Es konsolidiert bestehende Referenzdokumente,
|
||||
erfindet nichts neu und ist **auf die Ziffer genau** an
|
||||
`deploy/compose/docker-compose.yml` ausgerichtet (die Compose-Datei ist
|
||||
die maßgebliche Dienst- und Portliste; weicht dieses Dokument ab, ist
|
||||
das ein Fehler in diesem Dokument).
|
||||
|
||||
Einordnung: Dorfteich erbringt **keine Sicherheitsgrundfunktion**
|
||||
(§52 VSA, ADR 0019, Abgrenzungserklärung
|
||||
`40-abgrenzungserklaerung.md`). Diese Dokumentation beschreibt, was die
|
||||
Anwendung tut — Verschlüsselung, Netztrennung, Datenträgerschutz und
|
||||
Beweissicherung auf Plattformebene fallen dem Betreiber zu.
|
||||
|
||||
Quell-Dokumente (englisch, maßgeblich für Details):
|
||||
`docs/architecture/security.md`, `deployment.md`, `data-model.md`,
|
||||
`permissions.md`, `realtime-collaboration.md`,
|
||||
`plugin-architecture.md`, `operations.md`, `audit-events.md`;
|
||||
Deploy-Sicht: `deploy/stages.md`, `deploy/compose/docker-compose.yml`.
|
||||
|
||||
Pflegeregel: Änderungen an Diensten, Ports, Datenflüssen oder
|
||||
Speicherorten werden **im selben PR** hier nachgezogen (dieselbe Regel
|
||||
wie beim Härtungsleitfaden `50-haertungsleitfaden.md`).
|
||||
|
||||
---
|
||||
|
||||
## 1 Komponenten
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph client [Endgerät]
|
||||
B[Browser SPA]
|
||||
end
|
||||
subgraph host [Betreiber-Host / Reverse-Proxy-Zone]
|
||||
RP[Reverse Proxy TLS]
|
||||
end
|
||||
subgraph frontend [Docker-Netz frontend]
|
||||
W[web nginx statisch]
|
||||
A[api NestJS]
|
||||
C[collab Hocuspocus]
|
||||
end
|
||||
subgraph internal [Docker-Netz internal]
|
||||
DB[(PostgreSQL 17.5)]
|
||||
P[pandoc 3.6]
|
||||
G[gotenberg 8]
|
||||
BK[backup Sidecar]
|
||||
end
|
||||
SMTP[(SMTP-Relay extern)]
|
||||
MIR[(Backup-Spiegel rsync/WebDAV, Allowlist)]
|
||||
|
||||
B -->|HTTPS 443| RP
|
||||
RP -->|/| W
|
||||
RP -->|/api| A
|
||||
RP -->|/collab WebSocket| C
|
||||
A --> DB
|
||||
C --> DB
|
||||
A -->|HTTP pandoc:3030| P
|
||||
A -->|HTTP gotenberg:3000| G
|
||||
A -->|Mail| SMTP
|
||||
BK --> DB
|
||||
BK -.->|nur bei Konfiguration + Allowlist| MIR
|
||||
BK -->|Fehler-Mail| SMTP
|
||||
```
|
||||
|
||||
| Dienst | Image (gepinnt, #203/#236) | Zweck | Privilegien |
|
||||
| ---------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `web` | eigenes Image (nginx-unprivileged 1.27, uid 101) | statische SPA-Auslieferung | kein Volume, nur Netz `frontend`, kein DB-Zugang |
|
||||
| `api` | eigenes Image (Node 22.15.1-alpine, User `node`) | REST-API, Permissions, Scheduler-Jobs, Import/Export-Orchestrierung, Audit | Volumes `uploads`, `plugins`, `secrets`, `backups` (ro); Netze `frontend`+`internal`; DB-Vollzugriff |
|
||||
| `collab` | eigenes Image (Node, User `node`) | Echtzeit-Editing (Yjs/Hocuspocus), Persistenz der Dokumente | Netze `frontend`+`internal`; DB-Vollzugriff; keine Datei-Volumes |
|
||||
| `db` | `postgres:17.5-alpine@sha256:…` | einziger Datenbestand (außer Uploads) | nur Netz `internal`; Volume `db-data` |
|
||||
| `pandoc` | `pandoc/core:3.6@sha256:…` | Dokumentkonvertierung (Import/Export DOCX/ODT) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
|
||||
| `gotenberg` | `gotenberg/gotenberg:8@sha256:…` | HTML→PDF-Rendering (Chromium) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
|
||||
| `backup` | eigenes Image | nächtlicher `pg_dump -Fc` + Volume-Tar, Retention, optionaler Spiegel | nur `internal`; Volumes `uploads`, `plugins`, `secrets` (ro), `backups` (rw); DB-Zugang |
|
||||
| `caddy` (Compose-Profil, optional) | `caddy:2.10-alpine@sha256:…` | TLS-Terminierung, wenn kein Host-Proxy existiert | Ports 80/443 nach außen; Netz `frontend` |
|
||||
|
||||
Alle eigenen Images laufen als non-root (`USER node`); die Stages auf
|
||||
ONE nutzen einen Host-Reverse-Proxy (Caddy des Hosts), das
|
||||
`caddy`-Profil bleibt dort inaktiv.
|
||||
|
||||
## 2 Netzplan (Ports und Protokolle)
|
||||
|
||||
Maßgeblich: `deploy/compose/docker-compose.yml`. Alle
|
||||
Anwendungs-Portbindungen sind **auf 127.0.0.1 beschränkt** — von außen
|
||||
erreichbar ist ausschließlich der Reverse Proxy.
|
||||
|
||||
| Von | Nach | Port/Protokoll | Exposition |
|
||||
| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| Client | Reverse Proxy | 443/TCP HTTPS (80 nur Redirect) | **extern** |
|
||||
| Reverse Proxy | `web` | `127.0.0.1:${WEB_PORT:-8100}` → Container 8080, HTTP | Host-lokal |
|
||||
| Reverse Proxy | `api` | `127.0.0.1:${API_PORT:-8101}` → Container 3000, HTTP | Host-lokal |
|
||||
| Reverse Proxy | `collab` | `127.0.0.1:${COLLAB_PORT:-8102}` → Container 3000, HTTP + WebSocket-Upgrade | Host-lokal |
|
||||
| `api`/`collab`/`backup` | `db` | 5432/TCP (nur Docker-Netz `internal`) | intern |
|
||||
| `api` | `pandoc` | `http://pandoc:3030` (nur `internal`) | intern |
|
||||
| `api` | `gotenberg` | `http://gotenberg:3000` (nur `internal`) | intern |
|
||||
| `api`, `backup` | SMTP-Relay | vom Betreiber konfiguriert (`SMTP_*`) | **ausgehend extern** |
|
||||
| `backup` | Spiegel-Ziel | rsync über SSH (Port konfigurierbar) bzw. WebDAV/HTTPS — **nur** an Hosts der Deploy-Allowlist `BACKUP_ALLOWED_TARGETS` (ADR 0026, #192); leere Allowlist = alle Fernziele hart aus | **ausgehend extern, allowlist-beschränkt** |
|
||||
| optional `caddy` | Client | 80/443 | extern (nur wenn Profil aktiv) |
|
||||
|
||||
Es gibt **keine eingehenden** Verbindungen außer über den Reverse
|
||||
Proxy. `db`, `pandoc`, `gotenberg`, `backup` haben keinerlei
|
||||
Portbindung an den Host.
|
||||
|
||||
## 3 Datenflüsse
|
||||
|
||||
### 3.1 Authentifizierung
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant A as api
|
||||
participant DB as PostgreSQL
|
||||
B->>A: POST /api/v1/auth/login (Origin-Header Pflicht, fail-closed CSRF #189)
|
||||
A->>DB: user_identities (Argon2id-Hash prüfen), rate_limits (10/min/IP)
|
||||
A-->>B: Set-Cookie dt_session (HttpOnly, Secure, SameSite=Lax)
|
||||
Note over A,DB: Session-Row: gehashte Id, expiresAt (SESSION_ABSOLUTE_HOURS),<br/>Idle-Grenze (SESSION_IDLE_HOURS, #190); Audit auth.login_*
|
||||
```
|
||||
|
||||
Externe Authentisierung (OIDC/Proxy-Header) ist geplant (M27,
|
||||
#214–#217) und ändert diesen Fluss; bis dahin ist lokale
|
||||
Passwort-Authentifizierung der einzige Weg.
|
||||
|
||||
### 3.2 Editieren (Echtzeit)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant A as api
|
||||
participant C as collab
|
||||
participant DB as PostgreSQL
|
||||
B->>A: GET /pages/:id/collab-token (Guard: Read-Recht)
|
||||
A-->>B: Kurzlebiges Token (60 s TTL, HKDF-Subkey aus COLLAB_TOKEN_SECRET, #188), mode rw/ro
|
||||
B->>C: WebSocket /collab + Token
|
||||
C->>C: Token-Verifikation (shared token-crypto), rw nur bei Write-Grant
|
||||
B-->>C: Yjs-Updates (CRDT)
|
||||
C->>DB: debounced Persist: pages.ydoc_state + page_updates-Log,<br/>abgeleiteter page_content_cache (plain/markdown/html/outline/tsvector)
|
||||
A-->>C: Rechteänderungen via Postgres LISTEN/NOTIFY (Kanal-Konstanten shared) → close(4205)
|
||||
```
|
||||
|
||||
### 3.3 Export (PDF/DOCX/ODT/ZIP)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant A as api
|
||||
participant P as pandoc
|
||||
participant G as gotenberg
|
||||
B->>A: POST /pages/:id/export {format}
|
||||
A->>A: conversion_jobs-Row (input = aufbereitetes Markdown/HTML,<br/>bei eingestufter Seite Option {marking}, #208/#209)
|
||||
A->>G: html → pdf (Kennzeichnung im Header/Footer-Template je Seite)
|
||||
A->>P: gfm → docx/odt (reference-doc mit Kennzeichnung, in-Request-Datei)
|
||||
A-->>B: Poll GET /jobs/:id → Download /jobs/:id/result
|
||||
Note over A: Payloads werden nach conversion.payloadRetentionDays genullt (#233)
|
||||
```
|
||||
|
||||
Der Pond-ZIP-Export streamt direkt aus `page_content_cache` +
|
||||
`uploads`-Volume (permissionsgefiltert) und enthält seit M26
|
||||
Frontmatter/Aufdruck, `manifest.json` und Begleitdateien für
|
||||
eingestufte Inhalte (#210/#212).
|
||||
|
||||
### 3.4 Backup
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant BK as backup-Sidecar
|
||||
participant DB as PostgreSQL
|
||||
participant V as Volumes uploads/plugins
|
||||
participant M as Spiegel (optional)
|
||||
BK->>DB: nächtlich pg_dump -Fc
|
||||
BK->>V: tar der Volumes (lesend)
|
||||
BK->>BK: Restore-Set aufs backups-Volume, Retention (BACKUP_RETENTION_DAYS)
|
||||
BK-->>M: rsync/WebDAV NUR an BACKUP_ALLOWED_TARGETS (ADR 0026); leer = aus
|
||||
BK-->>BK: status.json; Fehler-Mail direkt via SMTP
|
||||
```
|
||||
|
||||
Backups sind **unverschlüsselt by design** (ADR 0015/0019) —
|
||||
Datenträger- und Transportschutz ist Plattformsache
|
||||
(Abgrenzungserklärung §2).
|
||||
|
||||
### 3.5 Lesekanäle
|
||||
|
||||
Jeder Kanal, über den Seiteninhalt die Anwendung verlässt (zugleich die
|
||||
Instrumentierungsliste für den Lesetrail, #222):
|
||||
|
||||
| Kanal | Pfad | Rechteprüfung | Kennzeichnung (M26) |
|
||||
| -------------------------- | -------------------------------- | -------------------------------------- | ----------------------------------- |
|
||||
| SPA-Seitenansicht | `GET /api/v1/pages/:id` u. a. | Guard (shared Resolver) | Banner oben+unten (#206) |
|
||||
| Public-/No-JS-Ansicht | `GET /api/v1/public/:pond/:page` | `public`-Grant, sonst 404 | Banner oben+unten (#211) |
|
||||
| Public REST API | `/api/public/v1/**` | `api.enabled` + Pond-Opt-in + PAT | `classification`-Feld (#211) |
|
||||
| MCP-Endpoint | `/api/mcp` | `mcp.enabled` + Pond-Opt-in + PAT | wie Public API (gleiches Modell) |
|
||||
| Atom-Feeds | `…/feed.xml` | `feeds.enabled`; Grant bzw. Feed-Token | `<category>`-Element (#211) |
|
||||
| Suche | `GET /api/v1/search` | per-Treffer-Resolution | Level je Treffer (#211) |
|
||||
| Attachment-Download | `GET /api/v1/media/:fileId` | Guard über Seite/Pond | Dateinamens-Präfix `VS-NfD_` (#212) |
|
||||
| Export (PDF/Office/ZIP/MD) | s. 3.3 | Guard; ZIP permissionsgefiltert | je Kanal (#207–#210) |
|
||||
| Collab-WebSocket | `/collab` | Token aus 3.2 | Inhalt = Editor-Ansicht (#206) |
|
||||
|
||||
## 4 Vertrauensgrenzen
|
||||
|
||||
1. **Client ↔ Instanz** (Internet/Behördennetz): TLS am Reverse Proxy
|
||||
terminiert (Betreiber-Zone). Die Anwendung setzt die
|
||||
Security-Header selbst (eigene Middleware, #197: CSP `script-src
|
||||
'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
|
||||
Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
|
||||
2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
|
||||
der Proxy ist die einzige Eintrittsstelle. Der geplante
|
||||
Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die
|
||||
Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin
|
||||
trägt der Proxy nur Transport.
|
||||
3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
|
||||
sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
|
||||
Zugang dorthin.
|
||||
4. **Plugin-Sandbox** (ADR 0008): Plugin-Code läuft ausschließlich im
|
||||
sandboxed iframe (eigene Origin-lose Umgebung, Message-Protokoll,
|
||||
deklarierte Capabilities); serverseitig wird beim Install ein
|
||||
Static-Gate erzwungen. Instanzweiter Kill-Switch `plugins.enabled`
|
||||
(#200); Hash-Pinning ist bewusst verschoben (#232,
|
||||
Restrisikoliste). Der Sandbox-Escape-Regressionstest (bösartiges
|
||||
Fixture-Plugin) läuft in CI.
|
||||
5. **Ausgehende Kanäle**: SMTP (Betreiber-Relay) und Backup-Spiegel
|
||||
(Allowlist, ADR 0026) sind die einzigen initiierten
|
||||
Außenverbindungen. Es gibt keine Telemetrie, keine Update-Pings,
|
||||
keine externen Font-/CDN-Ladungen (ADR 0016: Fonts self-hosted).
|
||||
6. **Betreiber-Plattform**: Host, Docker-Daemon, Volumes, Netzwerk und
|
||||
Backups liegen außerhalb der Anwendungsverantwortung
|
||||
(Abgrenzungserklärung).
|
||||
|
||||
## 5 Vollständige Liste der Inhaltskopien
|
||||
|
||||
Grundlage des Löschkonzepts im Betriebshandbuch
|
||||
(`70-betriebshandbuch.md` §5) — Löschen ist nur vollständig, wenn es
|
||||
jede dieser Kopien erreicht oder ihren Verbleib begründet.
|
||||
|
||||
**In der Datenbank (Volume `db-data`):**
|
||||
|
||||
| Ort | Inhalt | Lebenszyklus |
|
||||
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `pages.ydoc_state` | aktuelles Dokument (Yjs-Binärzustand) | bis Purge der Seite |
|
||||
| `page_updates` | inkrementelles Update-Log | Compaction; Purge löscht |
|
||||
| `page_content_cache` | Klartext, Markdown, HTML, Outline, **Suchvektor** | bei jedem Persist ersetzt; Purge löscht |
|
||||
| `page_versions` | eigenständige Snapshots (Historie) | bis Purge der Seite |
|
||||
| `comments` | Kommentartexte | Purge der Seite kaskadiert |
|
||||
| `conversion_jobs.input/result` | Export-/Import-Payloads (ganze Dokumente) | genullt nach `conversion.payloadRetentionDays` (#233) |
|
||||
| `mail_outbox` | Mail-Bodies (Digest-Titel!) | gelöscht nach `mail.outboxRetentionDays` (#234) |
|
||||
| `page_links.target_slug` | Slug-Text auch nach Ziel-Purge | bewusst behalten (#235, Restrisiko I-24) |
|
||||
| `audit_log` | Metadaten (nie Inhalt) | `audit.retentionDays` (#196) |
|
||||
| `attachments` (Row) | Metadaten + SHA-256 | Purge der Seite/des Ponds |
|
||||
|
||||
**Auf Volumes:**
|
||||
|
||||
| Ort | Inhalt | Lebenszyklus |
|
||||
| --------- | -------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `uploads` | Attachment-Bytes | Purge löscht Datei + Quota; Orphan-Sweep (#194) räumt Verwaiste |
|
||||
| `backups` | nächtliche Restore-Sets (Dump + Volume-Tars) | `BACKUP_RETENTION_DAYS` |
|
||||
| `plugins` | Plugin-Pakete (kein Seiteninhalt) | Uninstall |
|
||||
| `secrets` | Secret-Store (SMTP etc., kein Seiteninhalt) | Betreiber |
|
||||
|
||||
**Außerhalb der Instanz (nicht von Anwendungs-Löschung erreichbar —
|
||||
Löschkonzept muss sie benennen):**
|
||||
|
||||
| Ort | Inhalt | Kontrolle |
|
||||
| ----------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Backup-Spiegel (BASEL/WebDAV) | vollständige Restore-Sets | Betreiber; Retention auf Zielsystem |
|
||||
| Export-Artefakte | PDF/DOCX/ODT/ZIP beim Nutzer | organisatorisch (VS-Handhabung, Kennzeichnung M26) |
|
||||
| Browser-IndexedDB | Offline-Kopie zuletzt geöffneter Seiten | Endgeräteschutz (Restrisiko I-25) |
|
||||
| Feed-Reader / API-Konsumenten | abonnierte Inhalte | organisatorisch; Referenzkonfiguration schaltet Feeds/API ab |
|
||||
| Mails beim Empfänger | Digest-/Benachrichtigungstexte (Titel) | Restrisiko I-23; Referenzkonfiguration ohne SMTP |
|
||||
| Container-stdout-Logs | Metadaten inkl. Audit-Zeilen (nie Seiteninhalt, security.md §Logging) | Docker json-file mit Rotation; Collector des Betreibers |
|
||||
|
||||
## 6 Kryptografie-Inventar (Bestand, kein Anspruch)
|
||||
|
||||
Zur Einordnung (vollständige Liste: Abgrenzungserklärung §3): Argon2id
|
||||
für Passwort-Hashes, HMAC-signierte Kurzzeit-Tokens über `jose` mit
|
||||
HKDF-Zweckableitung (#188), SHA-256-Integritätshashes für Attachments
|
||||
(#199, fail-closed beim Download). Keine Inhalts- oder
|
||||
Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
|
||||
(§52 VSA).
|
||||
210
docs/vs-nfd/70-betriebshandbuch.md
Normal file
210
docs/vs-nfd/70-betriebshandbuch.md
Normal file
@ -0,0 +1,210 @@
|
||||
# Betriebshandbuch (VS-NfD, Issue #229)
|
||||
|
||||
Zweck: der Betreiber führt die Instanz ohne uns — auch an dem Tag, an
|
||||
dem etwas ausfällt. Dieses Handbuch bündelt die betrieblichen
|
||||
Prozeduren und verweist für Schrittfolgen auf die maßgeblichen
|
||||
Runbooks im Repository, statt sie zu duplizieren. Das Kapitel mit dem
|
||||
größten VS-Gewicht ist **§5 Löschung und Vernichtung**; es baut auf der
|
||||
vollständigen Kopienliste der Sicherheitsdokumentation auf
|
||||
(`60-sicherheitsdokumentation.md` §5).
|
||||
|
||||
**Belegstufen.** Jede Prozedur nennt am Ende ihren Erprobungsstand:
|
||||
|
||||
- ✅ _erprobt_ — vom Autor bzw. automatisiert mindestens einmal real
|
||||
ausgeführt, mit Beleg.
|
||||
- ⚠️ _Mechanik vorhanden, nicht geprobt_ — implementiert und getestet,
|
||||
aber noch nie im Ernstfall/als Übung durchgespielt.
|
||||
- ⏳ _offen_ — kommt mit dem genannten Issue.
|
||||
|
||||
---
|
||||
|
||||
## 1 Installation
|
||||
|
||||
Referenz: `deploy/stages.md` (maßgebliche Schrittfolge),
|
||||
`docs/self-hosting/README.md`.
|
||||
|
||||
1. Host-Voraussetzungen: Docker + Compose, ein Reverse Proxy mit TLS
|
||||
(oder das mitgelieferte `caddy`-Compose-Profil).
|
||||
2. `deploy/compose/docker-compose.yml` + `.env` (Vorlage
|
||||
`.env.example`; **niemals** eine echte `.env` ins Repo — CI-Zaun
|
||||
#198) auf den Host bringen; `COMPOSE_PROJECT_NAME`, Ports
|
||||
(`WEB_PORT`/`API_PORT`/`COLLAB_PORT`), `POSTGRES_PASSWORD`, Secrets
|
||||
setzen.
|
||||
3. `docker compose pull && docker compose up -d` — die api wendet
|
||||
Migrationen beim Start selbst an (`MIGRATE_ON_START`); es gibt
|
||||
keinen separaten Migrationsschritt.
|
||||
4. Erststart: der Setup-Wizard (Issue #80) legt das Admin-Konto an und
|
||||
verriegelt sich danach dauerhaft; bis zum Abschluss ist nur
|
||||
`/setup/*` erreichbar.
|
||||
5. Reverse Proxy: `/` → web, `/api` → api, `/collab` → collab mit
|
||||
WebSocket-Upgrade (Portliste: Sicherheitsdokumentation §2).
|
||||
6. Für VS-NfD-Betrieb anschließend die Referenzkonfiguration aus dem
|
||||
Härtungsleitfaden (`50-haertungsleitfaden.md`) anwenden und mit
|
||||
dessen Checkliste verifizieren.
|
||||
|
||||
Belegstufe: ✅ erprobt — die Stages Test/Int/Prod auf ONE sind exakt
|
||||
nach dieser Prozedur aufgesetzt und laufen produktiv (`deploy/stages.md`
|
||||
dokumentiert die realen Instanzen).
|
||||
|
||||
**Airgap-/Offline-Variante:** ⏳ offen — Mirror-Verfahren (#218),
|
||||
netzloser Build (#219), Testlauf in isolierter Umgebung (#220),
|
||||
Offline-Update-Pfad (#221); Meilenstein M28. Bereits vorhanden als
|
||||
Grundlage: alle Dritt-Images digest-gepinnt (#203), ein authoritativer
|
||||
Node-Pin (#236), SBOMs je Release (#202).
|
||||
|
||||
## 2 Update und Rollback
|
||||
|
||||
Referenz: `docs/architecture/operations.md` §Update strategy,
|
||||
`deploy/stages.md`.
|
||||
|
||||
- **Stages:** Merge auf `main` → CD baut Images, deployt Test, führt
|
||||
Smoke-Tests aus, promotet Int. CD synct **keine** Compose-Dateien —
|
||||
Compose-Änderungen werden von Hand auf den Host übernommen, sonst
|
||||
räumt `--remove-orphans` manuell ergänzte Container ab.
|
||||
Belegstufe: ✅ erprobt (läuft bei jedem Merge; zuletzt CD-Lauf 568).
|
||||
- **Produktion:** ausschließlich getaggte Releases. `git tag vX.Y.Z`
|
||||
baut die Versions-Images und führt die Update-Simulation als Gate
|
||||
aus; `git tag prod-vX.Y.Z-initial` pinnt die Version in der
|
||||
Prod-`.env` und rollt aus (readyz-Poll). Migrationen laufen beim
|
||||
api-Start; **Rückwärtsmigrationen gibt es nicht** — Rollback setzt
|
||||
deshalb ein DB-kompatibles Vorgängerimage voraus (Migrations-Kaveat;
|
||||
im Zweifel Restore aus dem Backup-Set der Vorversion).
|
||||
Belegstufe: ✅ erprobt (alle Prod-Versionen bis v0.12.0 so
|
||||
ausgerollt).
|
||||
- **Rollback:** `git tag prod-v<prev>-rollback1` pinnt die Vorversion
|
||||
zurück. Belegstufe: ⚠️ Mechanik vorhanden, nicht geprobt — ein realer
|
||||
Rollback war bisher nie nötig; die Übung steht aus.
|
||||
- **Dritt-Image-Digest heben:** Prozedur `deploy/stages.md` §5a
|
||||
(imagetools inspect → Compose-Referenz ändern → CI bestätigt → Stage-
|
||||
Composes von Hand nachziehen → `docker inspect` verifiziert).
|
||||
Belegstufe: ✅ erprobt (Rollout #203 am 31.07.2026).
|
||||
- **Offline-Update:** ⏳ offen (#221, M28).
|
||||
|
||||
## 3 Backup und Restore
|
||||
|
||||
Referenz: `docs/operations/restore-runbook.md` (maßgeblich),
|
||||
`deploy/backup-basel.md`, ADR 0015/0026.
|
||||
|
||||
- Nächtlich erzeugt der backup-Sidecar ein konsistentes Restore-Set
|
||||
(`pg_dump -Fc` + Tar der Volumes `uploads`/`plugins`) auf dem
|
||||
`backups`-Volume; Retention `BACKUP_RETENTION_DAYS` (Default 30,
|
||||
Test/Int 7). Status in `status.json`, Fehler alarmieren per Mail
|
||||
direkt via SMTP (bewusst nicht über die api — sie könnte das kaputte
|
||||
Teil sein).
|
||||
- **Zielbeschränkung (#192, ADR 0026):** Fernziele (rsync-Spiegel,
|
||||
WebDAV) funktionieren nur gegen Hosts der **Deploy-Allowlist**
|
||||
`BACKUP_ALLOWED_TARGETS`; eine leere Allowlist schaltet alle
|
||||
Fernziele hart ab. Die Allowlist ist bewusst NICHT über die Admin-UI
|
||||
änderbar (Rollentrennung, §6).
|
||||
- **Restore:** `deploy/backup/restore.sh` fährt den Stack kontrolliert
|
||||
herunter, spielt Dump + Volumes zurück und startet neu; Details und
|
||||
Totalverlust-Szenario im Runbook.
|
||||
- **Geprobter Restore:** ✅ erprobt — ein automatischer monatlicher
|
||||
Restore-Drill (`drill-*`-Tag → `drill.yml`) stellt das jüngste
|
||||
Prod-Set in einer Wegwerf-Umgebung wieder her und prüft Inhalte; das
|
||||
Protokoll jedes Laufs steht als Kommentar an Issue #98.
|
||||
Backups sind unverschlüsselt by design — Datenträgerschutz ist
|
||||
Plattformsache (Abgrenzungserklärung).
|
||||
|
||||
## 4 Wartungsjobs (Scheduler)
|
||||
|
||||
Alle Jobs laufen in der api (in-app Scheduler, `jobs`-Tabelle), sind im
|
||||
Site-Admin-Systempanel sichtbar und dort manuell auslösbar (auditiert
|
||||
als `job.triggered`). Aktueller Bestand (9):
|
||||
|
||||
| Job | Rhythmus | Wirkung |
|
||||
| -------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `trash-purge` | täglich | endgültiges Löschen abgelaufener Papierkorb-Seiten **und** -Teiche (`trash.retentionDays`, #31/#193) |
|
||||
| `orphan-file-sweep` | täglich | verwaiste Uploads entfernen (#194); trägt nachts auch den SHA-256-Backfill (#199) |
|
||||
| `page-compaction` | stündlich | `page_updates`-Log in den Zustand mergen |
|
||||
| `version-thinning` | täglich | automatische Versions-Snapshots ausdünnen |
|
||||
| `audit-retention` | täglich | `audit_log` nach `audit.retentionDays` beschneiden (#196), Lücke selbst auditiert (`audit.pruned`) |
|
||||
| `conversion-payload-prune` | täglich | Import-/Export-Payloads fertiger Jobs nullen (#233) |
|
||||
| `mail-outbox-retention` | täglich | SENT/endgültig FAILED Outbox-Zeilen löschen (#234) |
|
||||
| `data-export-purge` | stündlich | abgelaufene DSGVO-Datenexporte entfernen (#68) |
|
||||
| `notification-digest` | alle 15 min (Versand nach Nutzer-Präferenz) | Benachrichtigungs-Digests versenden |
|
||||
|
||||
Der e2e-Zaun `apps/web/e2e/system.spec.ts` pinnt diese Zahl — ein
|
||||
neuer Job ohne Handbuch-/Zaun-Anpassung wird rot.
|
||||
|
||||
## 5 Löschung und Vernichtung
|
||||
|
||||
Grundsatz: „Gelöscht" heißt in Dorfteich erst dann gelöscht, wenn alle
|
||||
Kopien aus `60-sicherheitsdokumentation.md` §5 erreicht sind. Die
|
||||
Tabelle nennt je Inhaltstyp, was die Löschung tut, welche Kopien sie
|
||||
erreicht und was **stehen bleibt**.
|
||||
|
||||
| Vorgang | Wirkung | erreichte Kopien | Rückstände / Fristen |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Seite in den Papierkorb | Soft-Delete (`deleted_at`), aus Suchindex entfernt (#195) | Suchvektor sofort | Inhalt vollständig vorhanden, wiederherstellbar bis Purge |
|
||||
| Seiten-Purge (automatisch nach `trash.retentionDays`, Default 30, oder manuell) | löscht Zustand, Update-Log, Content-Cache, Versionen, Kommentare, Attachments (Bytes + Quota), Watches; Kinder rücken auf | DB-Zeilen + `uploads`-Bytes | `page_links.target_slug` bleibt bewusst (#235, Restrisiko I-24); Backups/Spiegel bis Ablauf ihrer Retention; Export-/Endgeräte-Kopien organisatorisch |
|
||||
| Teich-Purge (#193, automatisch/manuell, Site-Admin) | löscht alle Seiten samt Kaskade, Labels, Grants, Nutzungszähler, Plugin-Opt-ins, Conversion-Jobs, Files | wie oben, teichweit | wie oben |
|
||||
| Nutzer löschen / DSGVO | Konto löschen bzw. Autorschaft pseudonymisieren (`user.pseudonymized`) | Identitätsdaten | von ihm erstellte Inhalte gehören dem Teich |
|
||||
| Export-Payloads | `conversion-payload-prune` nullt `input`/`result` fertiger Jobs (Default 30 d) | DB | Job-Zeile bleibt für Status/Audit |
|
||||
| Mail-Kopien | `mail-outbox-retention` löscht SENT/endgültig FAILED (Default 30 d) | DB | zugestellte Mails beim Empfänger (Restrisiko I-23) |
|
||||
| Backup-Sets | Retention des Sidecars bzw. des Spiegels | `backups`-Volume, Spiegel | ein gelöschter Inhalt lebt maximal bis zum Ablauf der längsten Backup-Retention weiter — bei sofortigem Vernichtungsbedarf Sets manuell löschen (Host-Zugriff) und Spiegel bereinigen |
|
||||
| Audit-Trail | `audit-retention` (Default 365 d) | DB | Metadaten, nie Inhalt |
|
||||
|
||||
**Sofortige Vernichtung einzelner Inhalte** (über die Fristen hinaus):
|
||||
Seite manuell purgen (Papierkorb → endgültig löschen), danach auf dem
|
||||
Host die Backup-Sets der Aufbewahrungskette löschen bzw. den Spiegel
|
||||
bereinigen und Endgeräte-/Exportkopien organisatorisch einsammeln.
|
||||
Belegstufe Purge-Pfade: ✅ erprobt (laufen täglich produktiv; Purge-
|
||||
Semantik durch Tests gepinnt). Belegstufe „Backup-Kette manuell
|
||||
vernichten": ⚠️ nicht geprobt.
|
||||
|
||||
**Außerbetriebnahme einer Instanz:** `docker compose down -v` entfernt
|
||||
Container und **alle benannten Volumes** (`db-data`, `uploads`,
|
||||
`plugins`, `secrets`, `backups`, Caddy-Volumes); anschließend Spiegel-
|
||||
Bestände löschen und Host-Datenträger nach Betreiber-Vorgabe
|
||||
vernichten — die physische Vernichtung ist Plattformsache
|
||||
(Abgrenzungserklärung). Belegstufe: ✅ erprobt für den Stack-Teil (der
|
||||
stillgelegte Alt-VPS wurde so zurückgebaut; Datenträger dort noch als
|
||||
Rollback-Reserve vorhanden).
|
||||
|
||||
## 6 Rollentrennung
|
||||
|
||||
| Aufgabe | braucht |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| Instanz-Settings, Nutzer-/Quota-Verwaltung, Plugins, Wartungsjobs anstoßen, Teich-Purge, Audit-Panel | **Site-Admin** (App-Rolle) |
|
||||
| Deploy, `.env`/Compose ändern, Backup-Allowlist, Secrets-Volume, Restore, Backup-Sets vernichten, DB-Direktzugriff, Log-Collector | **Plattformzugriff** (Host) |
|
||||
| Release nach Prod | Plattformzugriff + Freigabeprozess (Tag) |
|
||||
|
||||
Was ein Site-Admin **nicht** kann (bewusste Grenzen):
|
||||
|
||||
- die Backup-Zielliste ändern oder Fernziele aktivieren
|
||||
(`BACKUP_ALLOWED_TARGETS` ist Deploy-Konfiguration, ADR 0026);
|
||||
- Secrets lesen oder setzen, die im Secret-Store/der `.env` liegen
|
||||
(die Settings-Tabelle trägt nie Secrets, security.md §Secrets);
|
||||
- Deploy-/Composeänderungen, Image-Versionen, Migrationen auslösen;
|
||||
- den Audit-Trail editieren (nur lesen; Retention läuft als Job und
|
||||
protokolliert sich selbst);
|
||||
- Backups herunterladen ist möglich (In-App-Restore-Pfad, #103), aber
|
||||
ihre Vernichtung auf Host/Spiegel nicht.
|
||||
|
||||
Zu beachten: ein Site-Admin **liest** per Rollen-Bypass jeden Inhalt
|
||||
der Instanz (permissions.md) — die Trennung „wer darf Inhalte sehen"
|
||||
von „wer betreibt die Plattform" ist damit App-seitig bewusst NICHT
|
||||
absolut; wo das nicht tragbar ist, ist es organisatorisch zu regeln
|
||||
(Vier-Augen-Prinzip bei Vergabe der Site-Admin-Rolle). Der Lesetrail
|
||||
für eingestufte Inhalte (M29, #222–#225) macht solche Zugriffe
|
||||
nachvollziehbar.
|
||||
|
||||
## 7 Monitoring und Störung
|
||||
|
||||
Referenz: `deploy/monitoring.md`, `docs/architecture/operations.md`
|
||||
§Health & monitoring.
|
||||
|
||||
- `GET /api/v1/readyz` prüft DB, Migrationen, Konverter, Renderer,
|
||||
Backup-Status; `healthz` je Container. Externe Überwachung der
|
||||
Referenzinstanzen: Uptime-Kuma mit Alarmierung.
|
||||
- Logs: alle Dienste loggen JSON auf stdout (Docker json-file mit
|
||||
Rotation); Audit-Ereignisse mit `msg`-Präfix `audit: ` und
|
||||
`severity`-Feld — Weiterleitung an SIEM/Syslog übernimmt der
|
||||
Collector des Betreibers (Katalog:
|
||||
`docs/architecture/audit-events.md`, v1.1, mit
|
||||
Kompatibilitätsversprechen).
|
||||
- Integritätsalarm: `file.integrity_failed` (critical) = Download-Hash
|
||||
≠ Upload-Hash → Objekt als manipuliert/korrupt behandeln, Datei aus
|
||||
Backup-Set wiederherstellen (Runbook), erneut laden; der
|
||||
Audit-Eintrag trägt beide Hashes.
|
||||
154
docs/vs-nfd/90-restrisiken.md
Normal file
154
docs/vs-nfd/90-restrisiken.md
Normal file
@ -0,0 +1,154 @@
|
||||
# Restrisikoliste (VS-NfD, Issue #231)
|
||||
|
||||
Zweck: benennen, was **bewusst** offen bleibt. Ein Prüfer, der eine
|
||||
Lücke hier bereits verzeichnet findet, kann ihr zustimmen; eine
|
||||
unverzeichnete Lücke diskreditiert die gesamte Einreichung. Jeder
|
||||
Eintrag nennt das Risiko, warum es akzeptiert ist, die kompensierende
|
||||
Kontrolle und wer entschieden hat.
|
||||
|
||||
**Pflegeregel (verbindlich):** Schließt ein Issue mit einem wissentlich
|
||||
offenen Rest, wird diese Liste **im selben PR** ergänzt. Die formale
|
||||
Billigung der Einträge durch den jeweiligen Betreiber ist Teil seiner
|
||||
Risikoübernahme (Abgrenzungserklärung §8) — Bewertung/Scoring ist
|
||||
bewusst nicht Teil dieser Liste.
|
||||
|
||||
Entscheidungsvermerk: „Projektleitung" = Stefan Waidele;
|
||||
Entscheidungen sind über die genannten PRs/Issues/ADRs im Repository
|
||||
nachvollziehbar.
|
||||
|
||||
---
|
||||
|
||||
## R-01 Attachment-Inhalt trägt keine interne Kennzeichnung
|
||||
|
||||
- **Risiko:** Eine heruntergeladene, umbenannte Datei (bzw. eine ohne
|
||||
Begleitdatei weitergegebene) ist ein eingestuftes Binärobjekt ohne
|
||||
sichtbare Kennzeichnung.
|
||||
- **Warum akzeptiert:** Kennzeichnung **in** beliebige Binärformate zu
|
||||
schreiben hieße, fremde Formate umzuschreiben — ausgeschlossen durch
|
||||
ADR 0019/0022 (keine Übernahme von Grundfunktions-/Formatgarantien).
|
||||
- **Kompensation:** Download-Präfix `VS-NfD_` + Begleitdatei/Manifest
|
||||
im ZIP (#212); alle internen Darstellungen und alle übrigen
|
||||
Exportkanäle kennzeichnen selbst (#206–#211); Upload-Warnung bzw.
|
||||
serverseitiger Block (#213); organisatorische VS-Handhabung beim
|
||||
Empfänger.
|
||||
- **Entscheidung:** Projektleitung, PR #271 / Issue #212, 31.07.2026.
|
||||
|
||||
## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar
|
||||
|
||||
- **Risiko:** Bis #216 (M27) existiert kein Schalter
|
||||
`auth.local.enabled=false`; die Anwendung führt eigene
|
||||
Passwort-Konten, obwohl die Behördenumgebung Fremdauthentisierung
|
||||
vorsieht. Zusätzlich ist noch offen, ob der Schalter zur Laufzeit
|
||||
umschaltbar sein wird oder einen Neustart verlangt — das entscheidet
|
||||
#216 und trägt es hier nach.
|
||||
- **Warum akzeptiert:** Reihenfolge der Umsetzung (M26 vor M27);
|
||||
produktiver VS-NfD-Betrieb beginnt erst nach M27.
|
||||
- **Kompensation:** Referenzkonfiguration (`50-haertungsleitfaden.md`):
|
||||
Registrierung geschlossen, kurze Sessions (12 h absolut / 2 h idle),
|
||||
Argon2id-Hashes, Rate-Limits, Audit der Anmeldungen.
|
||||
- **Entscheidung:** Projektleitung, Maßnahmenplan Rev. 2 (M27-Planung),
|
||||
30.07.2026.
|
||||
|
||||
## R-03 Plugin-Hash-Pinning verschoben
|
||||
|
||||
- **Risiko:** Installierte Plugin-Pakete sind nicht gegen einen
|
||||
festgeschriebenen Hash verankert (#232); ein manipuliertes Paket
|
||||
gleichen Namens wäre beim Neuinstallieren nicht erkennbar.
|
||||
- **Warum akzeptiert:** Die Referenzkonfiguration betreibt Plugins
|
||||
**gar nicht** (`plugins.enabled=false`, #200) — der Kill-Switch deckt
|
||||
den VS-NfD-Betriebsmodus vollständig; Pinning lohnt erst, wenn ein
|
||||
Betreiber Plugins tatsächlich freigibt.
|
||||
- **Kompensation:** Kill-Switch (alle Plugin-Flächen 404, Dropzone
|
||||
quarantänisiert); Sandbox mit Capability-Modell + CI-Escape-
|
||||
Regressionstest (ADR 0008/0025); Install nur durch Site-Admin.
|
||||
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232, 30.07.2026.
|
||||
|
||||
## R-04 Git-Historie: einmalige Secret-Prüfung mit begrenztem Muster
|
||||
|
||||
- **Risiko:** Die einmalige Prüfung der gesamten Repository-Historie
|
||||
(#198, 30.07.2026) fand **keine** getrackten `.env`-Dateien und
|
||||
**keine** Treffer der Muster Private-Key-Block / AKIA / ghp\_ /
|
||||
glpat- / xox[baprs]-. Unstrukturierte Passwörter als schlichte
|
||||
Strings würde ein generisches Muster jedoch nicht finden.
|
||||
- **Warum akzeptiert:** Ein Negativbeweis über beliebige Strings ist
|
||||
nicht führbar; das strukturierte Muster deckt die realistischen
|
||||
Token-Formate.
|
||||
- **Kompensation:** CI-Zaun „No tracked .env files or secret material"
|
||||
auf jedem PR (#198) hält beide Invarianten ab jetzt; Review-Disziplin
|
||||
- Regel „Passwörter nie als CLI-Argument/Commit".
|
||||
- **Entscheidung:** Projektleitung, Issue #198, 30.07.2026 (Protokoll
|
||||
als Kommentar auf #231).
|
||||
|
||||
## R-05 Digest-/Benachrichtigungs-Mails tragen Seitentitel (I-23)
|
||||
|
||||
- **Risiko:** Zugestellte Mails enthalten Titel (Inhalts-Metadaten)
|
||||
eingestufter Seiten und liegen beim Empfänger außerhalb der Instanz.
|
||||
- **Warum akzeptiert:** Es gab bis M26 kein Einstufungs-Metadatum für
|
||||
eine Unterdrückung; die titelbasierte Unterdrückung wird in M32
|
||||
(#243–#246, `VS_NFD_MODE`) revisitiert.
|
||||
- **Kompensation:** Kopie zeitlich begrenzt
|
||||
(`mail.outboxRetentionDays`, #234); Referenzkonfiguration lässt SMTP
|
||||
unkonfiguriert — dann verlässt kein Titel die Instanz per Mail
|
||||
(`50-haertungsleitfaden.md` §1.2).
|
||||
- **Entscheidung:** Projektleitung, PR #255 / Issue #234, 30.07.2026;
|
||||
Revisit-Marker M32.
|
||||
|
||||
## R-06 `page_links.target_slug` überlebt den Ziel-Purge (I-24)
|
||||
|
||||
- **Risiko:** Nach dem endgültigen Löschen einer Seite bleibt ihr Slug
|
||||
(≈ Titel) als Linkziel-Text in den Zeilen verweisender Seiten.
|
||||
- **Warum akzeptiert:** Der Slug steht ohnehin sichtbar im Inhalt der
|
||||
verweisenden Seite (deren Autor das Ziel lesen durfte); die Zeile zu
|
||||
löschen entfernte nichts Sichtbares, bräche aber die gewollte
|
||||
Phantom-Link-Reauflösung.
|
||||
- **Kompensation:** Zugriff auf die verweisende Seite bleibt
|
||||
permissions-geprüft; vollständige Begründung in
|
||||
`docs/architecture/operations.md` (Purge-Abschnitt).
|
||||
- **Entscheidung:** Projektleitung, PR #256 / Issue #235, 30.07.2026.
|
||||
|
||||
## R-07 Offline-Kopie im Browser (IndexedDB, I-25)
|
||||
|
||||
- **Risiko:** Der Editor hält zuletzt geöffnete Seiten als
|
||||
Yjs-Offline-Kopie in der IndexedDB des Endgeräts
|
||||
(`apps/web/src/editor/use-collab-provider.ts`); sie übersteht
|
||||
Browser-Crashes und existiert für unsynchronisierte Offline-Edits.
|
||||
- **Warum akzeptiert:** Die Kopie ist Voraussetzung für
|
||||
verlustfreies kollaboratives Arbeiten (CRDT) und liegt in der
|
||||
Endgeräte-Zone, deren Schutz (Festplattenverschlüsselung,
|
||||
Gerätekontrolle) nach Abgrenzungserklärung ohnehin der Plattform-
|
||||
bzw. Organisationsverantwortung zufällt.
|
||||
- **Kompensation:** VS-Endgeräte-Vorgaben des Betreibers
|
||||
(Datenträgerverschlüsselung, kontrollierte Geräte); kurze Sessions
|
||||
(Referenzkonfiguration) begrenzen den angemeldeten Zeitraum.
|
||||
- **Entscheidung:** Projektleitung, Ist-Aufnahme I-25
|
||||
(`10-ist-aufnahme.md`), 29.07.2026.
|
||||
|
||||
## R-08 Bewusst nicht geplante Funktionen
|
||||
|
||||
- **Risiko/Inhalt:** Externe Suchengine (Suche bleibt in Postgres),
|
||||
Admin-Freigabe von Selbstregistrierungen, Plugin-Netzwerk-Allowlist —
|
||||
bewusst unscheduled.
|
||||
- **Warum akzeptiert:** Kein Bedarf im Zielbetrieb: die
|
||||
Referenzkonfiguration schließt Selbstregistrierung und Plugins ohnehin
|
||||
aus; die interne Suche vermeidet einen weiteren Dienst mit
|
||||
Inhaltskopie (die Kopienliste bliebe sonst nicht klein).
|
||||
- **Kompensation:** entfällt (kein zusätzliches Risiko gegenüber dem
|
||||
Status quo; Wiedervorlage bei realem Bedarf).
|
||||
- **Entscheidung:** Projektleitung, Projektplanung (dokumentiert im
|
||||
Workspace-Handoff), Stand 07/2026.
|
||||
|
||||
## R-09 Site-Admin liest jeden Inhalt (Rollen-Bypass)
|
||||
|
||||
- **Risiko:** Die App-Rolle Site-Admin umgeht die Permission-Resolution
|
||||
vollständig (permissions.md) — App-seitig gibt es keine absolute
|
||||
Trennung zwischen Plattform-Betrieb und Inhalts-Kenntnisnahme.
|
||||
- **Warum akzeptiert:** Ein Admin ohne Durchgriff könnte zentrale
|
||||
Pflichten (Purge, Quoten, Störungsanalyse) nicht erfüllen; eine
|
||||
echte Trennung wäre eine neue Grundfunktion (ADR 0019).
|
||||
- **Kompensation:** organisatorisch (Vergabe der Rolle nach
|
||||
Need-to-know, Vier-Augen-Prinzip); Lesetrail für eingestufte Inhalte
|
||||
macht Zugriffe nachvollziehbar (M29, #222–#225 — bis dahin trägt der
|
||||
bestehende Audit-Trail nur Verwaltungsereignisse); Betriebshandbuch
|
||||
§6 benennt die Grenze ausdrücklich.
|
||||
- **Entscheidung:** Projektleitung, permissions.md-Design (Issue #51)
|
||||
bzw. Aufnahme hier, 31.07.2026.
|
||||
@ -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",
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import type { ApiTokenScope } from './api-tokens';
|
||||
import type { CommentView } from './comments';
|
||||
import type { LabelTreeNode, LabelView } from './labels';
|
||||
import type { PageClassification } from './pages';
|
||||
|
||||
/**
|
||||
* Wire types of the public REST API (`/api/public/v1`, issue #104). The
|
||||
@ -29,6 +30,8 @@ export interface PublicPondView {
|
||||
export interface PublicPageListItemView {
|
||||
slug: string;
|
||||
title: string;
|
||||
/** VS-NfD level (issue #211, ADR 0022) — see `docs/self-hosting/public-api.md`. */
|
||||
classification: PageClassification;
|
||||
/** Parent page slug in the tree (issue #110), or null at the root — nulled
|
||||
* as well when the token's user may not read the parent (no existence leak). */
|
||||
parent: string | null;
|
||||
@ -41,6 +44,8 @@ export interface PublicPageView {
|
||||
slug: string;
|
||||
title: string;
|
||||
pondSlug: string;
|
||||
/** VS-NfD level (issue #211, ADR 0022) — see `docs/self-hosting/public-api.md`. */
|
||||
classification: PageClassification;
|
||||
/** Parent page slug (issue #110); see {@link PublicPageListItemView.parent}. */
|
||||
parent: string | null;
|
||||
markdown: string;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { PageClassification } from './pages';
|
||||
|
||||
/**
|
||||
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
|
||||
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
|
||||
@ -49,4 +51,7 @@ export interface SearchResultView {
|
||||
labelIds: string[];
|
||||
/** Snippet with matches wrapped in the highlight sentinels above. */
|
||||
snippet: string;
|
||||
/** VS-NfD level (issue #211, ADR 0022): a snippet of a classified page is
|
||||
* never shown unmarked — the UI renders the marking with every hit. */
|
||||
classification: PageClassification;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user