js/nojs merge
Massets/default.css
| @@ -1,10 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | @import "normalize.css"; | |
| 2 | 2 | @import "sakura-vader.css"; | |
| 3 | 3 | ||
| 4 | - | #jsguard { | |
| 5 | - | display: none; | |
| 6 | - | } | |
| 7 | - | ||
| 8 | 4 | body { | |
| 9 | 5 | display: flex; | |
| 10 | 6 | align-items: center; | |
| @@ -45,6 +41,10 @@ form { | |||
|---|---|---|---|
| 45 | 41 | margin-bottom: 0.5rem; | |
| 46 | 42 | } | |
| 47 | 43 | ||
| 44 | + | input[type="radio"] { | |
| 45 | + | flex-grow: 0; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | 48 | div { | |
| 49 | 49 | display: flex; | |
| 50 | 50 | flex-direction: row; | |
Massets/show.css
| @@ -79,12 +79,18 @@ video { | |||
|---|---|---|---|
| 79 | 79 | object-fit: scale-down; | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 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; | |
| 83 | 90 | background-color: #120c0e; | |
| 84 | 91 | color: #d9d8dc; | |
| 85 | - | border-color: #eb99a1; | |
| 86 | 92 | ||
| 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 */ | |
| 88 | 94 | form { | |
| 89 | 95 | display: flex; | |
| 90 | 96 | flex-direction: column; | |
Mbun.lock
| @@ -12,13 +12,9 @@ | |||
|---|---|---|---|
| 12 | 12 | "elysia": "^1.2.0", | |
| 13 | 13 | "file-type": "^20.0.1", | |
| 14 | 14 | "highlight.js": "^11.11.1", | |
| 15 | - | "pkcs7-padding": "^0.1.1", | |
| 16 | - | "utf-8-validate": "^6.0.5", | |
| 17 | 15 | }, | |
| 18 | 16 | "devDependencies": { | |
| 19 | 17 | "@biomejs/biome": "2.4.16", | |
| 20 | - | "@types/pkcs7-padding": "^0.1.3", | |
| 21 | - | "@types/utf-8-validate": "^5.0.2", | |
| 22 | 18 | "bun-types": "latest", | |
| 23 | 19 | }, | |
| 24 | 20 | }, | |
| @@ -64,10 +60,6 @@ | |||
|---|---|---|---|
| 64 | 60 | ||
| 65 | 61 | "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], | |
| 66 | 62 | ||
| 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 | - | ||
| 71 | 63 | "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], | |
| 72 | 64 | ||
| 73 | 65 | "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], | |
| @@ -112,12 +104,8 @@ | |||
|---|---|---|---|
| 112 | 104 | ||
| 113 | 105 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | |
| 114 | 106 | ||
| 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 | - | ||
| 117 | 107 | "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], | |
| 118 | 108 | ||
| 119 | - | "pkcs7-padding": ["pkcs7-padding@0.1.1", "", {}, "sha512-tM/sUOL5FdH7x6gSLHTNDFi1bAut/EXGFp/Ih8uRkVd2IdXwb5QWnxBgQXn6buadj1pLi3CYKCnrxb+XoTz+Ww=="], | |
| 120 | - | ||
| 121 | 109 | "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=="], | |
| 122 | 110 | ||
| 123 | 111 | "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], | |
| @@ -134,8 +122,6 @@ | |||
|---|---|---|---|
| 134 | 122 | ||
| 135 | 123 | "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], | |
| 136 | 124 | ||
| 137 | - | "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="], | |
| 138 | - | ||
| 139 | 125 | "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=="], | |
| 140 | 126 | ||
| 141 | 127 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], | |
Mpackage.json
| @@ -15,14 +15,10 @@ | |||
|---|---|---|---|
| 15 | 15 | "@elysiajs/static": "^1.2.0", | |
| 16 | 16 | "elysia": "^1.2.0", | |
| 17 | 17 | "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" | |
| 21 | 19 | }, | |
| 22 | 20 | "devDependencies": { | |
| 23 | 21 | "@biomejs/biome": "2.4.16", | |
| 24 | - | "@types/pkcs7-padding": "^0.1.3", | |
| 25 | - | "@types/utf-8-validate": "^5.0.2", | |
| 26 | 22 | "bun-types": "latest" | |
| 27 | 23 | }, | |
| 28 | 24 | "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"; | |
| 34 | 3 | ||
| 35 | 4 | async function uploadFile() { | |
| 36 | 5 | ( | |
| @@ -90,13 +59,25 @@ async function uploadFile() { | |||
|---|---|---|---|
| 90 | 59 | form.submit(); | |
| 91 | 60 | } | |
| 92 | 61 | ||
| 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; | |
| 96 | 73 | } | |
| 97 | 74 | ||
| 75 | + | // reset() first: it reverts controls to their HTML defaults (incl. the radios), | |
| 76 | + | // so apply the saved mode preference afterwards. | |
| 98 | 77 | (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 }); | |
| 100 | 81 | //disable bfcache, otherwise dialog will stay open when navigating back | |
| 101 | 82 | window.addEventListener("unload", () => {}); | |
| 102 | 83 | window.addEventListener("beforeunload", () => {}); | |
Msrc/client-show.ts
| @@ -1,51 +1,9 @@ | |||
|---|---|---|---|
| 1 | 1 | import { fileTypeFromBuffer } from "file-type"; | |
| 2 | 2 | import hljs from "highlight.js"; | |
| 3 | - | import { humanFileSize } from "./shared"; | |
| 3 | + | import { decrypt } from "./crypto"; | |
| 4 | + | import { getMode, humanFileSize, isValidUTF8, setupModeRadios } from "./shared"; | |
| 4 | 5 | ||
| 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) { | |
| 49 | 7 | const filesize = document.getElementById("filesize") as HTMLDivElement | null; | |
| 50 | 8 | if (filesize) { | |
| 51 | 9 | filesize.textContent = `size: ${humanFileSize(content.byteLength)}`; | |
| @@ -91,31 +49,59 @@ async function showContent(content: ArrayBuffer, filetype: string) { | |||
|---|---|---|---|
| 91 | 49 | mediaBox.appendChild(preview); | |
| 92 | 50 | } | |
| 93 | 51 | ||
| 94 | - | let content: ArrayBuffer; | |
| 52 | + | let encrypted: Uint8Array | undefined; | |
| 53 | + | let content: Uint8Array; | |
| 95 | 54 | let filetype: string; | |
| 96 | 55 | let filename: string; | |
| 97 | 56 | ||
| 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 | + | } | |
| 99 | 75 | const passwordInput = document.getElementById( | |
| 100 | 76 | "password", | |
| 101 | 77 | ) as HTMLInputElement | null; | |
| 102 | 78 | const passwordLabel = document.getElementById( | |
| 103 | 79 | "password-label", | |
| 104 | 80 | ) 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") || ""; | |
| 108 | 96 | } | |
| 109 | 97 | try { | |
| 110 | - | content = await decrypt(content, passwordInput.value); | |
| 98 | + | content = await decrypt(encrypted, password); | |
| 111 | 99 | } catch (_e) { | |
| 112 | - | passwordLabel.textContent = "Incorrect password"; | |
| 113 | - | return; | |
| 100 | + | if (passwordLabel) passwordLabel.textContent = "Incorrect password"; | |
| 101 | + | return false; | |
| 114 | 102 | } | |
| 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(); | |
| 119 | 105 | const downloadForm = document.getElementById( | |
| 120 | 106 | "download-form", | |
| 121 | 107 | ) as HTMLFormElement | null; | |
| @@ -130,29 +116,18 @@ async function onPasswordSubmit() { | |||
|---|---|---|---|
| 130 | 116 | link.click(); | |
| 131 | 117 | }); | |
| 132 | 118 | showContent(content, filetype); | |
| 119 | + | return true; | |
| 133 | 120 | } | |
| 134 | 121 | ||
| 135 | - | const uuid = window.location.pathname | |
| 136 | - | .split("/") | |
| 137 | - | .filter((x) => x !== "") | |
| 138 | - | .reverse()[0]; | |
| 122 | + | setupModeRadios("decrypt_mode"); | |
| 139 | 123 | ||
| 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 | + | } | |
| 157 | 132 | ||
| 158 | 133 | Object.assign(window, { onPasswordSubmit }); | |
Msrc/components.tsx
| @@ -4,8 +4,7 @@ import type { PropsWithChildren } from "@kitajs/html"; | |||
|---|---|---|---|
| 4 | 4 | import { escapeHTML } from "bun"; | |
| 5 | 5 | import { fileTypeFromBuffer } from "file-type"; | |
| 6 | 6 | import hljs from "highlight.js"; | |
| 7 | - | import isValidUTF8 from "utf-8-validate"; | |
| 8 | - | import { humanFileSize } from "./shared"; | |
| 7 | + | import { humanFileSize, isValidUTF8 } from "./shared"; | |
| 9 | 8 | ||
| 10 | 9 | export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort()); | |
| 11 | 10 | ||
| @@ -39,28 +38,17 @@ export function NotFound() { | |||
|---|---|---|---|
| 39 | 38 | ); | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 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> | |
| 45 | 45 | <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 /> | |
| 58 | 46 | <form | |
| 59 | - | action={withJs ? "/upload?withJs=true" : "/upload"} | |
| 47 | + | action="/upload" | |
| 60 | 48 | id="uploadForm" | |
| 61 | 49 | method="post" | |
| 62 | 50 | enctype="multipart/form-data" | |
| 63 | - | onsubmit={withJs ? "uploadFile(); return false" : undefined} | |
| 51 | + | onsubmit="return onUploadSubmit()" | |
| 64 | 52 | > | |
| 65 | 53 | <input type="reset" value="Reset form" /> | |
| 66 | 54 | <input required={true} type="file" id="file" name="file" /> | |
| @@ -108,6 +96,33 @@ export function Index(hostname: string, withJs: boolean) { | |||
|---|---|---|---|
| 108 | 96 | name="password" | |
| 109 | 97 | placeholder="Password" | |
| 110 | 98 | /> | |
| 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> | |
| 111 | 126 | <div> | |
| 112 | 127 | <label for="encrypted">Already encrypted:</label> | |
| 113 | 128 | <input type="checkbox" id="encrypted" name="encrypted" /> | |
| @@ -151,37 +166,7 @@ export function Index(hostname: string, withJs: boolean) { | |||
|---|---|---|---|
| 151 | 166 | password. | |
| 152 | 167 | </p> | |
| 153 | 168 | </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" /> | |
| 185 | 170 | </Template> | |
| 186 | 171 | ); | |
| 187 | 172 | } | |
| @@ -225,7 +210,7 @@ export async function ShowFile( | |||
|---|---|---|---|
| 225 | 210 | preview = <>Please wait for the file to load</>; | |
| 226 | 211 | } else { | |
| 227 | 212 | preview = <>This file can't be previewed</>; | |
| 228 | - | if (isValidUTF8(Buffer.from(content)) && filetype !== "blob") { | |
| 213 | + | if (isValidUTF8(content) && filetype !== "blob") { | |
| 229 | 214 | if (filetype === "none") { | |
| 230 | 215 | preview = ( | |
| 231 | 216 | <pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre> | |
| @@ -258,15 +243,50 @@ export async function ShowFile( | |||
|---|---|---|---|
| 258 | 243 | ||
| 259 | 244 | return ( | |
| 260 | 245 | <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 | + | )} | |
| 270 | 290 | <div id="content"> | |
| 271 | 291 | <div id="filename">{filename}</div> | |
| 272 | 292 | <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 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Database } from "bun:sqlite"; | |
| 2 | - | import crypto from "node:crypto"; | |
| 3 | 2 | import cron from "@elysiajs/cron"; | |
| 4 | 3 | import { html } from "@elysiajs/html"; | |
| 5 | 4 | import staticPlugin from "@elysiajs/static"; | |
| 6 | 5 | import { randomUUIDv7 } from "bun"; | |
| 7 | 6 | import { Elysia, StatusMap, t } from "elysia"; | |
| 8 | 7 | import { | |
| 9 | - | DecryptFile, | |
| 10 | 8 | filetypes, | |
| 11 | 9 | Index, | |
| 12 | 10 | NotFound, | |
| @@ -14,6 +12,7 @@ import { | |||
|---|---|---|---|
| 14 | 12 | ShowFile, | |
| 15 | 13 | WrongPassword, | |
| 16 | 14 | } from "./components"; | |
| 15 | + | import { decrypt, encrypt } from "./crypto"; | |
| 17 | 16 | ||
| 18 | 17 | const db = new Database("./db/db.sqlite"); | |
| 19 | 18 | db.run("PRAGMA foreign_keys = ON"); | |
| @@ -23,25 +22,6 @@ db.run( | |||
|---|---|---|---|
| 23 | 22 | ); | |
| 24 | 23 | db.run("PRAGMA optimize"); | |
| 25 | 24 | ||
| 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 | - | ||
| 45 | 25 | function stringArrayToEnum<T extends string>( | |
| 46 | 26 | arr: readonly T[], | |
| 47 | 27 | ): { [K in T]: K } { | |
| @@ -67,13 +47,12 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 67 | 47 | }, | |
| 68 | 48 | }), | |
| 69 | 49 | ) | |
| 70 | - | .get("/", ({ server }) => Index(server?.url.toString() ?? "", false)) | |
| 71 | - | .get("/js", ({ server }) => Index(server?.url.toString() ?? "", true)) | |
| 50 | + | .get("/", ({ server }) => Index(server?.url.toString() ?? "")) | |
| 72 | 51 | .post( | |
| 73 | 52 | "/upload", | |
| 74 | - | async ({ set, body, query }) => { | |
| 53 | + | async ({ set, body }) => { | |
| 75 | 54 | const uuid = randomUUIDv7(); | |
| 76 | - | let content = Buffer.from(await body.file.bytes()); | |
| 55 | + | let content: Uint8Array = Buffer.from(await body.file.bytes()); | |
| 77 | 56 | let encrypted = false; | |
| 78 | 57 | let delete_at: number | null = null; | |
| 79 | 58 | if (body.delete_in_minutes) { | |
| @@ -83,7 +62,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 83 | 62 | if (body.encrypted === "on") { | |
| 84 | 63 | encrypted = true; | |
| 85 | 64 | } else if (body.password) { | |
| 86 | - | content = encrypt(content, body.password); | |
| 65 | + | content = await encrypt(content, body.password); | |
| 87 | 66 | encrypted = true; | |
| 88 | 67 | } | |
| 89 | 68 | db.exec( | |
| @@ -98,11 +77,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 98 | 77 | ], | |
| 99 | 78 | ); | |
| 100 | 79 | 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}`; | |
| 106 | 81 | return `Created with id: ${uuid}`; | |
| 107 | 82 | }, | |
| 108 | 83 | { | |
| @@ -113,12 +88,12 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 113 | 88 | filetype: t.Enum(stringArrayToEnum(filetypes)), | |
| 114 | 89 | password: t.Optional(t.String()), | |
| 115 | 90 | encrypted: t.Optional(t.String()), | |
| 91 | + | encrypt_mode: t.Optional(t.String()), | |
| 116 | 92 | delete_in_minutes: t.String({ | |
| 117 | 93 | format: "regex", | |
| 118 | 94 | pattern: "(^$|^[0-9]+$)", | |
| 119 | 95 | }), | |
| 120 | 96 | }), | |
| 121 | - | query: t.Object({ withJs: t.Optional(t.Boolean()) }), | |
| 122 | 97 | }, | |
| 123 | 98 | ) | |
| 124 | 99 | .get( | |
| @@ -143,10 +118,16 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 143 | 118 | if (result.encrypted) { | |
| 144 | 119 | const password = cookie.password.value; | |
| 145 | 120 | 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 | + | ); | |
| 147 | 128 | } else { | |
| 148 | 129 | try { | |
| 149 | - | result.content = decrypt(result.content, password); | |
| 130 | + | result.content = await decrypt(result.content, password); | |
| 150 | 131 | } catch (_e) { | |
| 151 | 132 | set.status = StatusMap.Forbidden; | |
| 152 | 133 | set.headers["set-cookie"] = [ | |
| @@ -170,35 +151,6 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 170 | 151 | cookie: t.Object({ password: t.Optional(t.String()) }), | |
| 171 | 152 | }, | |
| 172 | 153 | ) | |
| 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 | 154 | .post( | |
| 203 | 155 | "/set-cookie/:uuid", | |
| 204 | 156 | ({ set, body, params }) => { | |
| @@ -211,13 +163,16 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 211 | 163 | return SetCookie(params.uuid); | |
| 212 | 164 | }, | |
| 213 | 165 | { | |
| 214 | - | body: t.Object({ password: t.String() }), | |
| 166 | + | body: t.Object({ | |
| 167 | + | password: t.String(), | |
| 168 | + | decrypt_mode: t.Optional(t.String()), | |
| 169 | + | }), | |
| 215 | 170 | params: t.Object({ uuid: t.String() }), | |
| 216 | 171 | }, | |
| 217 | 172 | ) | |
| 218 | 173 | .get( | |
| 219 | 174 | "/raw/:uuid", | |
| 220 | - | ({ set, params, cookie, query }) => { | |
| 175 | + | async ({ set, params, cookie, query }) => { | |
| 221 | 176 | const result = | |
| 222 | 177 | (db | |
| 223 | 178 | .prepare( | |
| @@ -239,7 +194,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 239 | 194 | return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it'; | |
| 240 | 195 | } | |
| 241 | 196 | try { | |
| 242 | - | result.content = decrypt(result.content, cookie.password.value); | |
| 197 | + | result.content = await decrypt(result.content, cookie.password.value); | |
| 243 | 198 | } catch (_e) { | |
| 244 | 199 | set.status = StatusMap.Forbidden; | |
| 245 | 200 | return "Incorrect password"; | |