#212: attachment downloads marked by filename prefix + companion #271
@ -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,73 @@ 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('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`)
|
||||||
|
|||||||
@ -13,7 +13,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 +38,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. */
|
||||||
@ -229,13 +236,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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -58,7 +58,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
|
|||||||
- [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
|
||||||
- [x] 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
|
- [ ] 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
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user