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