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
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
import type { AttachmentListItemView } from '@dorfteich/shared';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import type { Editor } from '@tiptap/react';
|
|
import { useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { FormError } from '../components/forms';
|
|
import { apiDelete, apiGet, apiUploadFile } from '../lib/api';
|
|
|
|
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
|
|
|
|
/**
|
|
* A page's attachments section (issue #61): upload a file, see it listed with
|
|
* its type icon, size, and uploader, insert it into the document as a download
|
|
* link, or delete it. Uploads and deletes are page-write-gated in the api; in
|
|
* read mode the panel is a download list (no upload/insert/delete controls).
|
|
*/
|
|
export function AttachmentsPanel({
|
|
pageId,
|
|
editor,
|
|
canEdit,
|
|
onClose,
|
|
}: {
|
|
pageId: string;
|
|
editor: Editor | null;
|
|
canEdit: boolean;
|
|
onClose: () => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('files');
|
|
const queryClient = useQueryClient();
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const list = useQuery({
|
|
queryKey: ['page-files', pageId],
|
|
queryFn: () => apiGet<AttachmentListItemView[]>(`/pages/${pageId}/files`),
|
|
});
|
|
|
|
async function refresh(): Promise<void> {
|
|
await queryClient.invalidateQueries({ queryKey: ['page-files', pageId] });
|
|
}
|
|
|
|
async function onPick(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
await apiUploadFile(`/pages/${pageId}/files`, file);
|
|
await refresh();
|
|
} catch (err) {
|
|
setError(err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function remove(id: string): Promise<void> {
|
|
setError(null);
|
|
try {
|
|
await apiDelete(`/files/${id}`);
|
|
await refresh();
|
|
} catch (err) {
|
|
setError(err);
|
|
}
|
|
}
|
|
|
|
function insertLink(item: AttachmentListItemView): void {
|
|
if (!editor) return;
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertContent([
|
|
{
|
|
type: 'text',
|
|
text: item.fileName,
|
|
marks: [{ type: 'link', attrs: { href: mediaUrl(item.id) } }],
|
|
},
|
|
{ type: 'text', text: ' ' },
|
|
])
|
|
.run();
|
|
}
|
|
|
|
const items = list.data ?? [];
|
|
|
|
return (
|
|
<aside className="attachments-panel" aria-label={t('title')}>
|
|
<div className="attachments-panel__header">
|
|
<h3>{t('title')}</h3>
|
|
<button type="button" className="button" onClick={onClose}>
|
|
{t('close')}
|
|
</button>
|
|
</div>
|
|
|
|
<FormError error={error} />
|
|
|
|
{canEdit && (
|
|
<div className="attachments-panel__upload">
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
className="attachments-panel__input"
|
|
onChange={(event) => void onPick(event)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="button"
|
|
disabled={busy}
|
|
onClick={() => fileInput.current?.click()}
|
|
>
|
|
{busy ? t('uploading') : t('upload')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{items.length === 0 ? (
|
|
<p className="attachments-panel__empty">{t('empty')}</p>
|
|
) : (
|
|
<ul className="attachments-panel__list">
|
|
{items.map((item) => (
|
|
<li key={item.id} className="attachments-item">
|
|
<span className="attachments-item__glyph" aria-hidden="true">
|
|
{fileGlyph(item.fileName, item.mimeType)}
|
|
</span>
|
|
<a
|
|
className="attachments-item__name"
|
|
href={mediaUrl(item.id)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
download={item.fileName}
|
|
>
|
|
{item.fileName}
|
|
</a>
|
|
<span className="attachments-item__meta">
|
|
{formatBytes(item.sizeBytes)} · {item.uploaderName}
|
|
</span>
|
|
{canEdit && (
|
|
<span className="attachments-item__actions">
|
|
{editor && (
|
|
<button
|
|
type="button"
|
|
className="button attachments-item__insert"
|
|
onClick={() => insertLink(item)}
|
|
>
|
|
{t('insert')}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="button attachments-item__delete"
|
|
onClick={() => void remove(item.id)}
|
|
>
|
|
{t('delete')}
|
|
</button>
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|