components.tsx
Raw
1// biome-ignore lint/correctness/noUnusedImports: Html is the JSX factory (tsconfig jsxFactory: Html.createElement)
2import { Html } from "@elysiajs/html";
3import type { PropsWithChildren } from "@kitajs/html";
4import { escapeHTML } from "bun";
5import hljs from "highlight.js";
6import { config } from "./config";
7import { humanFileSize, isValidUTF8 } from "./shared";
8
9export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
10
11function Template(props: PropsWithChildren<{ css: string }>) {
12 return (
13 <>
14 {"<!doctype html>"}
15 <html lang="en">
16 <head>
17 <title>⚡ZBin⚡</title>
18 <meta charset="UTF-8" />
19 <meta name="viewport" content="width=device-width, initial-scale=1" />
20 <link rel="stylesheet" href={props.css} />
21 <link
22 rel="icon"
23 href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📂</text></svg>"
24 />
25 </head>
26 <body>{props.children}</body>
27 </html>
28 </>
29 );
30}
31
32export function NotFound() {
33 return (
34 <Template css="/default.css">
35 <h1>404</h1>
36 <p>File not found</p>
37 </Template>
38 );
39}
40
41// Surfaces the server's configured abuse limits so users know the rules before
42// uploading. Each line only shows when the corresponding limit is actually set.
43function ServerLimits() {
44 return (
45 <div id="server-limits">
46 <small>Server limits:</small>
47 <ul>
48 <li>
49 <small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
50 </li>
51 {config.maxTotalBytes !== null ? (
52 <li>
53 <small>
54 Total storage capacity: {humanFileSize(config.maxTotalBytes)}
55 </small>
56 </li>
57 ) : (
58 ""
59 )}
60 {config.maxAgeMinutes !== null ? (
61 <li>
62 <small>
63 Files are kept for at most{" "}
64 {humanReadableTime(config.maxAgeMinutes)} before being deleted
65 </small>
66 </li>
67 ) : (
68 ""
69 )}
70 {config.uploadCooldownSeconds > 0 ? (
71 <li>
72 <small>
73 You can upload once every {config.uploadCooldownSeconds} second
74 {config.uploadCooldownSeconds > 1 ? "s" : ""} from the same
75 address
76 </small>
77 </li>
78 ) : (
79 ""
80 )}
81 {config.decryptCooldownSeconds > 0 ? (
82 <li>
83 <small>
84 Server-side decryption is limited to once every{" "}
85 {config.decryptCooldownSeconds} second
86 {config.decryptCooldownSeconds > 1 ? "s" : ""} from the same
87 address
88 </small>
89 </li>
90 ) : (
91 ""
92 )}
93 </ul>
94 </div>
95 );
96}
97
98export function Index(hostname: string) {
99 return (
100 <Template css="/default.css">
101 <h1>⚡ZBin⚡</h1>
102 <ServerLimits />
103 <dialog id="upload-dialog">File is being uploaded, please wait</dialog>
104 <form
105 action="/upload"
106 id="uploadForm"
107 method="post"
108 enctype="multipart/form-data"
109 onsubmit="return onUploadSubmit()"
110 >
111 <input type="reset" value="Reset form" />
112 <label for="filename">File name override:</label>
113 <small>Optional. If empty, will use the uploaded file name.</small>
114 <input
115 type="text"
116 id="filename"
117 name="filename"
118 placeholder="File name"
119 />
120 <label for="filetype">File type:</label>
121 <small>
122 Used for syntax highlighting, if the file is only containing valid
123 UTF-8 characters. Otherwise it will always be stored as a binary blob.
124 Can select <code>blob</code> to force this detection.
125 </small>
126 <select size="10" required={true} id="filetype" name="filetype">
127 {filetypes.map((filetype, i) => (
128 <option selected={i === 0} value={filetype}>
129 {filetype}
130 </option>
131 ))}
132 </select>
133 <label for="delete-in">Delete in:</label>
134 <small>
135 Optional. If set, file will be deleted after the given number of
136 minutes.
137 </small>
138 <input
139 type="number"
140 id="delete-in"
141 name="delete_in_minutes"
142 min="0"
143 placeholder="0"
144 />
145 <label for="password">Password:</label>
146 <small>
147 Optional. If set, file will be encrypted and requires the given
148 password to open.
149 </small>
150 <input
151 type="password"
152 id="password"
153 name="password"
154 placeholder="Password"
155 />
156 <label for="encrypt-mode-server">Encryption mode:</label>
157 <small>
158 Only relevant when a password is set. Client-side encrypts in your
159 browser so the password never reaches the server (requires
160 JavaScript). Server-side encrypts on upload.
161 </small>
162 <div>
163 <label>
164 <input
165 type="radio"
166 id="encrypt-mode-server"
167 name="encrypt_mode"
168 value="server"
169 checked={true}
170 />{" "}
171 Server-side
172 </label>
173 <label>
174 <input
175 type="radio"
176 name="encrypt_mode"
177 value="client"
178 disabled={true}
179 />{" "}
180 Client-side (in your browser)
181 </label>
182 </div>
183 <div>
184 <label for="encrypted">Already encrypted:</label>
185 <input type="checkbox" id="encrypted" name="encrypted" />
186 </div>
187 <small>
188 If set, file is already encrypted with an algorithm like in the python
189 file available below. Password is ignored in this case.
190 </small>
191 {/* The file input must stay LAST in the DOM so the multipart upload
192 sends the other fields before the file bytes — the server needs the
193 password/filetype to encrypt on the fly as the stream arrives. It is
194 moved back up to its usual spot visually with `order` in default.css. */}
195 <input required={true} type="file" id="file" name="file" />
196 <input type="submit" value="Upload File" />
197 <hr />
198 <h3>curl guide</h3>
199 <p>You can use curl to upload and download files</p>
200 <p>
201 Upload (
202 <b>
203 <code>-F file=…</code> must come last
204 </b>
205 ):
206 </p>
207 <pre>
208 curl {hostname}upload \{"\n"}
209 -F filetype="plaintext" `# optional` \{"\n"}
210 -F filename="file name" `# optional` \{"\n"}
211 -F delete_in_minutes="60" `# optional` \{"\n"}
212 -F password="mypassword" `# optional` \{"\n"}
213 -F encrypted="on" `# optional` \{"\n"}
214 -F file=@/path/to/file
215 </pre>
216 <p>
217 If you upload an already encrypted file, you should set the{" "}
218 <code>encrypted="on"</code> parameter and encrypt it in a compatible
219 way, so the frontend/backend can also decrypt it. You can use this
220 python script to encrypt a file locally:{" "}
221 <a href="/encrypt.py">encrypt.py</a>
222 </p>
223 <p>Download:</p>
224 <pre>
225 curl {hostname}raw/$uuid \{"\n"}
226 --cookie "password=mypassword" `# optional, only if encrypted`
227 </pre>
228 <p>
229 Append <code>?ignore_password=true</code> to the URL to download an
230 encrypted file in its still-encrypted form, without supplying the
231 password. You can then decrypt it locally with this python script:{" "}
232 <a href="/decrypt.py">decrypt.py</a>
233 </p>
234 </form>
235 <script src="/dist/client-index.js" />
236 </Template>
237 );
238}
239
240export function WrongPassword() {
241 return (
242 <Template css="/default.css">
243 <h1>Incorrect password</h1>
244 <p>Reload page to try again</p>
245 </Template>
246 );
247}
248
249export function humanReadableTime(minutes: number) {
250 // Guard on the total before it is broken into units below; an already-expired
251 // (or sub-minute) duration has no meaningful breakdown.
252 if (minutes <= 0) return "expired";
253 const years = Math.floor(minutes / 525600);
254 minutes %= 525600;
255 const days = Math.floor(minutes / 1440);
256 minutes %= 1440;
257 const hours = Math.floor(minutes / 60);
258 minutes %= 60;
259
260 let result = "";
261 if (years > 0) result += `${years} year${years > 1 ? "s" : ""} `;
262 if (days > 0) result += `${days} day${days > 1 ? "s" : ""} `;
263 if (hours > 0) result += `${hours} hour${hours > 1 ? "s" : ""} `;
264 if (minutes > 0) result += `${minutes} minute${minutes > 1 ? "s" : ""} `;
265 if (result === "") result = "0 minutes";
266 return result.trim();
267}
268
269// Renders content as escaped/highlighted HTML for a text preview, or returns
270// null if it isn't previewable text (binary, or the "blob" override). The
271// returned string is already HTML-safe and is injected raw into a <pre>.
272export function textPreviewHtml(
273 content: Uint8Array,
274 filetype: string,
275): string | null {
276 if (filetype === "blob" || !isValidUTF8(content)) return null;
277 const text = new TextDecoder("utf-8").decode(content);
278 if (filetype === "none") return escapeHTML(text);
279 return hljs.highlight(text, { language: filetype }).value;
280}
281
282// What the /show page should render in the preview area. Computed server-side
283// in index.ts so that ShowFile never has to decrypt or read content itself.
284export type Preview =
285 | { kind: "await" } // encrypted, needs client-side decryption (overlay + JS)
286 | { kind: "text"; html: string } // pre-rendered (escaped/highlighted) text
287 | { kind: "media"; mime: string } // <img>/<audio>/<video> pointing at /raw
288 | { kind: "none" }; // not previewable
289
290export function ShowFile(opts: {
291 filename: string;
292 uuid: string;
293 filetype: string;
294 deleteAt: number | null;
295 size: number | null;
296 preview: Preview;
297}) {
298 const { filename, uuid, filetype, deleteAt, size, preview } = opts;
299 const awaiting = preview.kind === "await";
300 const rawUrl = `/raw/${uuid}`;
301
302 let previewEl: JSX.Element = <>This file can't be previewed</>;
303 if (preview.kind === "await") {
304 previewEl = <>Please wait for the file to load</>;
305 } else if (preview.kind === "text") {
306 // Already escaped/highlighted by textPreviewHtml, injected raw.
307 previewEl = <pre>{preview.html}</pre>;
308 } else if (preview.kind === "media") {
309 if (preview.mime.startsWith("audio/")) {
310 previewEl = <audio controls="" src={rawUrl} />;
311 } else if (preview.mime.startsWith("video/")) {
312 previewEl = <video controls src={rawUrl} />;
313 } else if (preview.mime.startsWith("image/")) {
314 previewEl = <img src={rawUrl} alt={filename} />;
315 }
316 }
317
318 return (
319 <Template css="/show.css">
320 {awaiting ? (
321 <div id="decrypt-overlay">
322 <h1 safe>Encrypted file: {filename}</h1>
323 <form
324 id="decrypt-form"
325 action={`/set-cookie/${uuid}`}
326 method="post"
327 onsubmit="return onPasswordSubmit()"
328 >
329 <label id="password-label" for="password">
330 Enter a password to decrypt the file:
331 </label>
332 <input required type="password" id="password" name="password" />
333 <div>
334 <label>
335 <input
336 type="radio"
337 name="decrypt_mode"
338 value="server"
339 checked={true}
340 />{" "}
341 Server-side
342 </label>
343 <label>
344 <input
345 type="radio"
346 name="decrypt_mode"
347 value="client"
348 disabled={true}
349 />{" "}
350 Client-side (in your browser)
351 </label>
352 </div>
353 <input type="submit" value="Submit" />
354 </form>
355 </div>
356 ) : (
357 ""
358 )}
359 <div id="content">
360 <div id="filename" safe>
361 {filename}
362 </div>
363 <div id="mediabox">{previewEl}</div>
364 </div>
365 <div id="sidebar">
366 <h3>File info</h3>
367 <hr />
368 <p id="filesize">
369 size: {size !== null ? humanFileSize(size) : "unknown"}
370 </p>
371 <p>declared type: {filetype}</p>
372 {deleteAt ? (
373 <p>
374 delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
375 {humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
376 )
377 </p>
378 ) : (
379 ""
380 )}
381 <form id="download-form" action={rawUrl} method="get">
382 <button type="submit">Download</button>
383 </form>
384 </div>
385 {awaiting ? <script src="/dist/client-show.js" /> : ""}
386 </Template>
387 );
388}
389
390export function SetCookie(uuid: string) {
391 return (
392 <Template css="/default.css">
393 Redirecting you back to{" "}
394 <a safe href={`/show/${uuid}`}>
395 /show/{uuid}
396 </a>
397 </Template>
398 );
399}
400