index.ts
| 1 | import { Database } from "bun:sqlite"; |
| 2 | import cron from "@elysiajs/cron"; |
| 3 | import { html } from "@elysiajs/html"; |
| 4 | import staticPlugin from "@elysiajs/static"; |
| 5 | import { randomUUIDv7 } from "bun"; |
| 6 | import { Elysia, StatusMap, t } from "elysia"; |
| 7 | import { fileTypeFromBuffer } from "file-type"; |
| 8 | import { |
| 9 | filetypes, |
| 10 | Index, |
| 11 | NotFound, |
| 12 | SetCookie, |
| 13 | ShowFile, |
| 14 | WrongPassword, |
| 15 | } from "./components"; |
| 16 | import { config } from "./config"; |
| 17 | import { decrypt, encrypt } from "./crypto"; |
| 18 | |
| 19 | const db = new Database("./db/db.sqlite"); |
| 20 | db.run("PRAGMA foreign_keys = ON"); |
| 21 | db.run("PRAGMA journal_mode = WAL"); |
| 22 | db.run( |
| 23 | "CREATE TABLE IF NOT EXISTS files (uuid TEXT PRIMARY KEY, filename TEXT NOT NULL, content BLOB NOT NULL, filetype TEXT NOT NULL, encrypted INTEGER NOT NULL, delete_at INTEGER) STRICT", |
| 24 | ); |
| 25 | db.run("PRAGMA optimize"); |
| 26 | |
| 27 | // uuid route params are constrained to this shape so they can't be used to |
| 28 | // inject CRLF/extra directives into the Set-Cookie Path or content-disposition. |
| 29 | const UUID_PATTERN = "^[0-9a-fA-F-]{36}$"; |
| 30 | |
| 31 | // Per-IP timestamp of the last accepted upload, used for the upload cooldown. |
| 32 | // Pruned by the cron below so it can't grow without bound. |
| 33 | const lastUpload = new Map<string, number>(); |
| 34 | |
| 35 | type MinimalServer = { |
| 36 | requestIP(req: Request): { address: string } | null; |
| 37 | } | null; |
| 38 | |
| 39 | function clientIp( |
| 40 | server: MinimalServer, |
| 41 | request: Request, |
| 42 | headers: Record<string, string | undefined>, |
| 43 | ): string { |
| 44 | if (config.behindProxy) { |
| 45 | const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim(); |
| 46 | if (xff) return xff; |
| 47 | const real = headers["x-real-ip"]; |
| 48 | if (real) return real; |
| 49 | } |
| 50 | return server?.requestIP(request)?.address ?? "unknown"; |
| 51 | } |
| 52 | |
| 53 | function stringArrayToEnum<T extends string>( |
| 54 | arr: readonly T[], |
| 55 | ): { [K in T]: K } { |
| 56 | return arr.reduce((acc, key) => { |
| 57 | acc[key] = key; |
| 58 | return acc; |
| 59 | }, Object.create(null)); |
| 60 | } |
| 61 | |
| 62 | const app = new Elysia({ |
| 63 | serve: { |
| 64 | maxRequestBodySize: config.maxUploadBytes, |
| 65 | }, |
| 66 | }) |
| 67 | .use(staticPlugin({ assets: "./assets", prefix: "/" })) |
| 68 | .use(html()) |
| 69 | .use( |
| 70 | cron({ |
| 71 | name: "delete", |
| 72 | pattern: "*/5 * * * * *", |
| 73 | run() { |
| 74 | db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')"); |
| 75 | if (config.uploadCooldownSeconds > 0) { |
| 76 | const cutoff = Date.now() - config.uploadCooldownSeconds * 1000; |
| 77 | for (const [ip, ts] of lastUpload) { |
| 78 | if (ts < cutoff) lastUpload.delete(ip); |
| 79 | } |
| 80 | } |
| 81 | }, |
| 82 | }), |
| 83 | ) |
| 84 | .get("/", ({ server }) => Index(server?.url.toString() ?? "")) |
| 85 | .post( |
| 86 | "/upload", |
| 87 | async ({ set, body, server, request, headers }) => { |
| 88 | const ip = clientIp(server, request, headers); |
| 89 | const now = Date.now(); |
| 90 | if (config.uploadCooldownSeconds > 0) { |
| 91 | const last = lastUpload.get(ip) ?? 0; |
| 92 | if (now - last < config.uploadCooldownSeconds * 1000) { |
| 93 | set.status = 429; // Too Many Requests |
| 94 | return "Upload cooldown active, please wait before uploading again"; |
| 95 | } |
| 96 | } |
| 97 | if (body.file.size > config.maxUploadBytes) { |
| 98 | set.status = 413; // Payload Too Large |
| 99 | return `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`; |
| 100 | } |
| 101 | |
| 102 | const uuid = randomUUIDv7(); |
| 103 | let content: Uint8Array = Buffer.from(await body.file.bytes()); |
| 104 | let encrypted = false; |
| 105 | |
| 106 | // Retention: take the requested minutes (if any) and clamp it to the |
| 107 | // configured maximum age, so storage is time-bounded when MAX_AGE_MINUTES is set. |
| 108 | let minutes: number | null = |
| 109 | body.delete_in_minutes && Number(body.delete_in_minutes) > 0 |
| 110 | ? Number(body.delete_in_minutes) |
| 111 | : null; |
| 112 | if (config.maxAgeMinutes !== null) { |
| 113 | minutes = Math.min(minutes ?? config.maxAgeMinutes, config.maxAgeMinutes); |
| 114 | } |
| 115 | const delete_at = |
| 116 | minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null; |
| 117 | |
| 118 | if (body.encrypted === "on") { |
| 119 | encrypted = true; |
| 120 | } else if (body.password) { |
| 121 | content = await encrypt(content, body.password); |
| 122 | encrypted = true; |
| 123 | } |
| 124 | db.exec( |
| 125 | "INSERT INTO files (uuid, filename, content, filetype, encrypted, delete_at) VALUES (?, ?, ?, ?, ?, ?)", |
| 126 | [ |
| 127 | uuid, |
| 128 | body.filename || body.file.name, |
| 129 | content, |
| 130 | body.filetype, |
| 131 | encrypted, |
| 132 | delete_at, |
| 133 | ], |
| 134 | ); |
| 135 | if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now); |
| 136 | set.status = StatusMap["See Other"]; |
| 137 | set.headers.location = `/show/${uuid}`; |
| 138 | return `Created with id: ${uuid}`; |
| 139 | }, |
| 140 | { |
| 141 | body: t.Object({ |
| 142 | file: t.File(), |
| 143 | filename: t.Optional(t.String()), |
| 144 | filetype: t.Enum(stringArrayToEnum(filetypes)), |
| 145 | password: t.Optional(t.String()), |
| 146 | encrypted: t.Optional(t.String()), |
| 147 | delete_in_minutes: t.Optional( |
| 148 | t.String({ |
| 149 | format: "regex", |
| 150 | pattern: "(^$|^[0-9]+$)", |
| 151 | }), |
| 152 | ), |
| 153 | }), |
| 154 | }, |
| 155 | ) |
| 156 | .get( |
| 157 | "/show/:uuid", |
| 158 | async ({ set, params, cookie }) => { |
| 159 | const result = |
| 160 | (db |
| 161 | .prepare( |
| 162 | "SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?", |
| 163 | ) |
| 164 | .get(params.uuid) as { |
| 165 | filename: string; |
| 166 | content: Uint8Array; |
| 167 | filetype: string; |
| 168 | encrypted: number; |
| 169 | delete_at: number | null; |
| 170 | }) || null; |
| 171 | if (!result) { |
| 172 | set.status = StatusMap["Not Found"]; |
| 173 | return NotFound(); |
| 174 | } |
| 175 | if (result.encrypted) { |
| 176 | const password = cookie.password.value; |
| 177 | if (!password) { |
| 178 | return ShowFile( |
| 179 | result.filename, |
| 180 | params.uuid, |
| 181 | null, |
| 182 | result.filetype, |
| 183 | result.delete_at, |
| 184 | ); |
| 185 | } else { |
| 186 | try { |
| 187 | result.content = await decrypt(result.content, password); |
| 188 | } catch (_e) { |
| 189 | const secure = config.behindProxy ? "; Secure" : ""; |
| 190 | set.status = StatusMap.Forbidden; |
| 191 | set.headers["set-cookie"] = [ |
| 192 | `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, |
| 193 | `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, |
| 194 | ]; |
| 195 | return WrongPassword(); |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | return ShowFile( |
| 200 | result.filename, |
| 201 | params.uuid, |
| 202 | result.content, |
| 203 | result.filetype, |
| 204 | result.delete_at, |
| 205 | ); |
| 206 | }, |
| 207 | { |
| 208 | params: t.Object({ |
| 209 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 210 | }), |
| 211 | cookie: t.Object({ password: t.Optional(t.String()) }), |
| 212 | }, |
| 213 | ) |
| 214 | .post( |
| 215 | "/set-cookie/:uuid", |
| 216 | ({ set, body, params }) => { |
| 217 | // encodeURIComponent keeps ';', CR/LF and other separators out of the |
| 218 | // cookie value; Elysia URL-decodes the value again when it reads it back. |
| 219 | const value = encodeURIComponent(body.password); |
| 220 | const secure = config.behindProxy ? "; Secure" : ""; |
| 221 | set.headers["set-cookie"] = [ |
| 222 | `password=${value}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}`, |
| 223 | `password=${value}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}`, |
| 224 | ]; |
| 225 | set.headers.location = `/show/${params.uuid}`; |
| 226 | set.status = StatusMap["See Other"]; |
| 227 | return SetCookie(params.uuid); |
| 228 | }, |
| 229 | { |
| 230 | body: t.Object({ |
| 231 | password: t.String(), |
| 232 | }), |
| 233 | params: t.Object({ |
| 234 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 235 | }), |
| 236 | }, |
| 237 | ) |
| 238 | .get( |
| 239 | "/raw/:uuid", |
| 240 | async ({ set, params, cookie, query }) => { |
| 241 | const result = |
| 242 | (db |
| 243 | .prepare( |
| 244 | "SELECT content, filename, encrypted, filetype FROM files WHERE uuid = ?", |
| 245 | ) |
| 246 | .get(params.uuid) as { |
| 247 | content: Uint8Array; |
| 248 | filename: string; |
| 249 | encrypted: number; |
| 250 | filetype: string; |
| 251 | }) || null; |
| 252 | if (!result) { |
| 253 | set.status = StatusMap["Not Found"]; |
| 254 | return "File not found"; |
| 255 | } |
| 256 | const servingEncrypted = |
| 257 | result.encrypted && query.ignore_password === "true"; |
| 258 | if (result.encrypted && !servingEncrypted) { |
| 259 | if (!cookie.password.value) { |
| 260 | set.status = StatusMap.Unauthorized; |
| 261 | return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it'; |
| 262 | } |
| 263 | try { |
| 264 | result.content = await decrypt(result.content, cookie.password.value); |
| 265 | } catch (_e) { |
| 266 | set.status = StatusMap.Forbidden; |
| 267 | return "Incorrect password"; |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // Never let the browser sniff stored content into an executable type |
| 272 | // (e.g. HTML/SVG running as same-origin script). Only whitelisted, |
| 273 | // non-scriptable media is served inline with its real type; everything |
| 274 | // else (incl. still-encrypted bytes) is an octet-stream attachment. |
| 275 | let mime = "application/octet-stream"; |
| 276 | let disposition = "attachment"; |
| 277 | if (!servingEncrypted) { |
| 278 | const detected = await fileTypeFromBuffer(result.content); |
| 279 | if ( |
| 280 | detected && |
| 281 | detected.mime !== "image/svg+xml" && |
| 282 | (detected.mime.startsWith("image/") || |
| 283 | detected.mime.startsWith("audio/") || |
| 284 | detected.mime.startsWith("video/")) |
| 285 | ) { |
| 286 | mime = detected.mime; |
| 287 | disposition = "inline"; |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | const safeName = encodeURIComponent(result.filename); |
| 292 | set.headers["x-content-type-options"] = "nosniff"; |
| 293 | set.headers["content-type"] = mime; |
| 294 | set.headers.encrypted = result.encrypted ? "true" : "false"; |
| 295 | set.headers.filetype = result.filetype; |
| 296 | set.headers.filename = safeName; |
| 297 | set.headers["content-disposition"] = |
| 298 | `${disposition}; filename*=UTF-8''${safeName}`; |
| 299 | return result.content; |
| 300 | }, |
| 301 | { |
| 302 | params: t.Object({ |
| 303 | uuid: t.String({ format: "regex", pattern: UUID_PATTERN }), |
| 304 | }), |
| 305 | cookie: t.Object({ password: t.Optional(t.String()) }), |
| 306 | query: t.Object({ ignore_password: t.Optional(t.String()) }), |
| 307 | }, |
| 308 | ) |
| 309 | .listen(3000); |
| 310 | |
| 311 | console.log( |
| 312 | `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`, |
| 313 | ); |
| 314 |