components.tsx
| 1 | // biome-ignore lint/correctness/noUnusedImports: Html is the JSX factory (tsconfig jsxFactory: Html.createElement) |
| 2 | import { Html } from "@elysiajs/html"; |
| 3 | import type { PropsWithChildren } from "@kitajs/html"; |
| 4 | import { escapeHTML } from "bun"; |
| 5 | import hljs from "highlight.js"; |
| 6 | import { config } from "./config"; |
| 7 | import { humanFileSize, isValidUTF8, type Theme, themeIcon } from "./shared"; |
| 8 | |
| 9 | export 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. |
| 14 | function 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 | |
| 28 | function 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 | |
| 88 | export 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. |
| 99 | function 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 | |
| 154 | export 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 | |
| 296 | export 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 | |
| 305 | export 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>. |
| 328 | export 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. |
| 340 | export 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 | |
| 347 | export 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 | |
| 459 | export 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 |