index.ts
Raw
1import { Database } from "bun:sqlite";
2import cron from "@elysiajs/cron";
3import { html } from "@elysiajs/html";
4import staticPlugin from "@elysiajs/static";
5import { randomUUIDv7 } from "bun";
6import { Elysia, StatusMap, t } from "elysia";
7import { fileTypeFromBuffer } from "file-type";
8import {
9 filetypes,
10 Index,
11 NotFound,
12 SetCookie,
13 ShowFile,
14 WrongPassword,
15} from "./components";
16import { config } from "./config";
17import { decrypt, encrypt } from "./crypto";
18
19const db = new Database("./db/db.sqlite");
20db.run("PRAGMA foreign_keys = ON");
21db.run("PRAGMA journal_mode = WAL");
22db.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);
25db.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.
29const 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.
33const lastUpload = new Map<string, number>();
34
35type MinimalServer = {
36 requestIP(req: Request): { address: string } | null;
37} | null;
38
39function 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
53function 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
62const 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
311console.log(
312 `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
313);
314