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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import toast from 'react-hot-toast';
|
||||
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
|
||||
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import PdfDragButton from '../../components/ui/PdfDragButton';
|
||||
import AutosaveDateInput from '../../components/ui/AutosaveDateInput';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
@@ -2652,14 +2651,6 @@ export default function ContractDetail() {
|
||||
{c.bankCard.iban}
|
||||
<CopyButton value={c.bankCard.iban} />
|
||||
</p>
|
||||
{c.bankCard.documentPath && (
|
||||
<div className="mt-1">
|
||||
<PdfDragButton
|
||||
path={c.bankCard.documentPath}
|
||||
filename={`Bankkarte-${c.bankCard.iban}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{c.bankCard.bankName && <p className="text-gray-500">{c.bankCard.bankName}</p>}
|
||||
{c.bankCard.description && (
|
||||
<p className="text-sm text-gray-600 mt-1 italic whitespace-pre-line">
|
||||
@@ -2675,14 +2666,6 @@ export default function ContractDetail() {
|
||||
<CopyButton value={c.identityDocument.documentNumber} />
|
||||
</p>
|
||||
<p className="text-gray-500">{c.identityDocument.type}</p>
|
||||
{c.identityDocument.documentPath && (
|
||||
<div className="mt-1">
|
||||
<PdfDragButton
|
||||
path={c.identityDocument.documentPath}
|
||||
filename={`Ausweis-${c.identityDocument.documentNumber}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import CopyButton from '../../components/ui/CopyButton';
|
||||
import PdfDragButton from '../../components/ui/PdfDragButton';
|
||||
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||
import { buildContractLabelParts } from '../../utils/contractLabel';
|
||||
import type { ContractType } from '../../types';
|
||||
@@ -979,12 +978,6 @@ export default function ContractForm() {
|
||||
{selectedBankCard && (
|
||||
<CopyButton value={selectedBankCard.iban} title="IBAN kopieren" />
|
||||
)}
|
||||
{selectedBankCard?.documentPath && (
|
||||
<PdfDragButton
|
||||
path={selectedBankCard.documentPath}
|
||||
filename={`Bankkarte-${selectedBankCard.iban}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
{...register('bankCardId')}
|
||||
@@ -1011,12 +1004,6 @@ export default function ContractForm() {
|
||||
{selectedDocument && (
|
||||
<CopyButton value={selectedDocument.documentNumber} title="Ausweisnummer kopieren" />
|
||||
)}
|
||||
{selectedDocument?.documentPath && (
|
||||
<PdfDragButton
|
||||
path={selectedDocument.documentPath}
|
||||
filename={`Ausweis-${selectedDocument.documentNumber}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
{...register('identityDocumentId')}
|
||||
|
||||
@@ -18,7 +18,6 @@ import FileUpload from '../../components/ui/FileUpload';
|
||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink, Images } from 'lucide-react';
|
||||
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import PdfDragButton from '../../components/ui/PdfDragButton';
|
||||
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
||||
@@ -1003,10 +1002,6 @@ function BankCardsTab({
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
<PdfDragButton
|
||||
path={card.documentPath}
|
||||
filename={`Bankkarte-${card.iban}`}
|
||||
/>
|
||||
{canEdit && (
|
||||
<>
|
||||
<FileUpload
|
||||
@@ -1243,10 +1238,6 @@ function DocumentsTab({
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
<PdfDragButton
|
||||
path={doc.documentPath}
|
||||
filename={`Ausweis-${doc.documentNumber}`}
|
||||
/>
|
||||
{canEdit && (
|
||||
<>
|
||||
<FileUpload
|
||||
|
||||
@@ -24,15 +24,9 @@ export function viewUrl(path: string | null | undefined): string {
|
||||
return fileUrl(path, { inline: true });
|
||||
}
|
||||
|
||||
export function fileUrl(
|
||||
path: string | null | undefined,
|
||||
opts?: { inline?: boolean; token?: string },
|
||||
): string {
|
||||
export function fileUrl(path: string | null | undefined, opts?: { inline?: boolean }): string {
|
||||
if (!path) return '';
|
||||
// Expliziter Token (z.B. kurzlebiger 60s-Download-Token) hat Vorrang vor
|
||||
// dem langlebigen Access-Token. Genutzt vom PdfDragButton, damit bei einem
|
||||
// Fehl-Drop kein 15-Min-Vollzugriffs-Token in fremde Kontexte leakt.
|
||||
const token = opts?.token ?? getAccessToken();
|
||||
const token = getAccessToken();
|
||||
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||
// `?disposition=inline` schaltet die Anzeige im Browser-Tab ein,
|
||||
// der Backend-Controller bleibt aber nur dann inline, wenn die
|
||||
|
||||
Reference in New Issue
Block a user