zero-knowledge file sharing
at main 310 lines 9.7 kB view raw
1import { createSignal, Show, onMount, onCleanup } from "solid-js"; 2 3import { importKey } from "../lib/crypto"; 4import { btnClass, btnStyle, fadeIn } from "../lib/ui"; 5import { 6 formatBytes, 7 formatExpiry, 8 getExt, 9 triggerDownload, 10 IMAGE_EXTS, 11 TEXT_EXTS, 12 VIDEO_EXTS, 13 IMAGE_MIME, 14 VIDEO_MIME, 15 AUDIO_MIME, 16} from "../lib/utils"; 17 18const ghostClass = 19 "text-muted hover:text-accent hover:border-accent border border-border bg-transparent rounded px-2 py-1 text-sm transition-colors"; 20 21type Stage = "loading" | "meta" | "decrypting" | "content" | "error"; 22type ContentType = "text" | "image" | "video" | "audio" | "binary"; 23 24export default function View() { 25 const parts = location.pathname.split("/").filter(Boolean); 26 const id = parts[0] === "p" ? parts[1] : parts[0]; 27 28 const [stage, setStage] = createSignal<Stage>("loading"); 29 const [error, setError] = createSignal(""); 30 const [size, setSize] = createSignal(0); 31 const [expiresAt, setExpiresAt] = createSignal(0); 32 const [burnAfterRead, setBurnAfterRead] = createSignal(false); 33 const [burned, setBurned] = createSignal(false); 34 const [progress, setProgress] = createSignal(0); 35 const [decrypting, setDecrypting] = createSignal(false); 36 37 const [contentType, setContentType] = createSignal<ContentType>("binary"); 38 const [textContent, setTextContent] = createSignal(""); 39 const [imageSrc, setImageSrc] = createSignal(""); 40 const [mediaSrc, setMediaSrc] = createSignal(""); 41 const [fileName, setFileName] = createSignal(""); 42 const [copied, setCopied] = createSignal(false); 43 44 let decryptedBlob: Blob | null = null; 45 const worker = new Worker( 46 new URL("../lib/crypto.worker.ts", import.meta.url), 47 { 48 type: "module", 49 }, 50 ); 51 52 onCleanup(() => worker.terminate()); 53 54 onMount(async () => { 55 const keyEncoded = window.location.hash.slice(1); 56 57 if (!id || !keyEncoded) { 58 setError("Invalid URL."); 59 setStage("error"); 60 return; 61 } 62 63 try { 64 await importKey(keyEncoded); 65 } catch { 66 setError("Invalid key."); 67 setStage("error"); 68 return; 69 } 70 71 const infoRes = await fetch(`/api/file/${id}/info`); 72 if (!infoRes.ok) { 73 setError("File not found or expired."); 74 setStage("error"); 75 return; 76 } 77 78 const info = await infoRes.json(); 79 setSize(info.size); 80 setExpiresAt(info.expiresAt); 81 setBurnAfterRead(info.burnAfterRead); 82 83 if (info.burnAfterRead) { 84 setStage("meta"); 85 } else { 86 handleView(); 87 } 88 }); 89 90 const handleView = async () => { 91 setStage("decrypting"); 92 setProgress(0); 93 setDecrypting(false); 94 95 try { 96 const xhr = new XMLHttpRequest(); 97 xhr.open("GET", `/api/file/${id}`); 98 xhr.responseType = "arraybuffer"; 99 100 const buf = await new Promise<ArrayBuffer>((resolve, reject) => { 101 xhr.onprogress = (e) => { 102 if (e.lengthComputable) { 103 setProgress(Math.round((e.loaded / e.total) * 100)); 104 } 105 }; 106 xhr.onload = () => { 107 if (xhr.status >= 200 && xhr.status < 300) { 108 setProgress(100); 109 setDecrypting(true); 110 resolve(xhr.response); 111 } else { 112 reject(new Error("File not found or expired.")); 113 } 114 }; 115 xhr.onerror = () => reject(new Error("Download failed.")); 116 xhr.send(); 117 }); 118 119 const { fileName: name, fileData } = await new Promise<{ 120 fileName: string; 121 fileData: Uint8Array<ArrayBuffer>; 122 }>((resolve, reject) => { 123 worker.onmessage = (e) => { 124 if (e.data.error) reject(new Error(e.data.error)); 125 else resolve(e.data); 126 }; 127 worker.postMessage( 128 { 129 type: "decrypt", 130 ciphertext: buf, 131 keyEncoded: location.hash.slice(1), 132 }, 133 [buf], 134 ); 135 }); 136 137 const ext = getExt(name); 138 setFileName(name); 139 140 if (burnAfterRead()) setBurned(true); 141 142 const mime = 143 IMAGE_MIME[ext] || VIDEO_MIME[ext] || AUDIO_MIME[ext] || undefined; 144 if (mime) { 145 const blob = new Blob([fileData], { type: mime }); 146 decryptedBlob = blob; 147 const url = URL.createObjectURL(blob); 148 if (IMAGE_EXTS.has(ext)) { 149 setImageSrc(url); 150 setContentType("image"); 151 } else if (VIDEO_EXTS.has(ext)) { 152 setMediaSrc(url); 153 setContentType("video"); 154 } else { 155 setMediaSrc(url); 156 setContentType("audio"); 157 } 158 } else if (TEXT_EXTS.has(ext)) { 159 setTextContent(new TextDecoder().decode(fileData)); 160 setContentType("text"); 161 } else { 162 decryptedBlob = new Blob([fileData]); 163 setContentType("binary"); 164 } 165 166 setStage("content"); 167 } catch (e: any) { 168 setError(e.message || "Failed to decrypt."); 169 setStage("error"); 170 } 171 }; 172 173 const copyText = () => { 174 navigator.clipboard.writeText(textContent()); 175 setCopied(true); 176 setTimeout(() => setCopied(false), 1500); 177 }; 178 179 const saveFile = () => { 180 if (contentType() === "text") { 181 const blob = new Blob([textContent()], { type: "text/plain" }); 182 triggerDownload(blob, fileName()); 183 } else if (decryptedBlob) { 184 triggerDownload(decryptedBlob, fileName()); 185 } 186 }; 187 188 return ( 189 <> 190 <Show when={stage() === "loading"}> 191 <div class="flex justify-center"> 192 <span class="text-muted text-xs">loading</span> 193 </div> 194 </Show> 195 196 <Show when={stage() === "decrypting"}> 197 <div class="flex flex-col items-center gap-2" style={fadeIn}> 198 <Show 199 when={!decrypting()} 200 fallback={<span class="text-muted text-xs">decrypting</span>} 201 > 202 <span 203 class="text-accent font-medium tabular-nums" 204 style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }} 205 > 206 {progress()}% 207 </span> 208 <span class="text-muted text-xs">downloading</span> 209 </Show> 210 </div> 211 </Show> 212 213 <Show when={stage() === "meta"}> 214 <div class="flex flex-col items-center gap-3" style={fadeIn}> 215 <span 216 class="text-muted" 217 style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }} 218 > 219 {formatBytes(size())} 220 </span> 221 <button class={btnClass} style={btnStyle} onClick={handleView}> 222 view 223 </button> 224 <span class="text-muted text-xs"> 225 {formatExpiry(expiresAt())} · burns after viewing 226 </span> 227 </div> 228 </Show> 229 230 <Show when={stage() === "error"}> 231 <div class="flex justify-center" style={fadeIn}> 232 <span class="text-danger text-sm">{error()}</span> 233 </div> 234 </Show> 235 236 <Show when={stage() === "content"}> 237 <div class="mx-auto flex w-full flex-col gap-4" style={fadeIn}> 238 <div 239 class="flex items-center justify-between gap-4" 240 style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }} 241 > 242 <span class="text-text flex min-w-0 gap-1.5"> 243 <span class="truncate">{fileName()}</span> 244 <span class="text-muted shrink-0 font-medium"> 245 {formatBytes(size())} 246 </span> 247 </span> 248 <div class="flex shrink-0 items-center gap-2"> 249 <Show when={contentType() === "text"}> 250 <button class={ghostClass} onClick={copyText}> 251 {copied() ? "copied!" : "copy"} 252 </button> 253 </Show> 254 <Show when={contentType() !== "binary"}> 255 <button class={ghostClass} onClick={saveFile}> 256 save 257 </button> 258 </Show> 259 </div> 260 </div> 261 262 <Show when={contentType() === "image"}> 263 <div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4"> 264 <img 265 src={imageSrc()} 266 alt={fileName()} 267 class="max-h-[70vh] w-fit max-w-full rounded object-contain" 268 /> 269 </div> 270 </Show> 271 <Show when={contentType() === "video"}> 272 <div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4"> 273 <video 274 src={mediaSrc()} 275 controls 276 class="max-h-[70vh] w-fit max-w-full rounded object-contain" 277 /> 278 </div> 279 </Show> 280 <Show when={contentType() === "audio"}> 281 <div class="bg-surface border-border rounded-lg border p-4"> 282 <audio src={mediaSrc()} controls class="w-full" /> 283 </div> 284 </Show> 285 <Show when={contentType() === "text"}> 286 <div class="bg-surface border-border max-h-[70vh] w-full overflow-auto rounded-lg border p-4 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap"> 287 {textContent()} 288 </div> 289 </Show> 290 <Show when={contentType() === "binary"}> 291 <div class="flex justify-center"> 292 <button class={btnClass} style={btnStyle} onClick={saveFile}> 293 download 294 </button> 295 </div> 296 </Show> 297 298 <div class="flex items-center justify-between"> 299 <span class={`text-xs ${burned() ? "text-danger" : "text-muted"}`}> 300 {burned() ? "burned" : formatExpiry(expiresAt())} 301 </span> 302 <a href="/" class={ghostClass}> 303 new drop 304 </a> 305 </div> 306 </div> 307 </Show> 308 </> 309 ); 310}