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
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
118 lines
3.8 KiB
TypeScript
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;
|
|
}
|