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