zero-knowledge file sharing
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}