js/nojs merge

AuthorKonata <konata@posteo.jp>
Date
Commitf55e9643e41d4eb709ee97af0839fa7b7b0adb40
Parent82a92c3
10 files changed, 278 insertions(+), 267 deletions(-)
Massets/default.css
@@ -1,10 +1,6 @@
11 @import "normalize.css";
22 @import "sakura-vader.css";
33
4-#jsguard {
5- display: none;
6-}
7-
84 body {
95 display: flex;
106 align-items: center;
@@ -45,6 +41,10 @@ form {
4541 margin-bottom: 0.5rem;
4642 }
4743
44+ input[type="radio"] {
45+ flex-grow: 0;
46+ }
47+
4848 div {
4949 display: flex;
5050 flex-direction: row;
Massets/show.css
@@ -79,12 +79,18 @@ video {
7979 object-fit: scale-down;
8080 }
8181
82-dialog {
82+#decrypt-overlay {
83+ position: fixed;
84+ inset: 0;
85+ z-index: 10;
86+ display: flex;
87+ flex-direction: column;
88+ align-items: center;
89+ justify-content: center;
8390 background-color: #120c0e;
8491 color: #d9d8dc;
85- border-color: #eb99a1;
8692
87- /* biome-ignore lint/style/noDescendingSpecificity: targets dialog forms only, disjoint from the #sidebar form rule */
93+ /* biome-ignore lint/style/noDescendingSpecificity: targets the overlay form only, disjoint from the #sidebar form rule */
8894 form {
8995 display: flex;
9096 flex-direction: column;
Mbun.lock
@@ -12,13 +12,9 @@
1212 "elysia": "^1.2.0",
1313 "file-type": "^20.0.1",
1414 "highlight.js": "^11.11.1",
15- "pkcs7-padding": "^0.1.1",
16- "utf-8-validate": "^6.0.5",
1715 },
1816 "devDependencies": {
1917 "@biomejs/biome": "2.4.16",
20- "@types/pkcs7-padding": "^0.1.3",
21- "@types/utf-8-validate": "^5.0.2",
2218 "bun-types": "latest",
2319 },
2420 },
@@ -64,10 +60,6 @@
6460
6561 "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
6662
67- "@types/pkcs7-padding": ["@types/pkcs7-padding@0.1.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-rh4Tsp96Rj4KRBkRMHTZNsyjMBYT7lmHPr9Q6iZiRP1rrXxFzZKj3CpWP15a9ZE5O6BO3KqL0WsHzc+FGYYF1g=="],
68-
69- "@types/utf-8-validate": ["@types/utf-8-validate@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-ta7cOkEiNr0RGKARljNBaI7E1GBIr3VwS9RrSoQRmbdv1RVq7Q6VhjSGmQHYNt3nHn051qZBKKrpnw7cnEMDuQ=="],
70-
7163 "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
7264
7365 "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -112,12 +104,8 @@
112104
113105 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
114106
115- "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
116-
117107 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
118108
119- "pkcs7-padding": ["pkcs7-padding@0.1.1", "", {}, "sha512-tM/sUOL5FdH7x6gSLHTNDFi1bAut/EXGFp/Ih8uRkVd2IdXwb5QWnxBgQXn6buadj1pLi3CYKCnrxb+XoTz+Ww=="],
120-
121109 "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
122110
123111 "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -134,8 +122,6 @@
134122
135123 "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
136124
137- "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="],
138-
139125 "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
140126
141127 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
Mpackage.json
@@ -15,14 +15,10 @@
1515 "@elysiajs/static": "^1.2.0",
1616 "elysia": "^1.2.0",
1717 "file-type": "^20.0.1",
18- "highlight.js": "^11.11.1",
19- "pkcs7-padding": "^0.1.1",
20- "utf-8-validate": "^6.0.5"
18+ "highlight.js": "^11.11.1"
2119 },
2220 "devDependencies": {
2321 "@biomejs/biome": "2.4.16",
24- "@types/pkcs7-padding": "^0.1.3",
25- "@types/utf-8-validate": "^5.0.2",
2622 "bun-types": "latest"
2723 },
2824 "module": "src/index.js"
Msrc/client-index.ts
@@ -1,36 +1,5 @@
1-async function encrypt(
2- content: Uint8Array<ArrayBuffer>,
3- password: string,
4-): Promise<ArrayBuffer> {
5- const iv = crypto.getRandomValues(new Uint8Array(16));
6- const salt = crypto.getRandomValues(new Uint8Array(16));
7- const keyMaterial = await crypto.subtle.importKey(
8- "raw",
9- new TextEncoder().encode(password),
10- { name: "PBKDF2" },
11- false,
12- ["deriveBits", "deriveKey"],
13- );
14- const key = await crypto.subtle.deriveKey(
15- {
16- name: "PBKDF2",
17- salt: salt,
18- iterations: 100000,
19- hash: "SHA-512",
20- },
21- keyMaterial,
22- { name: "AES-CBC", length: 256 },
23- false,
24- ["encrypt"],
25- );
26- const encryptedContent = await crypto.subtle.encrypt(
27- { name: "AES-CBC", iv: iv },
28- key,
29- content,
30- );
31- return new Uint8Array([...salt, ...iv, ...new Uint8Array(encryptedContent)])
32- .buffer;
33-}
1+import { encrypt } from "./crypto";
2+import { getMode, setupModeRadios } from "./shared";
343
354 async function uploadFile() {
365 (
@@ -90,13 +59,25 @@ async function uploadFile() {
9059 form.submit();
9160 }
9261
93-const jsguards = document.getElementsByClassName("jsguard");
94-for (let i = 0; i < jsguards.length; i++) {
95- jsguards[i].remove();
62+// Decides per submit whether to encrypt in the browser (client mode) or let the
63+// form POST normally so the server encrypts (server mode / no password).
64+function onUploadSubmit(): boolean {
65+ const passwordInput = document.getElementById(
66+ "password",
67+ ) as HTMLInputElement | null;
68+ if (getMode("encrypt_mode") === "client" && passwordInput?.value) {
69+ uploadFile();
70+ return false;
71+ }
72+ return true;
9673 }
9774
75+// reset() first: it reverts controls to their HTML defaults (incl. the radios),
76+// so apply the saved mode preference afterwards.
9877 (document.getElementById("uploadForm") as HTMLFormElement | null)?.reset();
99-window.uploadFile = uploadFile;
78+setupModeRadios("encrypt_mode");
79+Object.assign(window, { uploadFile });
80+Object.assign(window, { onUploadSubmit });
10081 //disable bfcache, otherwise dialog will stay open when navigating back
10182 window.addEventListener("unload", () => {});
10283 window.addEventListener("beforeunload", () => {});
Msrc/client-show.ts
@@ -1,51 +1,9 @@
11 import { fileTypeFromBuffer } from "file-type";
22 import hljs from "highlight.js";
3-import { humanFileSize } from "./shared";
3+import { decrypt } from "./crypto";
4+import { getMode, humanFileSize, isValidUTF8, setupModeRadios } from "./shared";
45
5-async function decrypt(
6- encryptedContent: ArrayBuffer,
7- password: string,
8-): Promise<ArrayBuffer> {
9- const encryptedArray = new Uint8Array(encryptedContent);
10- const salt = encryptedArray.slice(0, 16);
11- const iv = encryptedArray.slice(16, 32);
12- const keyMaterial = await crypto.subtle.importKey(
13- "raw",
14- new TextEncoder().encode(password),
15- { name: "PBKDF2" },
16- false,
17- ["deriveBits", "deriveKey"],
18- );
19- const key = await crypto.subtle.deriveKey(
20- {
21- name: "PBKDF2",
22- salt: salt,
23- iterations: 100000,
24- hash: "SHA-512",
25- },
26- keyMaterial,
27- { name: "AES-CBC", length: 256 },
28- false,
29- ["decrypt"],
30- );
31- const decryptedContent = await crypto.subtle.decrypt(
32- { name: "AES-CBC", iv: iv },
33- key,
34- encryptedArray.slice(32),
35- );
36- return decryptedContent;
37-}
38-
39-function isValidUTF8(buf: ArrayBuffer) {
40- try {
41- new TextDecoder("utf-8", { fatal: true }).decode(buf);
42- return true;
43- } catch (_e) {
44- return false;
45- }
46-}
47-
48-async function showContent(content: ArrayBuffer, filetype: string) {
6+async function showContent(content: Uint8Array, filetype: string) {
497 const filesize = document.getElementById("filesize") as HTMLDivElement | null;
508 if (filesize) {
519 filesize.textContent = `size: ${humanFileSize(content.byteLength)}`;
@@ -91,31 +49,59 @@ async function showContent(content: ArrayBuffer, filetype: string) {
9149 mediaBox.appendChild(preview);
9250 }
9351
94-let content: ArrayBuffer;
52+let encrypted: Uint8Array | undefined;
53+let content: Uint8Array;
9554 let filetype: string;
9655 let filename: string;
9756
98-async function onPasswordSubmit() {
57+const uuid = window.location.pathname
58+ .split("/")
59+ .filter((x) => x !== "")
60+ .reverse()[0];
61+
62+// Remembers the client-side password for this file for the rest of the browser
63+// session, mirroring the server-side password cookie so the prompt only appears
64+// once per session.
65+const PASSWORD_KEY = `zbin-pw-${uuid}`;
66+
67+// Called from the overlay form's onsubmit. In server mode we let the form POST
68+// the password to /set-cookie (native submit). In client mode we intercept,
69+// fetch the still-encrypted bytes, and decrypt locally so the password never
70+// leaves the browser.
71+function onPasswordSubmit(): boolean {
72+ if (getMode("decrypt_mode") === "server") {
73+ return true;
74+ }
9975 const passwordInput = document.getElementById(
10076 "password",
10177 ) as HTMLInputElement | null;
10278 const passwordLabel = document.getElementById(
10379 "password-label",
10480 ) as HTMLLabelElement | null;
105- if (!passwordInput || !passwordLabel) {
106- console.error("Missing passwordInput or passwordLabel element.");
107- return;
81+ if (passwordInput) {
82+ void decryptClientSide(passwordInput.value, passwordLabel);
83+ }
84+ return false;
85+}
86+
87+async function decryptClientSide(
88+ password: string,
89+ passwordLabel: HTMLLabelElement | null,
90+): Promise<boolean> {
91+ if (!encrypted) {
92+ const response = await fetch(`/raw/${uuid}?ignore_password=true`);
93+ encrypted = new Uint8Array(await response.arrayBuffer());
94+ filetype = response.headers.get("filetype") || "none";
95+ filename = response.headers.get("filename") || "";
10896 }
10997 try {
110- content = await decrypt(content, passwordInput.value);
98+ content = await decrypt(encrypted, password);
11199 } catch (_e) {
112- passwordLabel.textContent = "Incorrect password";
113- return;
100+ if (passwordLabel) passwordLabel.textContent = "Incorrect password";
101+ return false;
114102 }
115- const dialog = document.getElementById(
116- "password-dialog",
117- ) as HTMLDialogElement | null;
118- dialog?.close();
103+ sessionStorage.setItem(PASSWORD_KEY, password);
104+ document.getElementById("decrypt-overlay")?.remove();
119105 const downloadForm = document.getElementById(
120106 "download-form",
121107 ) as HTMLFormElement | null;
@@ -130,29 +116,18 @@ async function onPasswordSubmit() {
130116 link.click();
131117 });
132118 showContent(content, filetype);
119+ return true;
133120 }
134121
135-const uuid = window.location.pathname
136- .split("/")
137- .filter((x) => x !== "")
138- .reverse()[0];
122+setupModeRadios("decrypt_mode");
139123
140-fetch(`/raw/${uuid}?ignore_password=true`).then(async (response) => {
141- content = await response.arrayBuffer();
142- const mediaBox = document.getElementById("mediabox");
143- if (mediaBox) {
144- mediaBox.innerHTML = "";
145- }
146- filetype = response.headers.get("filetype") || "none";
147- filename = response.headers.get("filename") || "";
148- if (response.headers.get("encrypted") === "true") {
149- const dialog = document.getElementById(
150- "password-dialog",
151- ) as HTMLDialogElement | null;
152- dialog?.showModal();
153- } else {
154- showContent(content, filetype);
155- }
156-});
124+// If we already decrypted this file this session, auto-decrypt with the stored
125+// password instead of prompting again. Drop a stale password if it no longer works.
126+const savedPassword = sessionStorage.getItem(PASSWORD_KEY);
127+if (savedPassword) {
128+ decryptClientSide(savedPassword, null).then((ok) => {
129+ if (!ok) sessionStorage.removeItem(PASSWORD_KEY);
130+ });
131+}
157132
158133 Object.assign(window, { onPasswordSubmit });
Msrc/components.tsx
@@ -4,8 +4,7 @@ import type { PropsWithChildren } from "@kitajs/html";
44 import { escapeHTML } from "bun";
55 import { fileTypeFromBuffer } from "file-type";
66 import hljs from "highlight.js";
7-import isValidUTF8 from "utf-8-validate";
8-import { humanFileSize } from "./shared";
7+import { humanFileSize, isValidUTF8 } from "./shared";
98
109 export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
1110
@@ -39,28 +38,17 @@ export function NotFound() {
3938 );
4039 }
4140
42-export function Index(hostname: string, withJs: boolean) {
43- let body = (
44- <>
41+export function Index(hostname: string) {
42+ return (
43+ <Template css="/default.css">
44+ <h1>⚡ZBin⚡</h1>
4545 <dialog id="upload-dialog">File is being uploaded, please wait</dialog>
46- {withJs ? (
47- <div style="text-align: center">
48- JS version with client-side encryption, for the version without js an
49- with server-side encryption, go <a href="/">here</a>.
50- </div>
51- ) : (
52- <div style="text-align: center">
53- JS-less version with server-side encryption, for the version with
54- client-side encryption, go to <a href="/js">/js</a>.
55- </div>
56- )}
57- <hr />
5846 <form
59- action={withJs ? "/upload?withJs=true" : "/upload"}
47+ action="/upload"
6048 id="uploadForm"
6149 method="post"
6250 enctype="multipart/form-data"
63- onsubmit={withJs ? "uploadFile(); return false" : undefined}
51+ onsubmit="return onUploadSubmit()"
6452 >
6553 <input type="reset" value="Reset form" />
6654 <input required={true} type="file" id="file" name="file" />
@@ -108,6 +96,33 @@ export function Index(hostname: string, withJs: boolean) {
10896 name="password"
10997 placeholder="Password"
11098 />
99+ <label for="encrypt-mode-server">Encryption mode:</label>
100+ <small>
101+ Only relevant when a password is set. Client-side encrypts in your
102+ browser so the password never reaches the server (requires JavaScript).
103+ Server-side encrypts on upload.
104+ </small>
105+ <div>
106+ <label>
107+ <input
108+ type="radio"
109+ id="encrypt-mode-server"
110+ name="encrypt_mode"
111+ value="server"
112+ checked={true}
113+ />{" "}
114+ Server-side
115+ </label>
116+ <label>
117+ <input
118+ type="radio"
119+ name="encrypt_mode"
120+ value="client"
121+ disabled={true}
122+ />{" "}
123+ Client-side (in your browser)
124+ </label>
125+ </div>
111126 <div>
112127 <label for="encrypted">Already encrypted:</label>
113128 <input type="checkbox" id="encrypted" name="encrypted" />
@@ -151,37 +166,7 @@ export function Index(hostname: string, withJs: boolean) {
151166 password.
152167 </p>
153168 </form>
154- </>
155- );
156- if (withJs) {
157- body = (
158- <>
159- <div class="jsguard">
160- JS seems to be disabled, enable it and refesh the page, or go back to
161- <a href="/">here</a> for the JS-less version.
162- </div>
163- <div class="jsguard">{body}</div>
164- <script src="/dist/client-index.js" />
165- </>
166- );
167- }
168- return (
169- <Template css="/default.css">
170- <h1>⚡ZBin⚡</h1>
171- {body}
172- </Template>
173- );
174-}
175-
176-export function DecryptFile(filename: string, uuid: string) {
177- return (
178- <Template css="/default.css">
179- <h1>Encrypted file: {filename}</h1>
180- <form action={`/set-cookie/${uuid}`} method="post">
181- <label for="password">Enter a Password to decrypt the file</label>
182- <input type="password" name="password" />
183- <input type="submit" value="Submit" />
184- </form>
169+ <script src="/dist/client-index.js" />
185170 </Template>
186171 );
187172 }
@@ -225,7 +210,7 @@ export async function ShowFile(
225210 preview = <>Please wait for the file to load</>;
226211 } else {
227212 preview = <>This file can't be previewed</>;
228- if (isValidUTF8(Buffer.from(content)) && filetype !== "blob") {
213+ if (isValidUTF8(content) && filetype !== "blob") {
229214 if (filetype === "none") {
230215 preview = (
231216 <pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre>
@@ -258,15 +243,50 @@ export async function ShowFile(
258243
259244 return (
260245 <Template css="/show.css">
261- <dialog id="password-dialog">
262- <form onsubmit="onPasswordSubmit(); return false">
263- <label id="password-label" for="password">
264- File is encrypted, enter password to decrypt:
265- </label>
266- <input required type="password" id="password" />
267- <input type="submit" value="Submit" />
268- </form>
269- </dialog>
246+ {content ? (
247+ ""
248+ ) : (
249+ <div id="decrypt-overlay">
250+ <h1>Encrypted file: {filename}</h1>
251+ <form
252+ id="decrypt-form"
253+ action={`/set-cookie/${uuid}`}
254+ method="post"
255+ onsubmit="return onPasswordSubmit()"
256+ >
257+ <label id="password-label" for="password">
258+ Enter a password to decrypt the file:
259+ </label>
260+ <input
261+ required
262+ type="password"
263+ id="password"
264+ name="password"
265+ />
266+ <div>
267+ <label>
268+ <input
269+ type="radio"
270+ name="decrypt_mode"
271+ value="server"
272+ checked={true}
273+ />{" "}
274+ Server-side
275+ </label>
276+ <label>
277+ <input
278+ type="radio"
279+ name="decrypt_mode"
280+ value="client"
281+ disabled={true}
282+ />{" "}
283+ Client-side (in your browser)
284+ </label>
285+ </div>
286+ <input type="submit" value="Submit" />
287+ </form>
288+ </div>
289+ )}
270290 <div id="content">
271291 <div id="filename">{filename}</div>
272292 <div id="mediabox">{preview}</div>
Asrc/crypto.ts
@@ -0,0 +1,55 @@
1+// AES-256-CBC encryption with a PBKDF2-derived key, using the WebCrypto API
2+// (crypto.subtle) which is available both in Bun (server) and the browser
3+// (client), so encryption/decryption is defined once for both sides.
4+// Wire format: salt[16] | iv[16] | ciphertext.
5+
6+async function deriveKey(
7+ password: string,
8+ salt: Uint8Array,
9+ usage: KeyUsage[],
10+): Promise<CryptoKey> {
11+ const material = await crypto.subtle.importKey(
12+ "raw",
13+ new TextEncoder().encode(password),
14+ { name: "PBKDF2" },
15+ false,
16+ ["deriveKey"],
17+ );
18+ return crypto.subtle.deriveKey(
19+ { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-512" },
20+ material,
21+ { name: "AES-CBC", length: 256 },
22+ false,
23+ usage,
24+ );
25+}
26+
27+export async function encrypt(
28+ content: Uint8Array,
29+ password: string,
30+): Promise<Uint8Array> {
31+ const iv = crypto.getRandomValues(new Uint8Array(16));
32+ const salt = crypto.getRandomValues(new Uint8Array(16));
33+ const key = await deriveKey(password, salt, ["encrypt"]);
34+ const ciphertext = await crypto.subtle.encrypt(
35+ { name: "AES-CBC", iv },
36+ key,
37+ content,
38+ );
39+ return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]);
40+}
41+
42+export async function decrypt(
43+ data: Uint8Array,
44+ password: string,
45+): Promise<Uint8Array> {
46+ const salt = data.slice(0, 16);
47+ const iv = data.slice(16, 32);
48+ const key = await deriveKey(password, salt, ["decrypt"]);
49+ const plaintext = await crypto.subtle.decrypt(
50+ { name: "AES-CBC", iv },
51+ key,
52+ data.slice(32),
53+ );
54+ return new Uint8Array(plaintext);
55+}
Msrc/index.ts
@@ -1,12 +1,10 @@
11 import { Database } from "bun:sqlite";
2-import crypto from "node:crypto";
32 import cron from "@elysiajs/cron";
43 import { html } from "@elysiajs/html";
54 import staticPlugin from "@elysiajs/static";
65 import { randomUUIDv7 } from "bun";
76 import { Elysia, StatusMap, t } from "elysia";
87 import {
9- DecryptFile,
108 filetypes,
119 Index,
1210 NotFound,
@@ -14,6 +12,7 @@ import {
1412 ShowFile,
1513 WrongPassword,
1614 } from "./components";
15+import { decrypt, encrypt } from "./crypto";
1716
1817 const db = new Database("./db/db.sqlite");
1918 db.run("PRAGMA foreign_keys = ON");
@@ -23,25 +22,6 @@ db.run(
2322 );
2423 db.run("PRAGMA optimize");
2524
26-function 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-
34-function 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-
4525 function stringArrayToEnum<T extends string>(
4626 arr: readonly T[],
4727 ): { [K in T]: K } {
@@ -67,13 +47,12 @@ const app = new Elysia({
6747 },
6848 }),
6949 )
70- .get("/", ({ server }) => Index(server?.url.toString() ?? "", false))
71- .get("/js", ({ server }) => Index(server?.url.toString() ?? "", true))
50+ .get("/", ({ server }) => Index(server?.url.toString() ?? ""))
7251 .post(
7352 "/upload",
74- async ({ set, body, query }) => {
53+ async ({ set, body }) => {
7554 const uuid = randomUUIDv7();
76- let content = Buffer.from(await body.file.bytes());
55+ let content: Uint8Array = Buffer.from(await body.file.bytes());
7756 let encrypted = false;
7857 let delete_at: number | null = null;
7958 if (body.delete_in_minutes) {
@@ -83,7 +62,7 @@ const app = new Elysia({
8362 if (body.encrypted === "on") {
8463 encrypted = true;
8564 } else if (body.password) {
86- content = encrypt(content, body.password);
65+ content = await encrypt(content, body.password);
8766 encrypted = true;
8867 }
8968 db.exec(
@@ -98,11 +77,7 @@ const app = new Elysia({
9877 ],
9978 );
10079 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- }
80+ set.headers.location = `/show/${uuid}`;
10681 return `Created with id: ${uuid}`;
10782 },
10883 {
@@ -113,12 +88,12 @@ const app = new Elysia({
11388 filetype: t.Enum(stringArrayToEnum(filetypes)),
11489 password: t.Optional(t.String()),
11590 encrypted: t.Optional(t.String()),
91+ encrypt_mode: t.Optional(t.String()),
11692 delete_in_minutes: t.String({
11793 format: "regex",
11894 pattern: "(^$|^[0-9]+$)",
11995 }),
12096 }),
121- query: t.Object({ withJs: t.Optional(t.Boolean()) }),
12297 },
12398 )
12499 .get(
@@ -143,10 +118,16 @@ const app = new Elysia({
143118 if (result.encrypted) {
144119 const password = cookie.password.value;
145120 if (!password) {
146- return DecryptFile(result.filename, params.uuid);
121+ return ShowFile(
122+ result.filename,
123+ params.uuid,
124+ null,
125+ result.filetype,
126+ result.delete_at,
127+ );
147128 } else {
148129 try {
149- result.content = decrypt(result.content, password);
130+ result.content = await decrypt(result.content, password);
150131 } catch (_e) {
151132 set.status = StatusMap.Forbidden;
152133 set.headers["set-cookie"] = [
@@ -170,35 +151,6 @@ const app = new Elysia({
170151 cookie: t.Object({ password: t.Optional(t.String()) }),
171152 },
172153 )
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- )
202154 .post(
203155 "/set-cookie/:uuid",
204156 ({ set, body, params }) => {
@@ -211,13 +163,16 @@ const app = new Elysia({
211163 return SetCookie(params.uuid);
212164 },
213165 {
214- body: t.Object({ password: t.String() }),
166+ body: t.Object({
167+ password: t.String(),
168+ decrypt_mode: t.Optional(t.String()),
169+ }),
215170 params: t.Object({ uuid: t.String() }),
216171 },
217172 )
218173 .get(
219174 "/raw/:uuid",
220- ({ set, params, cookie, query }) => {
175+ async ({ set, params, cookie, query }) => {
221176 const result =
222177 (db
223178 .prepare(
@@ -239,7 +194,7 @@ const app = new Elysia({
239194 return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
240195 }
241196 try {
242- result.content = decrypt(result.content, cookie.password.value);
197+ result.content = await decrypt(result.content, cookie.password.value);
243198 } catch (_e) {
244199 set.status = StatusMap.Forbidden;
245200 return "Incorrect password";
Msrc/shared.ts
@@ -3,3 +3,40 @@ export function humanFileSize(size: number) {
33 const i = Math.min(4, Math.floor(Math.log(size) / Math.log(1024)));
44 return `${(size / 1024 ** i).toFixed(2)} ${["B", "KiB", "MiB", "GiB", "TiB"][i]}`;
55 }
6+
7+// TextDecoder-based UTF-8 check that works in both Bun and the browser.
8+export function isValidUTF8(buf: ArrayBuffer | ArrayBufferView): boolean {
9+ try {
10+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
11+ return true;
12+ } catch (_e) {
13+ return false;
14+ }
15+}
16+
17+const MODE_STORAGE_KEY = "zbin-mode";
18+
19+// Enables the (HTML-disabled) client-side radio now that JS is running, applies
20+// the saved client/server preference (defaulting to client since JS is here),
21+// and persists changes. Shared by the index (encrypt_mode) and show (decrypt_mode) pages.
22+export function setupModeRadios(name: string) {
23+ const radios = document.querySelectorAll<HTMLInputElement>(
24+ `input[name="${name}"]`,
25+ );
26+ if (radios.length === 0) return;
27+ const saved = localStorage.getItem(MODE_STORAGE_KEY) ?? "client";
28+ for (const radio of radios) {
29+ radio.disabled = false;
30+ radio.checked = radio.value === saved;
31+ radio.addEventListener("change", () => {
32+ if (radio.checked) localStorage.setItem(MODE_STORAGE_KEY, radio.value);
33+ });
34+ }
35+}
36+
37+export function getMode(name: string): string {
38+ const checked = document.querySelector<HTMLInputElement>(
39+ `input[name="${name}"]:checked`,
40+ );
41+ return checked?.value ?? "server";
42+}