diff --git a/webapp/src/components/PublicSendPage.tsx b/webapp/src/components/PublicSendPage.tsx index d2a4d72..96fba07 100644 --- a/webapp/src/components/PublicSendPage.tsx +++ b/webapp/src/components/PublicSendPage.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState } from 'preact/hooks'; -import { Download, Eye, Lock } from 'lucide-preact'; +import { Clipboard, Download, Eye, Lock } from 'lucide-preact'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { toBufferSource } from '@/lib/crypto'; import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download'; +import NotFoundPage from '@/components/NotFoundPage'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; @@ -27,6 +29,25 @@ interface PublicSendData { file?: PublicSendFileData | null; } +function decodeBase64Url(value: string): Uint8Array | null { + try { + const raw = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4); + const decoded = atob(padded); + const out = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) out[i] = decoded.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function hasUsableSendKey(keyPart: string | null): boolean { + if (!keyPart) return false; + const bytes = decodeBase64Url(keyPart); + return !!bytes && bytes.length >= 16; +} + function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; } @@ -69,6 +90,7 @@ export default function PublicSendPage(props: PublicSendPageProps) { const [password, setPassword] = useState(''); const [needPassword, setNeedPassword] = useState(false); const [error, setError] = useState(''); + const [notFound, setNotFound] = useState(false); const [sendData, setSendData] = useState(null); const [busy, setBusy] = useState(false); const [downloadPercent, setDownloadPercent] = useState(null); @@ -83,8 +105,14 @@ export default function PublicSendPage(props: PublicSendPageProps) { loadAbortRef.current = controller; setBusy(true); setError(''); + setNotFound(false); setLoading(true); try { + if (!hasUsableSendKey(props.keyPart)) { + setNotFound(true); + setSendData(null); + return; + } const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal }); if (controller.signal.aborted || requestId !== loadRequestRef.current) return; if (!props.keyPart) { @@ -104,6 +132,10 @@ export default function PublicSendPage(props: PublicSendPageProps) { if (err.status === 401) { setNeedPassword(true); setError(t('txt_this_send_is_password_protected')); + } else if (err.status === 404) { + setNeedPassword(false); + setNotFound(true); + setError(''); } else { setError(err.message || t('txt_failed_to_open_send')); } @@ -158,9 +190,16 @@ export default function PublicSendPage(props: PublicSendPageProps) { }; }, [props.accessId, props.keyPart]); + if (!loading && notFound) { + return ; + } + return (
- + {loading &&

{t('txt_loading')}

} {!loading && needPassword && ( @@ -190,9 +229,20 @@ export default function PublicSendPage(props: PublicSendPageProps) { {!loading && sendData && ( <> -

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

{sendData.type === 0 ? (
+
+ {t('txt_text_send')} + +
{sendData.decText || ''}
) : ( diff --git a/webapp/src/components/StandalonePageFrame.tsx b/webapp/src/components/StandalonePageFrame.tsx index aed3811..0704752 100644 --- a/webapp/src/components/StandalonePageFrame.tsx +++ b/webapp/src/components/StandalonePageFrame.tsx @@ -3,6 +3,7 @@ import { APP_VERSION } from '@shared/app-version'; interface StandalonePageFrameProps { title: string; + eyebrow?: ComponentChildren; children: ComponentChildren; } @@ -17,6 +18,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
+ {props.eyebrow &&
{props.eyebrow}
}

{props.title}

{props.children}