index.ts
| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { mkdirSync } from "node:fs"; |
| 3 | import { unlink } from "node:fs/promises"; |
| 4 | import { Readable } from "node:stream"; |
| 5 | import { Database } from "bun:sqlite"; |
| 6 | import cron from "@elysiajs/cron"; |
| 7 | import { html } from "@elysiajs/html"; |
| 8 | import staticPlugin from "@elysiajs/static"; |
| 9 | import busboy from "busboy"; |
| 10 | import { Elysia, StatusMap, t } from "elysia"; |
| 11 | import { fileTypeFromBuffer } from "file-type"; |
| 12 | import { |
| 13 | filetypes, |
| 14 | Index, |
| 15 | NotFound, |
| 16 | SetCookie, |
| 17 | ShowFile, |
| 18 | textPreviewHtml, |
| 19 | WrongPassword, |
| 20 | type Preview, |
| 21 | } from "./components"; |
| 22 | import { config } from "./config"; |
| 23 | import { decryptToStream, encryptStream } from "./crypto"; |
| 24 | import { getTheme } from "./shared"; |
| 25 | |
| 26 | // Paths default to ./db; overridable via env so tests can point at a throwaway |
| 27 | // directory instead of the real database/blobs. |
| 28 | const BLOB_DIR = Bun.env.ZBIN_BLOB_DIR ?? "./db/blobs"; |
| 29 | mkdirSync(BLOB_DIR, { recursive: true }); |
| 30 | const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`; |
| 31 | const safeUnlink = (path: string) => unlink(path).catch(() => {}); |
| 32 | |
| 33 | const filetypeSet = new Set(filetypes); |
| 34 | const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection |
| 35 | const MAX_FILENAME_LEN = 255; // cap on a user-supplied filename (bytes/chars) |
| 36 | // Above this on-disk size we don't read+highlight a text file inline (it would |
| 37 | // buffer the whole file, and its HTML is larger still); /show offers download |
| 38 | // instead. /raw still streams the full file regardless. |
| 39 | const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB |
| 40 | |
| 41 | const db = new Database(Bun.env.ZBIN_DB_PATH ?? "./db/db.sqlite"); |
| 42 | db.run("PRAGMA foreign_keys = ON"); |
| 43 | db.run("PRAGMA journal_mode = WAL"); |
| 44 | // Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata. |
| 45 | // `size` is the on-disk byte count (post-encryption) and drives the total-bytes |
| 46 | // cap; `media_mime` is the MIME sniffed from the plaintext head at upload time, |
| 47 | // letting /show preview media without decrypting. |
| 48 | db.run( |
| 49 | "CREATE TABLE IF NOT EXISTS files (uuid TEXT PRIMARY KEY, filename TEXT NOT NULL, filetype TEXT NOT NULL, encrypted INTEGER NOT NULL, size INTEGER NOT NULL, media_mime TEXT, delete_at INTEGER) STRICT", |
| 50 | ); |
| 51 | // The expiry cron scans by delete_at every few seconds; index it so that stays |
| 52 | // a range lookup instead of a full table scan as the table grows. |
| 53 | db.run("CREATE INDEX IF NOT EXISTS idx_files_delete_at ON files(delete_at)"); |
| 54 | db.run("PRAGMA optimize=0x10002"); |
| 55 | |
| 56 | // Running total of stored content bytes, initialized once from the DB and then |
| 57 | // maintained in memory (incremented on upload, decremented when files expire). |
| 58 | let totalBytes = ( |
| 59 | db.prepare("SELECT COALESCE(SUM(size), 0) AS t FROM files").get() as { |
| 60 | t: number; |
| 61 | } |
| 62 | ).t; |
| 63 | |
| 64 | // uuid route params are constrained to this shape so they can't be used to |
| 65 | // inject CRLF/extra directives into the Set-Cookie Path or content-disposition, |
| 66 | // or to escape the blob directory. |
| 67 | const UUID_PATTERN = "^[0-9a-fA-F-]{36}$"; |
| 68 | |
| 69 | // Per-IP timestamp of the last accepted upload / last server-side decryption |
| 70 | // attempt, used for the respective cooldowns. Pruned by the cron below so they |
| 71 | // can't grow without bound. |
| 72 | const lastUpload = new Map<string, number>(); |
| 73 | const lastDecrypt = new Map<string, number>(); |
| 74 | |
| 75 | type MinimalServer = { |
| 76 | requestIP(req: Request): { address: string } | null; |
| 77 | } | null; |
| 78 | |
| 79 | function clientIp( |
| 80 | server: MinimalServer, |
| 81 | request: Request, |
| 82 | headers: Record<string, string | undefined>, |
| 83 | ): string { |
| 84 | if (config.behindProxy) { |
| 85 | const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim(); |
| 86 | if (xff) return xff; |
| 87 | const real = headers["x-real-ip"]; |
| 88 | if (real) return real; |
| 89 | } |
| 90 | return server?.requestIP(request)?.address ?? "unknown"; |
| 91 | } |
| 92 | |
| 93 | // An upload failure that maps to a specific HTTP status + message. |
| 94 | class UploadError extends Error { |
| 95 | constructor( |
| 96 | readonly status: number, |
| 97 | message: string, |
| 98 | ) { |
| 99 | super(message); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Read and discard the rest of a request body. When /upload rejects a request |
| 104 | // before it parses the body (cooldown, bad content type), the client is still |
| 105 | // streaming the file up; returning immediately cancels that in-flight stream and |
| 106 | // the client sees a reset (NET_INTERRUPTED / H3_REQUEST_CANCELLED over HTTP/2/3) |
| 107 | // instead of our response. Draining lets the request finish so the error gets |
| 108 | // delivered. Best-effort: a client that hangs up mid-drain just errors here. |
| 109 | async function drainBody(request: Request): Promise<void> { |
| 110 | if (!request.body) return; |
| 111 | const reader = request.body.getReader(); |
| 112 | try { |
| 113 | while (!(await reader.read()).done) {} |
| 114 | } catch { |
| 115 | } finally { |
| 116 | reader.releaseLock(); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | async function pump( |
| 121 | stream: ReadableStream<Uint8Array>, |
| 122 | onChunk: (chunk: Uint8Array) => void | Promise<void>, |
| 123 | ): Promise<void> { |
| 124 | const reader = stream.getReader(); |
| 125 | while (true) { |
| 126 | const { done, value } = await reader.read(); |
| 127 | if (done) break; |
| 128 | // Await the callback so a slow sink applies backpressure (Bun's FileSink |
| 129 | // .write returns a Promise when the write is still pending) instead of |
| 130 | // buffering the whole upload in memory. |
| 131 | await onChunk(value); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | async function sniffMime(bytes: Uint8Array): Promise<string | null> { |
| 136 | if (bytes.length === 0) return null; |
| 137 | return (await fileTypeFromBuffer(bytes))?.mime ?? null; |
| 138 | } |
| 139 | |
| 140 | // Media we preview inline via <img>/<audio>/<video> pointing at /raw. These |
| 141 | // stream from /raw, so /show never reads them into memory and the text-preview |
| 142 | // size cap doesn't apply. SVG is excluded (script-capable; /raw refuses to |
| 143 | // serve it inline) so it falls back to being shown as escaped text instead. |
| 144 | function isInlineMedia(mime: string): boolean { |
| 145 | return ( |
| 146 | mime !== "image/svg+xml" && |
| 147 | (mime.startsWith("image/") || |
| 148 | mime.startsWith("audio/") || |
| 149 | mime.startsWith("video/")) |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | const app = new Elysia({ |
| 154 | serve: { |
| 155 | maxRequestBodySize: config.maxUploadBytes, |
| 156 | }, |
| 157 | }) |
| 158 | .use(staticPlugin({ assets: "./assets", prefix: "/" })) |
| 159 | .use(html()) |
| 160 | .use( |
| 161 | cron({ |
| 162 | name: "delete", |
| 163 | pattern: "*/5 * * * * *", |
| 164 | async run() { |
| 165 | const expired = db |
| 166 | .prepare( |
| 167 | "SELECT uuid, size FROM files WHERE delete_at < strftime('%s', 'now')", |
| 168 | ) |
| 169 | .all() as { uuid: string; size: number }[]; |
| 170 | if (expired.length > 0) { |
| 171 | // Delete by the exact uuids selected above, not a second |
| 172 | // strftime('now') comparison (which evaluates at a later instant and |
| 173 | // could delete a row we didn't account for here — leaking the counter |
| 174 | // and orphaning its blob). |
| 175 | const placeholders = expired.map(() => "?").join(","); |
| 176 | db.exec( |
| 177 | `DELETE FROM files WHERE uuid IN (${placeholders})`, |
| 178 | expired.map((e) => e.uuid), |
| 179 | ); |
| 180 | for (const { uuid, size } of expired) { |
| 181 | await safeUnlink(blobPath(uuid)); |
| 182 | totalBytes -= size; |
| 183 | } |
| 184 | if (totalBytes < 0) totalBytes = 0; |
| 185 | } |
| 186 | const now = Date.now(); |
| 187 | if (config.uploadCooldownSeconds > 0) { |
| 188 | const cutoff = now - config.uploadCooldownSeconds * 1000; |
| 189 | for (const [ip, ts] of lastUpload) { |
| 190 | if (ts < cutoff) lastUpload.delete(ip); |
| 191 | } |
| 192 | } |
| 193 | if (config.decryptCooldownSeconds > 0) { |
| 194 | const cutoff = now - config.decryptCooldownSeconds * 1000; |
| 195 | for (const [ip, ts] of lastDecrypt) { |
| 196 | if (ts < cutoff) lastDecrypt.delete(ip); |
| 197 | } |
| 198 | } |
| 199 | }, |
| 200 | }), |
| 201 | ) |
| 202 | .use( |
| 203 | cron({ |
| 204 | // Run SQLite's optimizer periodically (not just at startup), per its docs. |
| 205 | name: "optimize", |
| 206 | pattern: "0 0 * * * *", |
| 207 | run() { |
| 208 | db.exec("PRAGMA optimize "); |
| 209 | }, |
| 210 | }), |
| 211 | ) |
| 212 | .get("/", ({ server, headers }) => |
| 213 | Index(server?.url.toString() ?? "", getTheme(headers.cookie)), |
| 214 | ) |
| 215 | // Cycles the theme preference (auto → light → dark) for clients without JS: |
| 216 | // the toggle is a form that POSTs here, we flip the cookie and redirect back. |
| 217 | // JS clients intercept the submit and never hit this route. |
| 218 | .post("/theme/cycle", ({ set, headers }) => { |
| 219 | const order = ["auto", "light", "dark"] as const; |
| 220 | const next = |
| 221 | order[(order.indexOf(getTheme(headers.cookie)) + 1) % order.length]; |
| 222 | const secure = config.behindProxy ? "; Secure" : ""; |
| 223 | set.headers["set-cookie"] = |
| 224 | `zbin-theme=${next}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`; |
| 225 | // Redirect back to the page the toggle was on, using only the path so the |
| 226 | // Referer can't be turned into an open redirect to another host. |
| 227 | let back = "/"; |
| 228 | try { |
| 229 | back = new URL(headers.referer ?? "").pathname || "/"; |
| 230 | } catch {} |
| 231 | set.headers.location = back; |
| 232 | set.status = StatusMap["See Other"]; |
| 233 | return ""; |
| 234 | }) |
| 235 | .post( |
| 236 | "/upload", |
| 237 | async ({ set, server, request, headers }) => { |
| 238 | const ip = clientIp(server, request, headers); |
| 239 | const now = Date.now(); |
| 240 | if (config.uploadCooldownSeconds > 0) { |
| 241 | const last = lastUpload.get(ip) ?? 0; |
| 242 | if (now - last < config.uploadCooldownSeconds * 1000) { |
| 243 | await drainBody(request); |
| 244 | set.status = 429; // Too Many Requests |
| 245 | return "Upload cooldown active, please wait before uploading again"; |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | const contentType = request.headers.get("content-type") ?? ""; |
| 250 | if (!contentType.includes("multipart/form-data") || !request.body) { |
| 251 | await drainBody(request); |
| 252 | set.status = 400; |
| 253 | return "Expected a multipart/form-data upload"; |
| 254 | } |
| 255 | |
| 256 | const uuid = randomUUID(); |
| 257 | const path = blobPath(uuid); |
| 258 | |
| 259 | // The in-flight blob write. A rejection can settle the upload while this |
| 260 | // is still draining to disk; the catch awaits it before unlinking so a |
| 261 | // late write can't recreate the blob after cleanup (orphan). |
| 262 | let writing: Promise<unknown> | undefined; |
| 263 | |
| 264 | try { |
| 265 | // Parse the multipart body as it streams in. The file part must come |
| 266 | // LAST so the other fields (password, filetype, ...) are known before |
| 267 | // the bytes flow and can drive on-the-fly encryption to disk. |
| 268 | const result = await new Promise<{ |
| 269 | filename: string; |
| 270 | filetype: string; |
| 271 | encrypted: boolean; |
| 272 | size: number; |
| 273 | mediaMime: string | null; |
| 274 | deleteAt: number | null; |
| 275 | }>((resolve, reject) => { |
| 276 | const bb = busboy({ |
| 277 | headers: { "content-type": contentType }, |
| 278 | limits: { files: 1, fileSize: config.maxUploadBytes }, |
| 279 | }); |
| 280 | const fields: Record<string, string> = {}; |
| 281 | let fileSeen = false; |
| 282 | // A form field arriving after the file part means the file wasn't |
| 283 | // sent last. We don't act on it here (the file is still streaming) — |
| 284 | // we record it and report it once the whole body is parsed, so the |
| 285 | // "file must be last" error always wins over an incidental symptom |
| 286 | // like a not-yet-seen filetype. |
| 287 | let fieldAfterFile = false; |
| 288 | |
| 289 | bb.on("field", (name, value) => { |
| 290 | if (fileSeen) fieldAfterFile = true; |
| 291 | else fields[name] = value; |
| 292 | }); |
| 293 | |
| 294 | bb.on("file", (name, stream, info) => { |
| 295 | if (name !== "file") { |
| 296 | stream.resume(); |
| 297 | return; |
| 298 | } |
| 299 | fileSeen = true; |
| 300 | writing = streamToBlob(stream, info); |
| 301 | }); |
| 302 | |
| 303 | bb.on("error", reject); |
| 304 | |
| 305 | // Settle once the entire body is parsed: by now the field set and the |
| 306 | // file's position relative to other fields are both fully known. |
| 307 | bb.on("close", async () => { |
| 308 | try { |
| 309 | if (!fileSeen) throw new UploadError(400, "No file provided"); |
| 310 | if (fieldAfterFile) { |
| 311 | throw new UploadError( |
| 312 | 400, |
| 313 | "the file field must be the last form field", |
| 314 | ); |
| 315 | } |
| 316 | // Set because a file part was seen; await it to surface any |
| 317 | // streaming error and read the stored size/type. |
| 318 | const file = await (writing as ReturnType<typeof streamToBlob>); |
| 319 | |
| 320 | const filetype = fields.filetype ?? ""; |
| 321 | if (!filetypeSet.has(filetype)) { |
| 322 | throw new UploadError(400, "Invalid or missing filetype"); |
| 323 | } |
| 324 | if ( |
| 325 | fields.filename && |
| 326 | fields.filename.length > MAX_FILENAME_LEN |
| 327 | ) { |
| 328 | throw new UploadError(400, "Filename too long"); |
| 329 | } |
| 330 | const deleteInMinutes = fields.delete_in_minutes; |
| 331 | if (deleteInMinutes && !/^[0-9]+$/.test(deleteInMinutes)) { |
| 332 | throw new UploadError(400, "Invalid delete_in_minutes"); |
| 333 | } |
| 334 | let minutes: number | null = |
| 335 | deleteInMinutes && Number(deleteInMinutes) > 0 |
| 336 | ? Number(deleteInMinutes) |
| 337 | : null; |
| 338 | if (config.maxAgeMinutes !== null) { |
| 339 | minutes = Math.min( |
| 340 | minutes ?? config.maxAgeMinutes, |
| 341 | config.maxAgeMinutes, |
| 342 | ); |
| 343 | } |
| 344 | const deleteAt = |
| 345 | minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null; |
| 346 | |
| 347 | resolve({ |
| 348 | filename: fields.filename || file.infoFilename || "file", |
| 349 | filetype, |
| 350 | encrypted: file.encrypted, |
| 351 | size: file.size, |
| 352 | mediaMime: file.mediaMime, |
| 353 | deleteAt, |
| 354 | }); |
| 355 | } catch (e) { |
| 356 | reject(e); |
| 357 | } |
| 358 | }); |
| 359 | |
| 360 | // Streams the file part to ./db/blobs/<uuid>, encrypting on the fly |
| 361 | // when a password was provided (server-side) or storing opaque bytes |
| 362 | // for an already-encrypted upload. Uses the fields seen so far; if the |
| 363 | // file wasn't last that set is incomplete, but the close handler |
| 364 | // rejects such uploads before anything is persisted. |
| 365 | async function streamToBlob(stream: Readable, info: busboy.FileInfo) { |
| 366 | let limitExceeded = false; |
| 367 | stream.on("limit", () => { |
| 368 | limitExceeded = true; |
| 369 | }); |
| 370 | const webIn = Readable.toWeb( |
| 371 | stream, |
| 372 | ) as unknown as ReadableStream<Uint8Array>; |
| 373 | |
| 374 | const sink = Bun.file(path).writer(); |
| 375 | let size = 0; |
| 376 | let mediaMime: string | null = null; |
| 377 | let encrypted = false; |
| 378 | |
| 379 | if (fields.encrypted === "on") { |
| 380 | // Client-side encrypted: opaque bytes, store as-is, no sniffing. |
| 381 | encrypted = true; |
| 382 | await pump(webIn, async (c) => { |
| 383 | size += c.length; |
| 384 | await sink.write(c); |
| 385 | }); |
| 386 | } else if (fields.password) { |
| 387 | // Server-side encryption: encrypt the stream to disk, sniffing |
| 388 | // the plaintext head for a preview MIME type. |
| 389 | encrypted = true; |
| 390 | let head: Uint8Array | undefined; |
| 391 | const cipher = await encryptStream( |
| 392 | webIn, |
| 393 | fields.password, |
| 394 | (h) => { |
| 395 | head = h; |
| 396 | }, |
| 397 | SNIFF_BYTES, |
| 398 | ); |
| 399 | await pump(cipher, async (c) => { |
| 400 | size += c.length; |
| 401 | await sink.write(c); |
| 402 | }); |
| 403 | if (head) mediaMime = await sniffMime(head); |
| 404 | } else { |
| 405 | // Plaintext: stream to disk, collecting the head for sniffing. |
| 406 | const headParts: Uint8Array[] = []; |
| 407 | let headLen = 0; |
| 408 | await pump(webIn, async (c) => { |
| 409 | size += c.length; |
| 410 | await sink.write(c); |
| 411 | if (headLen < SNIFF_BYTES) { |
| 412 | const slice = c.subarray(0, SNIFF_BYTES - headLen); |
| 413 | headParts.push(slice); |
| 414 | headLen += slice.length; |
| 415 | } |
| 416 | }); |
| 417 | mediaMime = await sniffMime(Buffer.concat(headParts)); |
| 418 | } |
| 419 | |
| 420 | await sink.end(); |
| 421 | if (limitExceeded) { |
| 422 | throw new UploadError( |
| 423 | 413, |
| 424 | `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`, |
| 425 | ); |
| 426 | } |
| 427 | return { |
| 428 | encrypted, |
| 429 | size, |
| 430 | mediaMime, |
| 431 | infoFilename: info.filename ?? "", |
| 432 | }; |
| 433 | } |
| 434 | |
| 435 | Readable.fromWeb( |
| 436 | request.body as unknown as import("node:stream/web").ReadableStream<Uint8Array>, |
| 437 | ).pipe(bb); |
| 438 | }); |
| 439 | |
| 440 | // Enforce the total-bytes cap against the in-memory counter. Concurrent |
| 441 | // uploads can transiently overshoot by up to (concurrency * per-file) |
| 442 | // before this check; acceptable at the expected scale. |
| 443 | if ( |
| 444 | config.maxTotalBytes !== null && |
| 445 | totalBytes + result.size > config.maxTotalBytes |
| 446 | ) { |
| 447 | await safeUnlink(path); |
| 448 | set.status = 507; // Insufficient Storage |
| 449 | return "Server storage is full, try again later"; |
| 450 | } |
| 451 | |
| 452 | db.exec( |
| 453 | "INSERT INTO files (uuid, filename, filetype, encrypted, size, media_mime, delete_at) VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 454 | [ |
| 455 | uuid, |
| 456 | result.filename, |
| 457 | result.filetype, |
| 458 | result.encrypted, |
| 459 | result.size, |
| 460 | result.mediaMime, |
| 461 | result.deleteAt, |
| 462 | ], |
| 463 | ); |
| 464 | totalBytes += result.size; |
| 465 | if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now); |
| 466 | set.status = StatusMap["See Other"]; |
| 467 | set.headers.location = `/show/${uuid}`; |
| 468 | return `Created with id: ${uuid}`; |
| 469 | } catch (e) { |
| 470 | // Let any in-flight write finish so it can't recreate the blob after |
| 471 | // we unlink it below. |
| 472 | if (writing) await writing.catch(() => {}); |
| 473 | await safeUnlink(path); |
| 474 | if (e instanceof UploadError) { |
| 475 | set.status = e.status; |
| 476 | return e.message; |
| 477 | } |
| 478 | throw e; |
| 479 | } |
| 480 | }, |
| 481 | { |
| 482 | // Body parsing is handled manually from the raw stream, so disable |
| 483 | // Elysia's parser (which would otherwise buffer the whole upload). |
| 484 | parse: "none", |
| 485 | }, |
| 486 | ) |
| 487 | .get( |
| 488 | "/show/:uuid", |
| 489 | async ({ set, params, cookie, server, request, headers }) => { |
| 490 | const theme = getTheme(headers.cookie); |
| 491 | const row = |
| 492 | (db |
| 493 | .prepare( |
| 494 | "SELECT filename, filetype, encrypted, size, media_mime, delete_at FROM files WHERE uuid = ?", |
| 495 | ) |
| 496 | .get(params.uuid) as { |
| 497 | filename: string; |
| 498 | filetype: string; |
| 499 | encrypted: number; |
| 500 | size: number; |
| 501 | media_mime: string | null; |
| 502 | delete_at: number | null; |
| 503 | }) || null; |
| 504 | if (!row) { |
| 505 | set.status = StatusMap["Not Found"]; |
| 506 | return NotFound(theme); |
| 507 | } |
| 508 | |
| 509 | const path = blobPath(params.uuid); |
| 510 | if (!(await Bun.file(path).exists())) { |
| 511 | // Row without its blob (e.g. the file was removed out of band): treat |
| 512 | // as not found rather than failing later while reading/streaming it. |
| 513 | set.status = StatusMap["Not Found"]; |
| 514 | return NotFound(theme); |
| 515 | } |
| 516 | let preview: Preview; |
| 517 | let shownSize: number | null = row.size; |
| 518 | |
| 519 | if (row.encrypted) { |
| 520 | const password = cookie.password.value; |
| 521 | if (!password) { |
| 522 | // No password yet: let the client-side flow handle decryption. |
| 523 | preview = { kind: "await" }; |
| 524 | shownSize = null; |
| 525 | } else if (row.media_mime && isInlineMedia(row.media_mime)) { |
| 526 | // Inline media: preview points at /raw, which performs the single |
| 527 | // decryption while streaming — nothing is read here, so the |
| 528 | // text-size cap below doesn't apply. |
| 529 | preview = { kind: "media", mime: row.media_mime }; |
| 530 | } else if (row.filetype === "blob") { |
| 531 | // Binary that isn't inline media: not previewable. |
| 532 | preview = { kind: "none" }; |
| 533 | } else if (row.size > MAX_TEXT_PREVIEW_BYTES) { |
| 534 | // Too large to decrypt + highlight inline; offer download instead. |
| 535 | preview = { kind: "toolarge" }; |
| 536 | } else { |
| 537 | // Text: this is the one server-side decryption for the view. |
| 538 | const ip = clientIp(server, request, headers); |
| 539 | if (decryptBlocked(ip)) { |
| 540 | set.status = 429; |
| 541 | return "Decryption cooldown active, please wait and reload"; |
| 542 | } |
| 543 | try { |
| 544 | const content = await readDecrypted(path, password, row.size); |
| 545 | recordDecrypt(ip); |
| 546 | const out = new Uint8Array(content); |
| 547 | shownSize = out.byteLength; |
| 548 | const text = textPreviewHtml(out, row.filetype); |
| 549 | preview = |
| 550 | text !== null ? { kind: "text", html: text } : { kind: "none" }; |
| 551 | } catch (_e) { |
| 552 | recordDecrypt(ip); |
| 553 | const secure = config.behindProxy ? "; Secure" : ""; |
| 554 | set.status = StatusMap.Forbidden; |
| 555 | set.headers["set-cookie"] = [ |
| 556 | `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, |
| 557 | `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, |
| 558 | ]; |
| 559 | return WrongPassword(theme); |
| 560 | } |
| 561 | } |
| 562 | } else if (row.media_mime && isInlineMedia(row.media_mime)) { |
| 563 | // Inline media: previews via /raw, so it's never read here and the |
| 564 | // text-size cap doesn't apply. |
| 565 | preview = { kind: "media", mime: row.media_mime }; |
| 566 | } else if (row.filetype !== "blob") { |
| 567 | if (row.size > MAX_TEXT_PREVIEW_BYTES) { |
| 568 | // Too large to read + highlight inline; offer download instead. |
| 569 | preview = { kind: "toolarge" }; |
| 570 | } else { |
| 571 | // Plaintext text: read from disk and render inline. |
| 572 | const content = new Uint8Array(await Bun.file(path).bytes()); |
| 573 | shownSize = content.byteLength; |
| 574 | const text = textPreviewHtml(content, row.filetype); |
| 575 | preview = |
| 576 | text !== null ? { kind: "text", html: text } : { kind: "none" }; |
| 577 | } |
| 578 | } else { |
| 579 | // Plaintext binary that isn't inline media: not previewable. |
| 580 | preview = { kind: "none" }; |
| 581 | } |
| 582 | |
| 583 | return ShowFile({ |
| 584 | filename: row.filename, |
| 585 | uuid: params.uuid, |
| 586 | filetype: row.filetype, |
| 587 | deleteAt: row.delete_at, |
| 588 | size: shownSize, |
| 589 | preview, |
| 590 | theme, |
| 591 | }); |
| 592 | }, |
| 593 | { |
| 594 | params: t.Object({ |
| 595 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 596 | }), |
| 597 | cookie: t.Object({ password: t.Optional(t.String()) }), |
| 598 | }, |
| 599 | ) |
| 600 | .post( |
| 601 | "/set-cookie/:uuid", |
| 602 | ({ set, body, params, headers }) => { |
| 603 | // encodeURIComponent keeps ';', CR/LF and other separators out of the |
| 604 | // cookie value; Elysia URL-decodes the value again when it reads it back. |
| 605 | const value = encodeURIComponent(body.password); |
| 606 | const secure = config.behindProxy ? "; Secure" : ""; |
| 607 | set.headers["set-cookie"] = [ |
| 608 | `password=${value}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}`, |
| 609 | `password=${value}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}`, |
| 610 | ]; |
| 611 | set.headers.location = `/show/${params.uuid}`; |
| 612 | set.status = StatusMap["See Other"]; |
| 613 | return SetCookie(params.uuid, getTheme(headers.cookie)); |
| 614 | }, |
| 615 | { |
| 616 | body: t.Object({ |
| 617 | password: t.String(), |
| 618 | }), |
| 619 | params: t.Object({ |
| 620 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 621 | }), |
| 622 | }, |
| 623 | ) |
| 624 | .get( |
| 625 | "/raw/:uuid", |
| 626 | async ({ set, params, cookie, query, server, request, headers }) => { |
| 627 | const row = |
| 628 | (db |
| 629 | .prepare( |
| 630 | "SELECT filename, encrypted, filetype, size FROM files WHERE uuid = ?", |
| 631 | ) |
| 632 | .get(params.uuid) as { |
| 633 | filename: string; |
| 634 | encrypted: number; |
| 635 | filetype: string; |
| 636 | size: number; |
| 637 | }) || null; |
| 638 | if (!row) { |
| 639 | set.status = StatusMap["Not Found"]; |
| 640 | return "File not found"; |
| 641 | } |
| 642 | |
| 643 | const path = blobPath(params.uuid); |
| 644 | if (!(await Bun.file(path).exists())) { |
| 645 | // Row without its blob: 404 instead of failing mid-stream / mid-decrypt. |
| 646 | set.status = StatusMap["Not Found"]; |
| 647 | return "File not found"; |
| 648 | } |
| 649 | const servingEncrypted = |
| 650 | row.encrypted && query.ignore_password === "true"; |
| 651 | |
| 652 | let body: ReadableStream<Uint8Array> | ReturnType<typeof Bun.file>; |
| 653 | let sniff: Uint8Array | null = null; |
| 654 | |
| 655 | if (row.encrypted && !servingEncrypted) { |
| 656 | const password = cookie.password.value; |
| 657 | if (!password) { |
| 658 | set.status = StatusMap.Unauthorized; |
| 659 | return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it'; |
| 660 | } |
| 661 | const ip = clientIp(server, request, headers); |
| 662 | if (decryptBlocked(ip)) { |
| 663 | set.status = 429; |
| 664 | return "Decryption cooldown active, please wait and retry"; |
| 665 | } |
| 666 | try { |
| 667 | const { firstChunk, stream } = await openDecrypted( |
| 668 | path, |
| 669 | password, |
| 670 | row.size, |
| 671 | ); |
| 672 | recordDecrypt(ip); |
| 673 | body = stream; |
| 674 | sniff = firstChunk.subarray(0, SNIFF_BYTES); |
| 675 | } catch (_e) { |
| 676 | recordDecrypt(ip); |
| 677 | set.status = StatusMap.Forbidden; |
| 678 | return "Incorrect password"; |
| 679 | } |
| 680 | } else { |
| 681 | // Serve the file as-is: plaintext, or (with ignore_password) the still |
| 682 | // encrypted bytes. BunFile streams and supports range requests. |
| 683 | body = Bun.file(path); |
| 684 | if (!servingEncrypted) { |
| 685 | sniff = new Uint8Array( |
| 686 | await Bun.file(path).slice(0, SNIFF_BYTES).arrayBuffer(), |
| 687 | ); |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | // Never let the browser sniff stored content into an executable type |
| 692 | // (e.g. HTML/SVG running as same-origin script). Only whitelisted, |
| 693 | // non-scriptable media is served inline with its real type; everything |
| 694 | // else (incl. still-encrypted bytes) is an octet-stream attachment. |
| 695 | let mime = "application/octet-stream"; |
| 696 | let disposition = "attachment"; |
| 697 | if (sniff) { |
| 698 | const detected = await fileTypeFromBuffer(sniff); |
| 699 | if (detected && isInlineMedia(detected.mime)) { |
| 700 | mime = detected.mime; |
| 701 | disposition = "inline"; |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | const safeName = encodeURIComponent(row.filename); |
| 706 | set.headers["x-content-type-options"] = "nosniff"; |
| 707 | set.headers["content-type"] = mime; |
| 708 | set.headers.encrypted = row.encrypted ? "true" : "false"; |
| 709 | set.headers.filetype = row.filetype; |
| 710 | set.headers.filename = safeName; |
| 711 | set.headers["content-disposition"] = |
| 712 | `${disposition}; filename*=UTF-8''${safeName}`; |
| 713 | return body; |
| 714 | }, |
| 715 | { |
| 716 | params: t.Object({ |
| 717 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 718 | }), |
| 719 | cookie: t.Object({ password: t.Optional(t.String()) }), |
| 720 | query: t.Object({ ignore_password: t.Optional(t.String()) }), |
| 721 | }, |
| 722 | ); |
| 723 | |
| 724 | // --- server-side decryption cooldown ---------------------------------------- |
| 725 | |
| 726 | function decryptBlocked(ip: string): boolean { |
| 727 | if (config.decryptCooldownSeconds <= 0) return false; |
| 728 | const last = lastDecrypt.get(ip) ?? 0; |
| 729 | return Date.now() - last < config.decryptCooldownSeconds * 1000; |
| 730 | } |
| 731 | function recordDecrypt(ip: string): void { |
| 732 | if (config.decryptCooldownSeconds > 0) lastDecrypt.set(ip, Date.now()); |
| 733 | } |
| 734 | |
| 735 | // Fully decrypt an on-disk blob to memory (used for text previews in /show). |
| 736 | async function readDecrypted( |
| 737 | path: string, |
| 738 | password: string, |
| 739 | size: number, |
| 740 | ): Promise<Uint8Array> { |
| 741 | const { stream } = await openDecrypted(path, password, size); |
| 742 | const parts: Uint8Array[] = []; |
| 743 | await pump(stream, (c) => { |
| 744 | parts.push(c); |
| 745 | }); |
| 746 | return Buffer.concat(parts); |
| 747 | } |
| 748 | |
| 749 | // Open an on-disk blob for streaming decryption, exposing the first plaintext |
| 750 | // chunk (for MIME sniffing) and the full plaintext stream from one derivation. |
| 751 | async function openDecrypted( |
| 752 | path: string, |
| 753 | password: string, |
| 754 | size: number, |
| 755 | ): Promise<{ firstChunk: Uint8Array; stream: ReadableStream<Uint8Array> }> { |
| 756 | const { firstChunk, body } = await decryptToStream( |
| 757 | Bun.file(path).stream(), |
| 758 | password, |
| 759 | size, |
| 760 | ); |
| 761 | return { firstChunk, stream: body }; |
| 762 | } |
| 763 | |
| 764 | export { app }; |
| 765 | |
| 766 | // Only bind a port (and log) when run directly with `bun src/index.ts`. Under |
| 767 | // `bun test` the module is imported and exercised via app.handle() instead, so |
| 768 | // it must not occupy port 3000. |
| 769 | if (import.meta.main) { |
| 770 | app.listen(3000); |
| 771 | console.log( |
| 772 | `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`, |
| 773 | ); |
| 774 | } |
| 775 |