dorfteich/packages/shared/src/files.ts
Claude Opus 4.8 30891f99cf
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
Add non-image attachments with allowlist, SVG policy, and file managers (#61)
Extend uploads (#27, ADR 0011) beyond images to a configurable general
attachment allowlist, plus the page attachments section and the Pond Admin
file manager.

Backend:
- Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped,
  images always allowed regardless) and `upload.svgPolicy` (reject | sanitize).
- FilesService.resolveUpload: raster images still decided by magic bytes; SVG
  is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped)
  or rejected per policy; everything else is admitted only if its extension is
  on the allowlist. A sanitized SVG's stored bytes are re-accounted so
  pond_usage matches disk.
- Downloads set `Content-Disposition: attachment` for every non-raster type
  (office files, PDFs, SVG) with `nosniff`, so they can never execute inline;
  raster images stay inline for page embeds.
- New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan
  flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read:
  the attachments section). New error code `upload_type_not_allowed` (de+en).

Frontend:
- Page attachments section (AttachmentsPanel): upload, list with type glyph,
  size, and uploader, insert-as-link into the document (an internal media link
  that downloads, never renders inline), and delete. Toggled in the editor.
- Pond file manager (PondFileManager) in pond settings for Pond Admins: every
  file with its referencing page (or an orphan flag) and storage usage.
- Admin uploads settings form (allowlist + SVG policy). New `files` i18n
  namespace (de+en).

Tests:
- files.e2e.db.test.ts: allowlisted non-image accepted and served as a
  download; disallowed extension rejected; renamed-.html-as-.png still fails;
  SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page
  attachment listing; pond file manager usage/orphan; non-admin denied.
- New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list →
  insert link (verified attachment disposition + nosniff), disallowed-type
  error, pond file manager usage/orphan.

Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121,
web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom
to the api for server-side SVG sanitization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 02:52:40 +02:00

118 lines
3.8 KiB
TypeScript

/**
* Attachment types shared between api and web (issue #27, ADR 0011). M2
* accepted images only; M6 (#61) adds a configurable allowlist for general
* attachments (PDF, office files, …) plus an SVG policy.
*/
export const ATTACHMENT_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
] as const;
export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number];
export function isImageMimeType(mimeType: string): boolean {
return (ATTACHMENT_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
}
/**
* SVG is an image but also an XML document that can carry scripts and event
* handlers, so it is never treated like a raster image: it is sanitized or
* rejected on upload (instance setting) and always served as a download,
* never inline (security.md §Uploads).
*/
export const SVG_MIME_TYPE = 'image/svg+xml';
/** How the instance handles SVG uploads (ADR 0011, security.md §Uploads). */
export type SvgPolicy = 'reject' | 'sanitize';
/**
* Extension → served MIME type for the non-image allowlist. The allowlist is
* keyed on the lowercase extension (what an admin configures and what names
* the download); the MIME here only sets the response `Content-Type`, and
* non-images are always sent with `Content-Disposition: attachment` +
* `nosniff`, so a wrong guess can never cause inline execution.
*/
export const ATTACHMENT_EXTENSION_MIME_TYPES: Readonly<Record<string, string>> = {
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
rtf: 'application/rtf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
odt: 'application/vnd.oasis.opendocument.text',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
odp: 'application/vnd.oasis.opendocument.presentation',
zip: 'application/zip',
};
/**
* Default non-image allowlist (extensions, lowercase, no dot). Images from
* {@link ATTACHMENT_IMAGE_MIME_TYPES} are always allowed regardless of this
* list; SVG is governed separately by the SVG policy.
*/
export const DEFAULT_ATTACHMENT_EXTENSIONS: readonly string[] = [
'pdf',
'txt',
'md',
'csv',
'doc',
'docx',
'odt',
'xls',
'xlsx',
'ods',
'ppt',
'pptx',
'odp',
'zip',
];
/** Lowercase extension without the leading dot, or '' when the name has none. */
export function fileExtension(fileName: string): string {
const dot = fileName.lastIndexOf('.');
if (dot <= 0 || dot === fileName.length - 1) return '';
return fileName.slice(dot + 1).toLowerCase();
}
/**
* Hard ceiling on the raw multipart body the api will buffer in memory,
* independent of the per-pond/user `max_file_bytes` quota (QuotaService)
* that governs the actually accepted size — mirrors how
* `MAX_PAGE_DOCUMENT_BYTES` relates to the JSON body-parser limit (pages.ts).
*/
export const MAX_UPLOAD_PARSE_BYTES = 64 * 1024 * 1024;
export interface AttachmentView {
id: string;
pondId: string;
pageId: string | null;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
/**
* A row in the page-attachments section and the pond file manager (#61):
* carries the uploader's display name and, for the pond manager, the title
* of the page currently referencing the file (null = orphan candidate).
*/
export interface AttachmentListItemView extends AttachmentView {
uploaderName: string;
pageTitle: string | null;
}
/** Pond-wide file manager payload (Pond Admin, #61). */
export interface PondFilesView {
files: AttachmentListItemView[];
storageBytesUsed: number;
storageBytesLimit: number;
}