index.ts
Raw
1import { Database } from "bun:sqlite";
2import crypto from "node:crypto";
3import cron from "@elysiajs/cron";
4import { html } from "@elysiajs/html";
5import staticPlugin from "@elysiajs/static";
6import { randomUUIDv7 } from "bun";
7import { Elysia, StatusMap, t } from "elysia";
8import {
9 DecryptFile,
10 filetypes,
11 Index,
12 NotFound,
13 SetCookie,
14 ShowFile,
15 WrongPassword,
16} from "./components";
17
18const db = new Database("./db/db.sqlite");
19db.run("PRAGMA foreign_keys = ON");
20db.run("PRAGMA journal_mode = WAL2");
21db.run(
22 "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",
23);
24db.run("PRAGMA optimize");
25
26function encrypt(content: Uint8Array, password: string): Buffer<ArrayBuffer> {
27 const iv = crypto.randomBytes(16);
28 const salt = crypto.randomBytes(16);
29 const key = crypto.pbkdf2Sync(password, salt, 100000, 32, "sha512");
30 const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
31 return Buffer.concat([salt, iv, cipher.update(content), cipher.final()]);
32}
33
34function decrypt(encrypted_content: Uint8Array, password: string): Buffer {
35 const salt = encrypted_content.slice(0, 16);
36 const iv = encrypted_content.slice(16, 32);
37 const key = crypto.pbkdf2Sync(password, salt, 100000, 32, "sha512");
38 const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
39 return Buffer.concat([
40 decipher.update(encrypted_content.slice(32)),
41 decipher.final(),
42 ]);
43}
44
45function stringArrayToEnum<T extends string>(
46 arr: readonly T[],
47): { [K in T]: K } {
48 return arr.reduce((acc, key) => {
49 acc[key] = key;
50 return acc;
51 }, Object.create(null));
52}
53
54const app = new Elysia({
55 serve: {
56 maxRequestBodySize: 1024 * 1024 * 1024, // 1GB
57 },
58})
59 .use(staticPlugin({ assets: "./assets", prefix: "/" }))
60 .use(html())
61 .use(
62 cron({
63 name: "delete",
64 pattern: "*/5 * * * * *",
65 run() {
66 db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')");
67 },
68 }),
69 )
70 .get("/", ({ server }) => Index(server?.url.toString() ?? "", false))
71 .get("/js", ({ server }) => Index(server?.url.toString() ?? "", true))
72 .post(
73 "/upload",
74 async ({ set, body, query }) => {
75 const uuid = randomUUIDv7();
76 let content = Buffer.from(await body.file.bytes());
77 let encrypted = false;
78 let delete_at: number | null = null;
79 if (body.delete_in_minutes) {
80 delete_at =
81 Math.floor(Date.now() / 1000) + Number(body.delete_in_minutes) * 60;
82 }
83 if (body.encrypted === "on") {
84 encrypted = true;
85 } else if (body.password) {
86 content = encrypt(content, body.password);
87 encrypted = true;
88 }
89 db.exec(
90 "INSERT INTO files (uuid, filename, content, filetype, encrypted, delete_at) VALUES (?, ?, ?, ?, ?, ?)",
91 [
92 uuid,
93 body.filename || body.file.name,
94 content,
95 body.filetype,
96 encrypted,
97 delete_at,
98 ],
99 );
100 set.status = StatusMap["See Other"];
101 if (query.withJs) {
102 set.headers.location = `/show-js/${uuid}`;
103 } else {
104 set.headers.location = `/show/${uuid}`;
105 }
106 return `Created with id: ${uuid}`;
107 },
108 {
109 body: t.Object({
110 file: t.File(),
111 filename: t.Optional(t.String()),
112 //filetype: t.String()/*todo:verifiy via t.Enum*/,
113 filetype: t.Enum(stringArrayToEnum(filetypes)),
114 password: t.Optional(t.String()),
115 encrypted: t.Optional(t.String()),
116 delete_in_minutes: t.String({
117 format: "regex",
118 pattern: "(^$|^[0-9]+$)",
119 }),
120 }),
121 query: t.Object({ withJs: t.Optional(t.Boolean()) }),
122 },
123 )
124 .get(
125 "/show/:uuid",
126 async ({ set, params, cookie }) => {
127 const result =
128 (db
129 .prepare(
130 "SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?",
131 )
132 .get(params.uuid) as {
133 filename: string;
134 content: Uint8Array;
135 filetype: string;
136 encrypted: boolean;
137 delete_at: number | null;
138 }) || null;
139 if (!result) {
140 set.status = StatusMap["Not Found"];
141 return NotFound();
142 }
143 if (result.encrypted) {
144 const password = cookie.password.value;
145 if (!password) {
146 return DecryptFile(result.filename, params.uuid);
147 } else {
148 try {
149 result.content = decrypt(result.content, password);
150 } catch (_e) {
151 set.status = StatusMap.Forbidden;
152 set.headers["set-cookie"] = [
153 `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
154 `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
155 ];
156 return WrongPassword();
157 }
158 }
159 }
160 return ShowFile(
161 result.filename,
162 params.uuid,
163 result.content,
164 result.filetype,
165 result.delete_at,
166 );
167 },
168 {
169 params: t.Object({ uuid: t.String() }),
170 cookie: t.Object({ password: t.Optional(t.String()) }),
171 },
172 )
173 .get(
174 "/show-js/:uuid",
175 async ({ set, params }) => {
176 const result =
177 (db
178 .prepare(
179 "SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?",
180 )
181 .get(params.uuid) as {
182 filename: string;
183 content: Uint8Array;
184 filetype: string;
185 encrypted: boolean;
186 delete_at: number | null;
187 }) || null;
188 if (!result) {
189 set.status = StatusMap["Not Found"];
190 return NotFound();
191 }
192 return ShowFile(
193 result.filename,
194 params.uuid,
195 null,
196 result.filetype,
197 result.delete_at,
198 );
199 },
200 { params: t.Object({ uuid: t.String() }) },
201 )
202 .post(
203 "/set-cookie/:uuid",
204 ({ set, body, params }) => {
205 set.headers["set-cookie"] = [
206 `password=${body.password}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly`,
207 `password=${body.password}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly`,
208 ];
209 set.headers.location = `/show/${params.uuid}`;
210 set.status = StatusMap["See Other"];
211 return SetCookie(params.uuid);
212 },
213 {
214 body: t.Object({ password: t.String() }),
215 params: t.Object({ uuid: t.String() }),
216 },
217 )
218 .get(
219 "/raw/:uuid",
220 ({ set, params, cookie, query }) => {
221 const result =
222 (db
223 .prepare(
224 "SELECT content, filename, encrypted, filetype FROM files WHERE uuid = ?",
225 )
226 .get(params.uuid) as {
227 content: Uint8Array;
228 filename: string;
229 encrypted: boolean;
230 filetype: string;
231 }) || null;
232 if (!result) {
233 set.status = StatusMap["Not Found"];
234 return "File not found";
235 }
236 if (result.encrypted && query.ignore_password !== "true") {
237 if (!cookie.password.value) {
238 set.status = StatusMap.Unauthorized;
239 return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
240 }
241 try {
242 result.content = decrypt(result.content, cookie.password.value);
243 } catch (_e) {
244 set.status = StatusMap.Forbidden;
245 return "Incorrect password";
246 }
247 }
248 set.headers.encrypted = result.encrypted ? "true" : "false";
249 set.headers.filetype = result.filetype;
250 set.headers.filename = result.filename;
251 set.headers["content-disposition"] =
252 `inline; filename=${result.filename}`;
253 return result.content;
254 },
255 {
256 params: t.Object({ uuid: t.String() }),
257 cookie: t.Object({ password: t.Optional(t.String()) }),
258 query: t.Object({ ignore_password: t.Optional(t.String()) }),
259 },
260 )
261 .listen(3000);
262
263console.log(
264 `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
265);
266