PdfDragButton komplett entfernt (Plattformgrenze)

Test-Ergebnis: Browser kann einer fremden Desktop-App keine echte
lokale Datei zum Anhaengen uebergeben. URL-Drag -> Thunderbird nur Link
(0 Bytes, Fehler beim Senden); Datei-Drop in den Text -> nur Dateiname
als Text; auf die Anhang-Leiste (Thunderbird/Linux) -> ebenfalls kein
echter Anhang; Webmail -> gar nicht moeglich.

Entscheidung: Feature raus. Download- und Anzeigen-Button decken den
Bedarf zuverlaessig ab. PdfDragButton geloescht, fileUrl()-Token-Param
zurueckgebaut. Pentest R131 damit gegenstandslos (kein Drag mehr).

Die separaten Copy-Buttons fuer IBAN + Ausweisnummer im Vertrags-
formular bleiben erhalten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:51:52 +02:00
co-authored by Claude Opus 4.8
parent 3073acb4b0
commit 5bbb7dfffe
6 changed files with 18 additions and 247 deletions
@@ -1,170 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { GripVertical, FileDown, Info, Loader2 } from 'lucide-react';
import api from '../../services/api';
import { fileUrl } from '../../utils/fileUrl';
/**
* Ziehbares Element, mit dem eine im Backend hinterlegte Datei (i.d.R. ein
* PDF-Scan) als ECHTE DATEI aus dem Browser heraus in eine fremde Anwendung
* Mail-Anhang, Datei-Explorer gezogen werden kann.
*
* Ansatz (bewusst Blob statt URL):
* Die Datei wird VORAB per authentifiziertem Request (Bearer-Header über die
* Axios-Instanz) als Blob geladen und beim `dragstart` als Datei-Inhalt in
* den DataTransfer gelegt:
* - `dataTransfer.items.add(File)` → echte Datei für Web-/App-Ziele,
* - `DownloadURL` mit einer `blob:`-Object-URL → Chromium liefert die Bytes
* beim Drop selbst aus (Explorer/Datei-Manager).
* Dadurch wird der DATEI-INHALT übertragen, NICHT ein Link. Das behebt den
* Fall, dass Mail-Programme (z.B. Thunderbird) nur eine URL als Anhang
* speichern und die Datei erst beim Senden nachladen (→ 0 Bytes / Fehler,
* weil kurzlebiger Token längst abgelaufen ist).
*
* SICHERHEIT (Pentest R131): Im Drag steckt jetzt WEDER eine Server-URL NOCH
* ein Token nur der Datei-Inhalt bzw. eine lokale `blob:`-URL. Ein Fehl-Drop
* kann also keinen Token (weder Access- noch Download-Token) mehr leaken.
*
* Grenzen der Browser-Plattform:
* - „PDF per Strg+V als DATEI einfügen" bleibt im Browser unmöglich → Drag.
* - Zuverlässiges Datei-Drop nach draußen funktioniert am besten in
* Chromium (Chrome/Edge). Ziele, die weder `File` noch `DownloadURL`
* annehmen (manche Webmail-Compose-Felder), erhalten keine echte Datei
* dort bleibt der Klick-Fallback (Datei im Tab öffnen → manuell anhängen).
*/
interface PdfDragButtonProps {
/** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */
path: string | null | undefined;
/** Dateiname beim Ablegen. Ohne Endung wird sie aus dem Pfad ergänzt. */
filename?: string;
label?: string;
title?: string;
className?: string;
size?: 'sm' | 'md';
}
const MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
};
function extOf(path: string): string {
const clean = path.split('?')[0].split('#')[0];
const dot = clean.lastIndexOf('.');
return dot === -1 ? '' : clean.slice(dot + 1).toLowerCase();
}
export default function PdfDragButton({
path,
filename,
label = 'PDF ziehen',
title,
className = '',
size = 'sm',
}: PdfDragButtonProps) {
const blobRef = useRef<Blob | null>(null);
const objectUrlRef = useRef<string | null>(null);
const fetchingRef = useRef(false);
const [state, setState] = useState<'idle' | 'loading' | 'ready'>('idle');
// Datei-Bytes einmal laden (auth über Bearer-Header der Axios-Instanz).
const ensureBlob = () => {
if (!path || blobRef.current || fetchingRef.current) return;
fetchingRef.current = true;
setState('loading');
api
.get('/files/download', { params: { path }, responseType: 'blob' })
.then((res) => {
blobRef.current = res.data as Blob;
setState('ready');
})
.catch(() => {
setState('idle');
})
.finally(() => {
fetchingRef.current = false;
});
};
// Beim Mount vorladen, damit der erste Drag sofort eine echte Datei liefert.
useEffect(() => {
if (path) ensureBlob();
return () => {
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);
if (!path) return null;
const ext = extOf(path);
const mime = MIME_BY_EXT[ext] || 'application/octet-stream';
const base = (filename && filename.trim()) || 'dokument';
const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_');
const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`;
const handleDragStart = (e: React.DragEvent) => {
const blob = blobRef.current;
if (!blob) {
// Bytes noch nicht da → keinen halben (Link-)Anhang erzeugen. Laden
// anstoßen; der nächste Versuch liefert dann die echte Datei.
ensureBlob();
e.preventDefault();
return;
}
// Frische Object-URL, vorherige freigeben.
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
const url = URL.createObjectURL(blob);
objectUrlRef.current = url;
const file = new File([blob], name, { type: mime });
try {
e.dataTransfer.items.add(file);
} catch {
/* ältere Browser: dann trägt DownloadURL den Drop */
}
try {
e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${url}`);
} catch {
/* noop */
}
e.dataTransfer.effectAllowed = 'copy';
};
const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4';
const loading = state === 'loading';
return (
<span className={`inline-flex items-center gap-1 ${className}`}>
<span
draggable={state === 'ready'}
onMouseEnter={ensureBlob}
onFocus={ensureBlob}
onDragStart={handleDragStart}
onClick={() => window.open(fileUrl(path, { inline: true }), '_blank', 'noopener')}
title={
title ||
(loading
? 'Datei wird geladen …'
: 'In Mail-Anhang/Explorer ziehen (am besten Chrome/Edge). Klick: im Tab öffnen.')
}
className={`inline-flex items-center gap-1 select-none rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs text-gray-600 transition-colors hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50 ${
state === 'ready' ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
}`}
>
<GripVertical className={`${iconSize} text-gray-400`} />
{loading ? <Loader2 className={`${iconSize} animate-spin`} /> : <FileDown className={iconSize} />}
<span>{loading ? 'lädt …' : label}</span>
</span>
<span
className="inline-flex cursor-help text-gray-400"
title="Ziehen als echte Datei funktioniert am besten in Chrome/Edge (Mail-Anhang, Explorer). In Firefox bzw. wenn das Ziel keine Datei annimmt, stattdessen anklicken und manuell anhängen."
>
<Info className={iconSize} />
</span>
</span>
);
}