import { useEffect, useRef, useState } from 'preact/hooks'; import { Download, Eye, Lock } from 'lucide-preact'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import { toBufferSource } from '@/lib/crypto'; import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; interface PublicSendPageProps { accessId: string; keyPart: string | null; } interface PublicSendFileData { id: string; fileName?: string | null; sizeName?: string | null; } interface PublicSendData { id: string; type: 0 | 1; decName?: string | null; decText?: string | null; decFileName?: string | null; expirationDate?: string | null; file?: PublicSendFileData | null; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; } function optionalString(value: unknown): string | null { return typeof value === 'string' ? value : null; } function parsePublicSendData(value: unknown): PublicSendData | null { const source = asRecord(value); if (!source) return null; const id = optionalString(source.id); const rawType = Number(source.type); if (!id || (rawType !== 0 && rawType !== 1)) return null; const fileSource = asRecord(source.file); const fileId = optionalString(fileSource?.id); const file = fileSource && fileId ? { id: fileId, fileName: optionalString(fileSource.fileName), sizeName: optionalString(fileSource.sizeName), } : null; if (rawType === 1 && !file) return null; return { id, type: rawType, decName: optionalString(source.decName), decText: optionalString(source.decText), decFileName: optionalString(source.decFileName), expirationDate: optionalString(source.expirationDate), file, }; } export default function PublicSendPage(props: PublicSendPageProps) { const [loading, setLoading] = useState(true); const [password, setPassword] = useState(''); const [needPassword, setNeedPassword] = useState(false); const [error, setError] = useState(''); const [sendData, setSendData] = useState(null); const [busy, setBusy] = useState(false); const [downloadPercent, setDownloadPercent] = useState(null); const loadRequestRef = useRef(0); const loadAbortRef = useRef(null); async function loadSend(pass?: string): Promise { loadAbortRef.current?.abort(); const controller = new AbortController(); const requestId = loadRequestRef.current + 1; loadRequestRef.current = requestId; loadAbortRef.current = controller; setBusy(true); setError(''); setLoading(true); try { const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal }); if (controller.signal.aborted || requestId !== loadRequestRef.current) return; if (!props.keyPart) { setError(t('txt_this_link_is_missing_decryption_key')); setSendData(null); return; } const decrypted = await decryptPublicSend(data, props.keyPart); if (controller.signal.aborted || requestId !== loadRequestRef.current) return; const parsed = parsePublicSendData(decrypted); if (!parsed) throw new Error(t('txt_send_unavailable')); setSendData(parsed); setNeedPassword(false); } catch (e) { if (controller.signal.aborted || requestId !== loadRequestRef.current) return; const err = e as Error & { status?: number }; if (err.status === 401) { setNeedPassword(true); setError(t('txt_this_send_is_password_protected')); } else { setError(err.message || t('txt_failed_to_open_send')); } setSendData(null); } finally { if (controller.signal.aborted || requestId !== loadRequestRef.current) return; setBusy(false); setLoading(false); } } async function downloadFile(): Promise { if (!sendData?.id || !sendData?.file?.id) return; setBusy(true); setDownloadPercent(null); setError(''); try { const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined); const resp = await fetch(url); if (!resp.ok) throw new Error(t('txt_download_failed')); const encryptedBytes = await readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent)); let blob: Blob; if (props.keyPart) { try { const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart); blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' }); } catch { // Legacy compatibility: early web-created file sends uploaded plaintext bytes. blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' }); } } else { blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' }); } downloadBytesAsFile( new Uint8Array(await blob.arrayBuffer()), sendData.decFileName || sendData.file?.fileName || t('txt_send_file'), 'application/octet-stream' ); } catch (e) { const err = e as Error; setError(err.message || t('txt_download_failed')); } finally { setBusy(false); setDownloadPercent(null); } } useEffect(() => { void loadSend(); return () => { loadAbortRef.current?.abort(); }; }, [props.accessId, props.keyPart]); return (
{loading &&

{t('txt_loading')}

} {!loading && needPassword && (
{ e.preventDefault(); void loadSend(password); }} >
)} {!loading && sendData && ( <>

{sendData.decName || t('txt_no_name')}

{sendData.type === 0 ? (
{sendData.decText || ''}
) : (
{t('txt_file')} {sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}
)} {!!sendData.expirationDate &&

{t('txt_expires_at_value', { value: sendData.expirationDate })}

} )} {!loading && !sendData && !needPassword && !error && (

{t('txt_send_unavailable')}

)} {!!error &&

{error}

}
); }