Compare commits
4 Commits
1cf0458593
...
f0c6af4412
| Author | SHA1 | Date | |
|---|---|---|---|
| f0c6af4412 | |||
| 868b79c8bc | |||
| e505fc74dc | |||
| 521ea514b4 |
@ -674,13 +674,16 @@ jobs:
|
|||||||
# image has no iproute2). Sharing the netns means no published ports.
|
# image has no iproute2). Sharing the netns means no published ports.
|
||||||
- name: Start pinned pandoc + Gotenberg sidecars
|
- name: Start pinned pandoc + Gotenberg sidecars
|
||||||
run: |
|
run: |
|
||||||
# Clear any leftovers from an earlier interrupted run so the named
|
# Sidecar names carry THIS job container's id: parallel runs on the
|
||||||
# containers never collide, and nothing leaks on the shared host.
|
# shared host must not collide on a fixed name (a fixed-name rm -f
|
||||||
docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
# here even killed a sibling run's live sidecars — run 547).
|
||||||
JOB_ID=$(cat /etc/hostname)
|
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
|
--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
|
--network "container:${JOB_ID}" gotenberg/gotenberg:8
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
curl -sf http://localhost:3030/version >/dev/null && break
|
curl -sf http://localhost:3030/version >/dev/null && break
|
||||||
@ -704,15 +707,15 @@ jobs:
|
|||||||
- name: Dump sidecar logs on failure
|
- name: Dump sidecar logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
|
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true
|
||||||
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 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
|
# Always tear the sidecars down — they run on the shared runner host, so a
|
||||||
# leaked (especially Chromium-backed Gotenberg) container would waste its
|
# 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
|
- name: Stop sidecars
|
||||||
if: always()
|
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:
|
images:
|
||||||
name: Build container images
|
name: Build container images
|
||||||
|
|||||||
@ -90,14 +90,17 @@ export class FilesController {
|
|||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): 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');
|
response.set('X-Content-Type-Options', 'nosniff');
|
||||||
// Attachments are immutable — a new upload always gets a new id.
|
// Attachments are immutable — a new upload always gets a new id.
|
||||||
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
||||||
const kind = inline ? 'inline' : 'attachment';
|
const kind = inline ? 'inline' : 'attachment';
|
||||||
return new StreamableFile(stream, {
|
return new StreamableFile(stream, {
|
||||||
type: attachment.mimeType,
|
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}`);
|
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 () => {
|
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
|
||||||
const page = await api()
|
const page = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@ -13,7 +14,9 @@ import {
|
|||||||
AttachmentListItemView,
|
AttachmentListItemView,
|
||||||
AttachmentView,
|
AttachmentView,
|
||||||
PondFilesView,
|
PondFilesView,
|
||||||
|
PageClassification,
|
||||||
SVG_MIME_TYPE,
|
SVG_MIME_TYPE,
|
||||||
|
classificationFilenamePrefix,
|
||||||
fileExtension,
|
fileExtension,
|
||||||
isImageMimeType,
|
isImageMimeType,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
@ -36,6 +39,11 @@ export interface FileDownload {
|
|||||||
* else — office files, PDFs, and SVG — is always sent as a download so it
|
* else — office files, PDFs, and SVG — is always sent as a download so it
|
||||||
* can never execute inline (ADR 0011, security.md §Uploads). */
|
* can never execute inline (ADR 0011, security.md §Uploads). */
|
||||||
inline: boolean;
|
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. */
|
/** What the upload bytes resolved to after allowlist + SVG handling. */
|
||||||
@ -198,6 +206,16 @@ export class FilesService {
|
|||||||
): Promise<AttachmentView> {
|
): Promise<AttachmentView> {
|
||||||
const page = await this.prisma.page.findFirst({ where: { id: pageId } });
|
const page = await this.prisma.page.findFirst({ where: { id: pageId } });
|
||||||
if (!page) throw new NotFoundException();
|
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);
|
return this.upload(user, page.pondId, file, page.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,13 +247,40 @@ export class FilesService {
|
|||||||
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
|
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const classification = await this.effectiveClassification(attachment);
|
||||||
return {
|
return {
|
||||||
attachment,
|
attachment,
|
||||||
stream: Readable.from(buffer),
|
stream: Readable.from(buffer),
|
||||||
inline: isImageMimeType(attachment.mimeType),
|
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
|
* Hash attachments that predate #199 (sha256 null), a bounded batch per
|
||||||
* nightly run until none remain — idempotent by construction (hashed rows
|
* 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:');
|
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 () => {
|
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.
|
// An attachment row with no file (data drift): upload then remove the bytes.
|
||||||
const image = await files.upload({ id: ownerId } as never, personalPondId, {
|
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 });
|
manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level });
|
||||||
}
|
}
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
const mediaLevel = mediaClassification.get(attachment.id) ?? 'unclassified';
|
||||||
manifestFiles.push({
|
manifestFiles.push({
|
||||||
path: `${prefix}media/${mediaNameById.get(attachment.id)!}`,
|
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
|
// The archive-level manifest (#210): every file with its level, and the
|
||||||
// highest level contained stated once — the bulk-egress channel stays
|
// highest level contained stated once — the bulk-egress channel stays
|
||||||
|
|||||||
@ -101,6 +101,13 @@ export function buildOpenApiDocument(): object {
|
|||||||
properties: {
|
properties: {
|
||||||
slug: { type: 'string' },
|
slug: { type: 'string' },
|
||||||
title: { 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: {
|
parent: {
|
||||||
type: ['string', 'null'],
|
type: ['string', 'null'],
|
||||||
description:
|
description:
|
||||||
@ -118,6 +125,14 @@ export function buildOpenApiDocument(): object {
|
|||||||
slug: { type: 'string' },
|
slug: { type: 'string' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
pondSlug: { 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: {
|
parent: {
|
||||||
type: ['string', 'null'],
|
type: ['string', 'null'],
|
||||||
description: 'Parent page slug; see PageListItem.parent.',
|
description: 'Parent page slug; see PageListItem.parent.',
|
||||||
|
|||||||
@ -327,6 +327,35 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
|
|||||||
.expect(404);
|
.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 () => {
|
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 markdown = '# Heading\n\nHello **world** from the API.\n';
|
||||||
const created = await pub()
|
const created = await pub()
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
pondFeatureEnabled,
|
pondFeatureEnabled,
|
||||||
pondSettingsSchema,
|
pondSettingsSchema,
|
||||||
type CommentListFilter,
|
type CommentListFilter,
|
||||||
|
type PageClassification,
|
||||||
type PageListQuery,
|
type PageListQuery,
|
||||||
type CreateCommentInput,
|
type CreateCommentInput,
|
||||||
type CreateLabelInput,
|
type CreateLabelInput,
|
||||||
@ -110,6 +111,7 @@ export class PublicApiService {
|
|||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
classification: item.classification,
|
||||||
parent: (item.parentId && slugById.get(item.parentId)) || null,
|
parent: (item.parentId && slugById.get(item.parentId)) || null,
|
||||||
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
|
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
@ -129,6 +131,9 @@ export class PublicApiService {
|
|||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
pondSlug,
|
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,
|
parent,
|
||||||
markdown: cache?.markdown ?? '',
|
markdown: cache?.markdown ?? '',
|
||||||
html: cache?.html ?? '',
|
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}`);
|
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 () => {
|
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);
|
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>');
|
expect(res.text).toContain('<title>Newer Page — Feed Pond</title>');
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PageClassification,
|
||||||
|
classificationMarking,
|
||||||
|
highestClassification,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import { Pond, User } from '@prisma/client';
|
import { Pond, User } from '@prisma/client';
|
||||||
|
|
||||||
import { PagesService } from '../pages/pages.service';
|
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. */
|
/** How many entries a feed carries — plenty for readers polling regularly. */
|
||||||
const FEED_ENTRIES = 30;
|
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 {
|
interface FeedEntry {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
updated: Date;
|
updated: Date;
|
||||||
summary?: string;
|
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}/api/v1/public/${pond.slug}/${page.slug}`
|
||||||
: `${baseUrl}/p/${pond.slug}/${page.slug}`,
|
: `${baseUrl}/p/${pond.slug}/${page.slug}`,
|
||||||
updated: new Date(page.updatedAt),
|
updated: new Date(page.updatedAt),
|
||||||
|
classification: page.classification,
|
||||||
}));
|
}));
|
||||||
return atomDocument({
|
return atomDocument({
|
||||||
id: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`,
|
id: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`,
|
||||||
@ -79,7 +91,7 @@ export class FeedService {
|
|||||||
const pond = await this.requireVisiblePond(user, pondSlug);
|
const pond = await this.requireVisiblePond(user, pondSlug);
|
||||||
const page = await this.prisma.page.findFirst({
|
const page = await this.prisma.page.findFirst({
|
||||||
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
|
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'))) {
|
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
@ -94,11 +106,13 @@ export class FeedService {
|
|||||||
user === null
|
user === null
|
||||||
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
|
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
|
||||||
: `${baseUrl}/p/${pond.slug}/${page.slug}`;
|
: `${baseUrl}/p/${pond.slug}/${page.slug}`;
|
||||||
|
const pageLevel = page.classification.toLowerCase() as PageClassification;
|
||||||
const entries = versions.map((version) => ({
|
const entries = versions.map((version) => ({
|
||||||
id: `urn:dorfteich:version:${version.id}`,
|
id: `urn:dorfteich:version:${version.id}`,
|
||||||
title: version.label ?? version.trigger.toLowerCase(),
|
title: version.label ?? version.trigger.toLowerCase(),
|
||||||
link,
|
link,
|
||||||
updated: version.createdAt,
|
updated: version.createdAt,
|
||||||
|
classification: pageLevel,
|
||||||
}));
|
}));
|
||||||
return atomDocument({
|
return atomDocument({
|
||||||
id: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}/feed.xml`,
|
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: {
|
function atomDocument(feed: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -125,6 +151,10 @@ function atomDocument(feed: {
|
|||||||
entries: FeedEntry[];
|
entries: FeedEntry[];
|
||||||
}): string {
|
}): string {
|
||||||
const updated = feed.entries[0]?.updated ?? new Date();
|
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
|
const entries = feed.entries
|
||||||
.map(
|
.map(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
@ -133,6 +163,7 @@ function atomDocument(feed: {
|
|||||||
` <title>${escapeHtml(entry.title)}</title>\n` +
|
` <title>${escapeHtml(entry.title)}</title>\n` +
|
||||||
` <link href="${escapeHtml(entry.link)}"/>\n` +
|
` <link href="${escapeHtml(entry.link)}"/>\n` +
|
||||||
` <updated>${entry.updated.toISOString()}</updated>\n` +
|
` <updated>${entry.updated.toISOString()}</updated>\n` +
|
||||||
|
categoryTag(entry.classification, ' ') +
|
||||||
(entry.summary ? ` <summary>${escapeHtml(entry.summary)}</summary>\n` : '') +
|
(entry.summary ? ` <summary>${escapeHtml(entry.summary)}</summary>\n` : '') +
|
||||||
` </entry>`,
|
` </entry>`,
|
||||||
)
|
)
|
||||||
@ -143,6 +174,7 @@ function atomDocument(feed: {
|
|||||||
` <id>${escapeHtml(feed.id)}</id>\n` +
|
` <id>${escapeHtml(feed.id)}</id>\n` +
|
||||||
` <title>${escapeHtml(feed.title)}</title>\n` +
|
` <title>${escapeHtml(feed.title)}</title>\n` +
|
||||||
` <link rel="self" href="${escapeHtml(feed.selfLink)}"/>\n` +
|
` <link rel="self" href="${escapeHtml(feed.selfLink)}"/>\n` +
|
||||||
|
categoryTag(highest === 'unclassified' ? undefined : highest, ' ') +
|
||||||
` <updated>${updated.toISOString()}</updated>\n` +
|
` <updated>${updated.toISOString()}</updated>\n` +
|
||||||
`${entries}\n` +
|
`${entries}\n` +
|
||||||
`</feed>\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; }
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
|
||||||
img { max-width: 100%; height: auto; }
|
img { max-width: 100%; height: auto; }
|
||||||
.public-page__pond { color: #64748b; font-size: 0.9rem; }
|
.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; }
|
pre { overflow-x: auto; }
|
||||||
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
|
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
|
||||||
font-size: 0.9rem; }
|
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');
|
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 () => {
|
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}`).expect(404);
|
||||||
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);
|
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { classificationMarking } from '@dorfteich/shared';
|
||||||
import type { PageClassification, PageCommentsView } from '@dorfteich/shared';
|
import type { PageClassification, PageCommentsView } from '@dorfteich/shared';
|
||||||
import { Page, Pond, User } from '@prisma/client';
|
import { Page, Pond, User } from '@prisma/client';
|
||||||
|
|
||||||
@ -194,6 +195,12 @@ export class PublicService {
|
|||||||
canonical: string,
|
canonical: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const content = await this.content(user, pondSlug, pageSlug);
|
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
|
// No session-dependent content: this document is identical for every viewer
|
||||||
// who may read the page (crawler-safe, cacheable). The shared shell adds
|
// who may read the page (crawler-safe, cacheable). The shared shell adds
|
||||||
// the legal footer links (issue #82) in the instance default locale.
|
// 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`,
|
`/api/v1/public/${encodeURIComponent(pondSlug)}/feed.xml`,
|
||||||
canonical,
|
canonical,
|
||||||
).toString(),
|
).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>
|
<h1>${escapeHtml(content.title)}</h1>
|
||||||
${content.html}`,
|
${content.html}
|
||||||
|
${banner}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
PageClassification,
|
||||||
SEARCH_HIGHLIGHT_END,
|
SEARCH_HIGHLIGHT_END,
|
||||||
SEARCH_HIGHLIGHT_START,
|
SEARCH_HIGHLIGHT_START,
|
||||||
SEARCH_RESULT_LIMIT,
|
SEARCH_RESULT_LIMIT,
|
||||||
@ -34,6 +35,7 @@ interface SearchRow {
|
|||||||
pondName: string;
|
pondName: string;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
snippet: string;
|
snippet: string;
|
||||||
|
classification: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -159,6 +161,7 @@ export class PostgresSearchProvider extends SearchProvider {
|
|||||||
|
|
||||||
const rows = await this.prisma.$queryRaw<SearchRow[]>(Prisma.sql`
|
const rows = await this.prisma.$queryRaw<SearchRow[]>(Prisma.sql`
|
||||||
SELECT p.id AS "pageId", p.title, p.slug, p.pond_id AS "pondId",
|
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",
|
po.slug AS "pondSlug", po.name AS "pondName",
|
||||||
COALESCE(
|
COALESCE(
|
||||||
ARRAY(SELECT pl.label_id FROM page_labels pl WHERE pl.page_id = p.id),
|
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,
|
pondName: row.pondName,
|
||||||
labelIds: row.labelIds,
|
labelIds: row.labelIds,
|
||||||
snippet: row.snippet,
|
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',
|
pondSlug: 'pond',
|
||||||
pondName: 'Pond',
|
pondName: 'Pond',
|
||||||
labelIds: [],
|
labelIds: [],
|
||||||
|
classification: 'unclassified',
|
||||||
snippet: 'a snippet',
|
snippet: 'a snippet',
|
||||||
};
|
};
|
||||||
const fake: SearchProvider = {
|
const fake: SearchProvider = {
|
||||||
|
|||||||
@ -90,6 +90,20 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
|||||||
expect(results[0]!.pageId).toBe(titleHit);
|
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 () => {
|
it('highlights the match in the snippet', async () => {
|
||||||
const results = await search.search({ q: term }, owner);
|
const results = await search.search({ q: term }, owner);
|
||||||
const bodyHit = results.find((r) => r.snippet.includes(SEARCH_HIGHLIGHT_START));
|
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
|
// parent page (#205) wins over this default. The marking is not a
|
||||||
// protection mechanism — permissions ignore it.
|
// protection mechanism — permissions ignore it.
|
||||||
'classification.newPageDefault': z.enum(['unclassified', 'vs_nfd']).default('unclassified'),
|
'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
|
// Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions
|
||||||
// without the dot. Images are always allowed regardless; SVG is governed
|
// without the dot. Images are always allowed regardless; SVG is governed
|
||||||
// by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so
|
// 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();
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Editor } from '@tiptap/react';
|
import type { Editor } from '@tiptap/react';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
@ -19,11 +19,16 @@ export function AttachmentsPanel({
|
|||||||
pageId,
|
pageId,
|
||||||
editor,
|
editor,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
classification,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
canEdit: boolean;
|
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;
|
onClose: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('files');
|
const { t } = useTranslation('files');
|
||||||
@ -96,6 +101,12 @@ export function AttachmentsPanel({
|
|||||||
|
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
|
|
||||||
|
{canEdit && classification === 'vs_nfd' && (
|
||||||
|
<p className="attachments-panel__warning" role="note">
|
||||||
|
{t('classifiedUploadWarning')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="attachments-panel__upload">
|
<div className="attachments-panel__upload">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -29,6 +29,7 @@ interface InstanceSettings {
|
|||||||
'upload.allowedExtensions': string[];
|
'upload.allowedExtensions': string[];
|
||||||
'upload.svgPolicy': 'reject' | 'sanitize';
|
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||||
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
|
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
|
||||||
|
'classification.uploadPolicy': 'warn' | 'block';
|
||||||
'legal.imprint': string;
|
'legal.imprint': string;
|
||||||
'legal.privacyPolicy': string;
|
'legal.privacyPolicy': string;
|
||||||
'home.content': string;
|
'home.content': string;
|
||||||
@ -101,6 +102,15 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
|
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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}>
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
{t('settings:admin.save')}
|
{t('settings:admin.save')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared';
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
@ -126,6 +131,8 @@ interface ResolvedPage {
|
|||||||
pondId: string;
|
pondId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** VS-NfD level (issue #213); undefined for the offline-cache fallback. */
|
||||||
|
classification?: PageClassification;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageEditor({
|
function PageEditor({
|
||||||
@ -305,6 +312,7 @@ function PageEditor({
|
|||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
classification={page.classification}
|
||||||
onClose={onCloseAttachments}
|
onClose={onCloseAttachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -421,7 +429,13 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null;
|
const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null;
|
||||||
|
|
||||||
const resolved: ResolvedPage | null = page.data
|
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
|
: offlineCached
|
||||||
? {
|
? {
|
||||||
id: offlineCached.pageId,
|
id: offlineCached.pageId,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { classificationMarking } from '@dorfteich/shared';
|
||||||
import type { PondView, SearchResultView } from '@dorfteich/shared';
|
import type { PondView, SearchResultView } from '@dorfteich/shared';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 {
|
export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||||
const { t } = useTranslation('search');
|
const { t } = useTranslation('search');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pondSlug } = useCurrentPondRoute();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -220,6 +222,14 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
|||||||
onClick={() => open(hit)}
|
onClick={() => open(hit)}
|
||||||
>
|
>
|
||||||
<span className="search-result__title">{hit.title}</span>
|
<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>
|
<span className="search-result__pond">{t('inPond', { pond: hit.pondName })}</span>
|
||||||
<LabelChips labelIds={hit.labelIds} byId={byId} />
|
<LabelChips labelIds={hit.labelIds} byId={byId} />
|
||||||
<span className="search-result__snippet">
|
<span className="search-result__snippet">
|
||||||
|
|||||||
@ -2809,6 +2809,16 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.search-result__snippet {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@ -3061,6 +3071,17 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
margin: 0;
|
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 {
|
.attachments-panel__upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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
|
labels _around_ it (e.g. an accessibility label naming the element) are
|
||||||
i18n'd. The single source is `classificationMarking()` in
|
i18n'd. The single source is `classificationMarking()` in
|
||||||
`@dorfteich/shared` (`packages/shared/src/pages.ts`); no output channel
|
`@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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@ -192,3 +192,25 @@ not a copy of the purged page.
|
|||||||
| max upload size | 25 MiB (quota ladder, ADR 0011) |
|
| max upload size | 25 MiB (quota ladder, ADR 0011) |
|
||||||
| collab connections per instance | 500 concurrent |
|
| collab connections per instance | 500 concurrent |
|
||||||
| rate limits | login 10/min/IP, signup 5/h/IP, API 100/min/user |
|
| 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,
|
Deliberately not in v1 (stage 2): attachment upload, version endpoints,
|
||||||
webhooks.
|
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
|
## Connect Claude Code / MCP clients
|
||||||
|
|
||||||
The instance ships its own MCP endpoint (Streamable HTTP) at `/api/mcp` —
|
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] 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] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209
|
||||||
- [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
- [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
||||||
- [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
- [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
||||||
- [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212
|
- [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212
|
||||||
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
|
- [x] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
|
||||||
|
|
||||||
### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap
|
### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap
|
||||||
|
|
||||||
|
|||||||
@ -110,5 +110,6 @@
|
|||||||
"backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.",
|
"backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.",
|
||||||
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.",
|
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.",
|
||||||
"pond_not_found": "Der Teich existiert nicht.",
|
"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)",
|
"svgSanitize": "Bereinigen (Skripte entfernen)",
|
||||||
"svgReject": "Ablehnen",
|
"svgReject": "Ablehnen",
|
||||||
"save": "Upload-Einstellungen speichern"
|
"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",
|
"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).",
|
"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",
|
"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": {
|
"landing": {
|
||||||
"title": "Startseite",
|
"title": "Startseite",
|
||||||
|
|||||||
@ -110,5 +110,6 @@
|
|||||||
"backup_set_not_found": "The selected backup set was not found.",
|
"backup_set_not_found": "The selected backup set was not found.",
|
||||||
"scope_required": "This API token does not have the required scope.",
|
"scope_required": "This API token does not have the required scope.",
|
||||||
"pond_not_found": "The pond does not exist.",
|
"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)",
|
"svgSanitize": "Sanitize (strip scripts)",
|
||||||
"svgReject": "Reject",
|
"svgReject": "Reject",
|
||||||
"save": "Save upload settings"
|
"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",
|
"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).",
|
"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",
|
"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": {
|
"landing": {
|
||||||
"title": "Landing page",
|
"title": "Landing page",
|
||||||
|
|||||||
@ -26,6 +26,16 @@ export function classificationMarking(classification: PageClassification): strin
|
|||||||
return classification === 'vs_nfd' ? 'VS – NUR FÜR DEN DIENSTGEBRAUCH' : null;
|
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
|
/** Ordering of levels: the index in {@link PAGE_CLASSIFICATIONS} (lowest
|
||||||
* first) — the tree invariant (#205) and archive-level statements (#210)
|
* first) — the tree invariant (#205) and archive-level statements (#210)
|
||||||
* compare through this, never through string comparison. */
|
* compare through this, never through string comparison. */
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import type { ApiTokenScope } from './api-tokens';
|
import type { ApiTokenScope } from './api-tokens';
|
||||||
import type { CommentView } from './comments';
|
import type { CommentView } from './comments';
|
||||||
import type { LabelTreeNode, LabelView } from './labels';
|
import type { LabelTreeNode, LabelView } from './labels';
|
||||||
|
import type { PageClassification } from './pages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire types of the public REST API (`/api/public/v1`, issue #104). The
|
* Wire types of the public REST API (`/api/public/v1`, issue #104). The
|
||||||
@ -29,6 +30,8 @@ export interface PublicPondView {
|
|||||||
export interface PublicPageListItemView {
|
export interface PublicPageListItemView {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: 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
|
/** 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). */
|
* as well when the token's user may not read the parent (no existence leak). */
|
||||||
parent: string | null;
|
parent: string | null;
|
||||||
@ -41,6 +44,8 @@ export interface PublicPageView {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
pondSlug: 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 page slug (issue #110); see {@link PublicPageListItemView.parent}. */
|
||||||
parent: string | null;
|
parent: string | null;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { PageClassification } from './pages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
|
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
|
||||||
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
|
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
|
||||||
@ -49,4 +51,7 @@ export interface SearchResultView {
|
|||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
/** Snippet with matches wrapped in the highlight sentinels above. */
|
/** Snippet with matches wrapped in the highlight sentinels above. */
|
||||||
snippet: string;
|
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