streaming + various fixes

AuthorKonata <konata@posteo.jp>
Date
Commit0d1b4e00ea6552b923144c2c2462d53e4b0310dc
Parent08f36f9
15 files changed, 1153 insertions(+), 251 deletions(-)
MREADME.md
@@ -8,8 +8,10 @@ ZBin is configured through environment variables (all optional):
88 | Variable | Default | Description |
99 | --- | --- | --- |
1010 | `MAX_UPLOAD_BYTES` | `104857600` (100 MiB) | Hard cap on a single upload. |
11+| `MAX_TOTAL_BYTES` | unlimited | Cap on total stored content across all files. When set, an upload that would push the total over the cap is rejected (`507`). |
1112 | `MAX_AGE_MINUTES` | unlimited | Maximum retention. When set, every upload is deleted after at most this many minutes (a longer requested `delete_in_minutes` is clamped down). |
1213 | `UPLOAD_COOLDOWN_SECONDS` | `0` (off) | Minimum seconds between uploads from the same client IP. |
14+| `DECRYPT_COOLDOWN_SECONDS` | `0` (off) | Minimum seconds between server-side decryption attempts from the same client IP. Bounds the PBKDF2 CPU cost an attacker who knows a file's URL can force by repeatedly requesting it with password cookies. |
1315 | `BEHIND_PROXY` | `false` | Set to `true` (or `1`) when running behind a trusted TLS-terminating reverse proxy (the usual production setup). Reads `X-Forwarded-For` / `X-Real-IP` for the client IP **and** adds the `Secure` flag to the password cookie. Leave off for direct/local HTTP. |
1416
1517 > Passwords for server-side decryption travel in a cookie, so ZBin should always be
Aassets/decrypt.py
@@ -0,0 +1,72 @@
1+from cryptography.hazmat.primitives import hashes
2+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
3+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
4+from cryptography.hazmat.backends import default_backend
5+import sys
6+
7+CHUNK = 64 * 1024 # plaintext bytes per chunk (must match src/crypto.ts)
8+TAG = 16
9+ENC_CHUNK = CHUNK + TAG # ciphertext bytes for a full chunk
10+HEADER = 16 + 7 # salt[16] | noncePrefix[7]
11+
12+
13+def decrypt(data: bytes, password: str) -> bytes:
14+ """
15+ Decrypts content produced by encrypt.py / the server / browser.
16+
17+ Uses chunked AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
18+ implementation in src/crypto.ts.
19+
20+ Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
21+ where encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
22+ and nonce_i = noncePrefix[7] || uint32_be(i) || flag, flag=1 on the final
23+ chunk else 0. Each non-final plaintext chunk is exactly CHUNK bytes, so chunk
24+ boundaries (and which chunk is last) follow from the total length.
25+
26+ Args:
27+ data (bytes): The encrypted wire format.
28+ password (str): The password used for encryption.
29+
30+ Returns:
31+ bytes: the decrypted content
32+
33+ Raises:
34+ Exception: if the password is wrong or the data is corrupt/truncated (the
35+ AES-GCM tag, chunk counter, and final-chunk flag are all authenticated).
36+ """
37+
38+ if len(data) < HEADER + TAG:
39+ raise ValueError("ciphertext too short")
40+
41+ salt = data[:16]
42+ prefix = data[16:HEADER]
43+ body = data[HEADER:]
44+
45+ kdf = PBKDF2HMAC(
46+ algorithm=hashes.SHA512(),
47+ length=32,
48+ salt=salt,
49+ iterations=210000,
50+ backend=default_backend()
51+ )
52+ aes = AESGCM(kdf.derive(password.encode()))
53+
54+ out = bytearray()
55+ offset = 0
56+ i = 0
57+ while offset < len(body):
58+ enc_len = min(ENC_CHUNK, len(body) - offset)
59+ last = 1 if offset + enc_len >= len(body) else 0
60+ nonce = prefix + i.to_bytes(4, "big") + bytes([last])
61+ out += aes.decrypt(nonce, body[offset:offset + enc_len], None)
62+ offset += enc_len
63+ i += 1
64+ return bytes(out)
65+
66+
67+if __name__ == "__main__":
68+ if len(sys.argv) != 4:
69+ print("Usage: decrypt.py <file> <password> <outfile>")
70+ sys.exit(1)
71+ data = decrypt(open(sys.argv[1], "rb").read(), sys.argv[2])
72+ open(sys.argv[3], "wb").write(data)
Massets/default.css
@@ -84,6 +84,13 @@ img {
8484 input[type="reset"] {
8585 max-width: 25rem;
8686 align-self: center;
87+ order: -2;
88+}
89+
90+/* The file input lives last in the DOM (so the multipart upload sends the other
91+ fields first) but belongs right below the reset button visually. */
92+#file {
93+ order: -1;
8794 }
8895
8996 dialog {
Massets/encrypt.py
@@ -5,23 +5,31 @@ from cryptography.hazmat.backends import default_backend
55 import os
66 import sys
77
8+CHUNK = 64 * 1024 # plaintext bytes per chunk (must match src/crypto.ts)
9+
10+
811 def encrypt(content: bytes, password: str) -> bytes:
912 """
1013 Encrypts the given content using the provided password.
1114
12- Uses AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
15+ Uses chunked AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
1316 server/browser implementation in src/crypto.ts.
1417
18+ Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
19+ where encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
20+ and nonce_i = noncePrefix[7] || uint32_be(i) || flag, flag=1 on the final
21+ chunk else 0. Each non-final plaintext chunk is exactly CHUNK bytes.
22+
1523 Args:
1624 content (bytes): The content to be encrypted.
1725 password (str): The password used for encryption.
1826
1927 Returns:
20- bytes: salt[16] | iv[12] | ciphertext+tag
28+ bytes: the wire format described above
2129 """
2230
23- # Generate a random initialization vector (IV) and salt
24- iv = os.urandom(12)
31+ # Generate a random nonce prefix and salt
32+ prefix = os.urandom(7)
2533 salt = os.urandom(16)
2634
2735 # Derive a key from the password using PBKDF2
@@ -32,13 +40,20 @@ def encrypt(content: bytes, password: str) -> bytes:
3240 iterations=210000,
3341 backend=default_backend()
3442 )
35- key = kdf.derive(password.encode())
43+ aes = AESGCM(kdf.derive(password.encode()))
44+
45+ # Always emit at least one (possibly empty) final chunk so empty input still
46+ # round-trips and the final-chunk flag is always present.
47+ chunks = max(1, -(-len(content) // CHUNK)) # ceil division
48+ out = bytearray(salt + prefix)
49+ for i in range(chunks):
50+ piece = content[i * CHUNK:(i + 1) * CHUNK]
51+ last = 1 if i == chunks - 1 else 0
52+ nonce = prefix + i.to_bytes(4, "big") + bytes([last])
53+ out += aes.encrypt(nonce, piece, None)
54+ return bytes(out)
3655
37- # Encrypt with AES-256-GCM (the authentication tag is appended to the ciphertext)
38- encrypted_content = AESGCM(key).encrypt(iv, content, None)
3956
40- # Return the salt, IV, and encrypted content concatenated
41- return salt + iv + encrypted_content
4257 if __name__ == "__main__":
4358 if len(sys.argv) != 4:
4459 print("Usage: encrypt.py <file> <password> <outfile>")
Mbun.lock
@@ -5,10 +5,11 @@
55 "": {
66 "name": "zbin",
77 "dependencies": {
8- "@chneau/elysia-compression": "^1.0.11",
98 "@elysiajs/cron": "^1.2.0",
109 "@elysiajs/html": "^1.2.0",
1110 "@elysiajs/static": "^1.2.0",
11+ "@types/busboy": "^1.5.4",
12+ "busboy": "^1.6.0",
1213 "elysia": "^1.2.0",
1314 "file-type": "^20.0.1",
1415 "highlight.js": "^11.11.1",
@@ -40,8 +41,6 @@
4041
4142 "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
4243
43- "@chneau/elysia-compression": ["@chneau/elysia-compression@1.0.11", "", { "dependencies": { "elysia": "^1.1.9" } }, "sha512-J4wfz5Qs68p/DNvkg5DXxo8sxJKSibzaUBP4W56uvqgs9NtHG0LC6tnyv7sAPEjNKzh21l6kinMoXbtVDp/KSQ=="],
44-
4544 "@elysiajs/cron": ["@elysiajs/cron@1.4.2", "", { "dependencies": { "croner": "^6.0.3" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-osQrgInKlW0m0/NUuBUX7+O9CFnx5NnG3NszeCaokTChaCVYciv/S4mmXzZZ8+Zg9sI8BmEQbV+L19wKStXYZw=="],
4645
4746 "@elysiajs/html": ["@elysiajs/html@1.4.2", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Db7dmbkN7gptckMpU0/Fq9Qi3QuhQr/CH60A+8rs+RT+74NUC8sONs5nkfMm5oL+6kCUWCv19uUwOjBP7zsjYQ=="],
@@ -58,6 +57,8 @@
5857
5958 "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
6059
60+ "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="],
61+
6162 "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
6263
6364 "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -66,6 +67,8 @@
6667
6768 "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
6869
70+ "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
71+
6972 "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
7073
7174 "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@@ -106,6 +109,8 @@
106109
107110 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
108111
112+ "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
113+
109114 "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=="],
110115
111116 "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
Mcompose.yaml
@@ -8,7 +8,9 @@ services:
88 - 3000:3000
99 environment:
1010 # See README for details. All optional.
11- # MAX_UPLOAD_BYTES: "104857600" # 100 MiB
12- # MAX_AGE_MINUTES: "10080" # auto-delete after 7 days
13- # UPLOAD_COOLDOWN_SECONDS: "10" # per-IP upload cooldown
14- # BEHIND_PROXY: "true" # trust forwarded headers + Secure cookies (TLS reverse proxy)
11+ # MAX_UPLOAD_BYTES: "104857600" # 100 MiB
12+ # MAX_TOTAL_BYTES: "10737418240" # cap total stored content at 10 GiB
13+ # MAX_AGE_MINUTES: "10080" # auto-delete after 7 days
14+ # UPLOAD_COOLDOWN_SECONDS: "10" # per-IP upload cooldown
15+ # DECRYPT_COOLDOWN_SECONDS: "2" # per-IP server-side decryption cooldown
16+ # BEHIND_PROXY: "true" # trust forwarded headers + Secure cookies (TLS reverse proxy)
Mpackage.json
@@ -7,19 +7,21 @@
77 "prod": "bun run build && bun run src/index.ts",
88 "format": "biome format --write",
99 "lint": "biome lint",
10+ "typecheck": "tsc --noEmit",
1011 "test": "bun test"
1112 },
1213 "dependencies": {
13- "@chneau/elysia-compression": "^1.0.11",
1414 "@elysiajs/cron": "^1.2.0",
1515 "@elysiajs/html": "^1.2.0",
1616 "@elysiajs/static": "^1.2.0",
17+ "busboy": "^1.6.0",
1718 "elysia": "^1.2.0",
1819 "file-type": "^20.0.1",
1920 "highlight.js": "^11.11.1"
2021 },
2122 "devDependencies": {
2223 "@biomejs/biome": "2.4.16",
24+ "@types/busboy": "^1.5.4",
2325 "bun-types": "latest"
2426 },
2527 "module": "src/index.js"
Msrc/client-index.ts
@@ -49,7 +49,7 @@ async function uploadFile() {
4949 const content = await file.arrayBuffer();
5050 const encryptedContent = await encrypt(new Uint8Array(content), password);
5151
52- const myFile = new File([encryptedContent], file.name);
52+ const myFile = new File([encryptedContent as BlobPart], file.name);
5353 const dataTransfer = new DataTransfer();
5454 dataTransfer.items.add(myFile);
5555
Msrc/client-show.ts
@@ -29,7 +29,7 @@ async function showContent(content: Uint8Array, filetype: string) {
2929 } else {
3030 const filetype = await fileTypeFromBuffer(content);
3131 if (filetype) {
32- const blob = new Blob([content], { type: filetype.mime });
32+ const blob = new Blob([content as BlobPart], { type: filetype.mime });
3333 const url = URL.createObjectURL(blob);
3434 if (filetype.mime.startsWith("audio/")) {
3535 preview = document.createElement("audio");
@@ -108,7 +108,7 @@ async function decryptClientSide(
108108 downloadForm?.setAttribute("action", "");
109109 downloadForm?.addEventListener("submit", (e) => {
110110 e.preventDefault();
111- const blob = new Blob([content]);
111+ const blob = new Blob([content as BlobPart]);
112112 const url = URL.createObjectURL(blob);
113113 const link = document.createElement("a");
114114 link.download = filename;
Msrc/components.test.ts
@@ -3,22 +3,30 @@ import { humanReadableTime, ShowFile } from "./components";
33
44 const UUID = "00000000-0000-0000-0000-000000000000";
55
6-test("escapes filename in the file view (no stored XSS)", async () => {
6+test("escapes filename in the file view (no stored XSS)", () => {
77 const evil = "<img src=x onerror=alert(1)>";
8- const html = await ShowFile(
9- evil,
10- UUID,
11- new TextEncoder().encode("hi"),
12- "none",
13- null,
14- );
8+ const html = ShowFile({
9+ filename: evil,
10+ uuid: UUID,
11+ filetype: "none",
12+ deleteAt: null,
13+ size: 2,
14+ preview: { kind: "text", html: "hi" },
15+ });
1516 expect(html).not.toContain(evil);
1617 expect(html).toContain("&lt;img");
1718 });
1819
19-test("escapes filename in the encrypted overlay (no stored XSS)", async () => {
20+test("escapes filename in the encrypted overlay (no stored XSS)", () => {
2021 const evil = "<script>alert(1)</script>";
21- const html = await ShowFile(evil, UUID, null, "none", null);
22+ const html = ShowFile({
23+ filename: evil,
24+ uuid: UUID,
25+ filetype: "none",
26+ deleteAt: null,
27+ size: null,
28+ preview: { kind: "await" },
29+ });
2230 expect(html).not.toContain(evil);
2331 expect(html).toContain("&lt;script&gt;");
2432 });
Msrc/components.tsx
@@ -2,7 +2,6 @@
22 import { Html } from "@elysiajs/html";
33 import type { PropsWithChildren } from "@kitajs/html";
44 import { escapeHTML } from "bun";
5-import { fileTypeFromBuffer } from "file-type";
65 import hljs from "highlight.js";
76 import { config } from "./config";
87 import { humanFileSize, isValidUTF8 } from "./shared";
@@ -49,6 +48,15 @@ function ServerLimits() {
4948 <li>
5049 <small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
5150 </li>
51+ {config.maxTotalBytes !== null ? (
52+ <li>
53+ <small>
54+ Total storage capacity: {humanFileSize(config.maxTotalBytes)}
55+ </small>
56+ </li>
57+ ) : (
58+ ""
59+ )}
5260 {config.maxAgeMinutes !== null ? (
5361 <li>
5462 <small>
@@ -63,7 +71,20 @@ function ServerLimits() {
6371 <li>
6472 <small>
6573 You can upload once every {config.uploadCooldownSeconds} second
66- {config.uploadCooldownSeconds > 1 ? "s" : ""} from the same address
74+ {config.uploadCooldownSeconds > 1 ? "s" : ""} from the same
75+ address
76+ </small>
77+ </li>
78+ ) : (
79+ ""
80+ )}
81+ {config.decryptCooldownSeconds > 0 ? (
82+ <li>
83+ <small>
84+ Server-side decryption is limited to once every{" "}
85+ {config.decryptCooldownSeconds} second
86+ {config.decryptCooldownSeconds > 1 ? "s" : ""} from the same
87+ address
6788 </small>
6889 </li>
6990 ) : (
@@ -88,7 +109,6 @@ export function Index(hostname: string) {
88109 onsubmit="return onUploadSubmit()"
89110 >
90111 <input type="reset" value="Reset form" />
91- <input required={true} type="file" id="file" name="file" />
92112 <label for="filename">File name override:</label>
93113 <small>Optional. If empty, will use the uploaded file name.</small>
94114 <input
@@ -136,8 +156,8 @@ export function Index(hostname: string) {
136156 <label for="encrypt-mode-server">Encryption mode:</label>
137157 <small>
138158 Only relevant when a password is set. Client-side encrypts in your
139- browser so the password never reaches the server (requires JavaScript).
140- Server-side encrypts on upload.
159+ browser so the password never reaches the server (requires
160+ JavaScript). Server-side encrypts on upload.
141161 </small>
142162 <div>
143163 <label>
@@ -168,20 +188,30 @@ export function Index(hostname: string) {
168188 If set, file is already encrypted with an algorithm like in the python
169189 file available below. Password is ignored in this case.
170190 </small>
191+ {/* The file input must stay LAST in the DOM so the multipart upload
192+ sends the other fields before the file bytes — the server needs the
193+ password/filetype to encrypt on the fly as the stream arrives. It is
194+ moved back up to its usual spot visually with `order` in default.css. */}
195+ <input required={true} type="file" id="file" name="file" />
171196 <input type="submit" value="Upload File" />
172197 <hr />
173198 <h3>curl guide</h3>
174- You can use curl to upload and download files
175- <br />
176- Upload:
199+ <p>You can use curl to upload and download files</p>
200+ <p>
201+ Upload (
202+ <b>
203+ <code>-F file=…</code> must come last
204+ </b>
205+ ):
206+ </p>
177207 <pre>
178208 curl {hostname}upload \{"\n"}
179- -F file=@/path/to/file \{"\n"}
180- -F filetype="plaintext" \{"\n"}# optional{"\n"}
181- -F filename="file name" \{"\n"}# optional{"\n"}
182- -F delete_in_minutes="60" \{"\n"}# optional{"\n"}
183- -F password="mypassword" \{"\n"}# optional{"\n"}
184- -F encrypted="on"
209+ -F filetype="plaintext" `# optional` \{"\n"}
210+ -F filename="file name" `# optional` \{"\n"}
211+ -F delete_in_minutes="60" `# optional` \{"\n"}
212+ -F password="mypassword" `# optional` \{"\n"}
213+ -F encrypted="on" `# optional` \{"\n"}
214+ -F file=@/path/to/file
185215 </pre>
186216 <p>
187217 If you upload an already encrypted file, you should set the{" "}
@@ -190,17 +220,16 @@ export function Index(hostname: string) {
190220 python script to encrypt a file locally:{" "}
191221 <a href="/encrypt.py">encrypt.py</a>
192222 </p>
193- <br />
194- Download:
223+ <p>Download:</p>
195224 <pre>
196- curl {hostname}raw/$uuid?ignore_password=true/false \{"\n"}# optional,
197- if encrypted with password{"\n"}
198- --cookie "password=mypassword"
225+ curl {hostname}raw/$uuid \{"\n"}
226+ --cookie "password=mypassword" `# optional, only if encrypted`
199227 </pre>
200228 <p>
201- If ignore_password is set to true, then encrypted files can be
202- downloaded directly (in encrypted form) without supplying the
203- password.
229+ Append <code>?ignore_password=true</code> to the URL to download an
230+ encrypted file in its still-encrypted form, without supplying the
231+ password. You can then decrypt it locally with this python script:{" "}
232+ <a href="/decrypt.py">decrypt.py</a>
204233 </p>
205234 </form>
206235 <script src="/dist/client-index.js" />
@@ -237,59 +266,58 @@ export function humanReadableTime(minutes: number) {
237266 return result.trim();
238267 }
239268
240-export async function ShowFile(
241- filename: string,
242- uuid: string,
243- content: Uint8Array | null,
269+// Renders content as escaped/highlighted HTML for a text preview, or returns
270+// null if it isn't previewable text (binary, or the "blob" override). The
271+// returned string is already HTML-safe and is injected raw into a <pre>.
272+export function textPreviewHtml(
273+ content: Uint8Array,
244274 filetype: string,
245- delete_at: number | null,
246-) {
247- //if content is null, this is for the js frontend
248- let preview: JSX.Element;
249- if (!content) {
250- preview = <>Please wait for the file to load</>;
251- } else {
252- preview = <>This file can't be previewed</>;
253- if (isValidUTF8(content) && filetype !== "blob") {
254- if (filetype === "none") {
255- preview = (
256- <pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre>
257- );
258- } else {
259- preview = (
260- <pre>
261- {
262- hljs.highlight(new TextDecoder("utf-8").decode(content), {
263- language: filetype,
264- }).value
265- }
266- </pre>
267- );
268- }
269- } else {
270- // Point media previews at /raw/:uuid rather than inlining the whole
271- // file as a base64 data URI (which would balloon the HTML and server
272- // memory for large files). /raw serves a safe content-type and, for
273- // encrypted files, decrypts using the path-scoped password cookie.
274- const detected = await fileTypeFromBuffer(content);
275- const rawUrl = `/raw/${uuid}`;
276- if (detected) {
277- if (detected.mime.startsWith("audio/")) {
278- preview = <audio controls="" src={rawUrl} />;
279- } else if (detected.mime.startsWith("video/")) {
280- preview = <video controls src={rawUrl} />;
281- } else if (detected.mime.startsWith("image/")) {
282- preview = <img src={rawUrl} alt={filename} />;
283- }
284- }
275+): string | null {
276+ if (filetype === "blob" || !isValidUTF8(content)) return null;
277+ const text = new TextDecoder("utf-8").decode(content);
278+ if (filetype === "none") return escapeHTML(text);
279+ return hljs.highlight(text, { language: filetype }).value;
280+}
281+
282+// What the /show page should render in the preview area. Computed server-side
283+// in index.ts so that ShowFile never has to decrypt or read content itself.
284+export type Preview =
285+ | { kind: "await" } // encrypted, needs client-side decryption (overlay + JS)
286+ | { kind: "text"; html: string } // pre-rendered (escaped/highlighted) text
287+ | { kind: "media"; mime: string } // <img>/<audio>/<video> pointing at /raw
288+ | { kind: "none" }; // not previewable
289+
290+export function ShowFile(opts: {
291+ filename: string;
292+ uuid: string;
293+ filetype: string;
294+ deleteAt: number | null;
295+ size: number | null;
296+ preview: Preview;
297+}) {
298+ const { filename, uuid, filetype, deleteAt, size, preview } = opts;
299+ const awaiting = preview.kind === "await";
300+ const rawUrl = `/raw/${uuid}`;
301+
302+ let previewEl: JSX.Element = <>This file can't be previewed</>;
303+ if (preview.kind === "await") {
304+ previewEl = <>Please wait for the file to load</>;
305+ } else if (preview.kind === "text") {
306+ // Already escaped/highlighted by textPreviewHtml, injected raw.
307+ previewEl = <pre>{preview.html}</pre>;
308+ } else if (preview.kind === "media") {
309+ if (preview.mime.startsWith("audio/")) {
310+ previewEl = <audio controls="" src={rawUrl} />;
311+ } else if (preview.mime.startsWith("video/")) {
312+ previewEl = <video controls src={rawUrl} />;
313+ } else if (preview.mime.startsWith("image/")) {
314+ previewEl = <img src={rawUrl} alt={filename} />;
285315 }
286316 }
287317
288318 return (
289319 <Template css="/show.css">
290- {content ? (
291- ""
292- ) : (
320+ {awaiting ? (
293321 <div id="decrypt-overlay">
294322 <h1 safe>Encrypted file: {filename}</h1>
295323 <form
@@ -301,12 +329,7 @@ export async function ShowFile(
301329 <label id="password-label" for="password">
302330 Enter a password to decrypt the file:
303331 </label>
304- <input
305- required
306- type="password"
307- id="password"
308- name="password"
309- />
332+ <input required type="password" id="password" name="password" />
310333 <div>
311334 <label>
312335 <input
@@ -330,36 +353,36 @@ export async function ShowFile(
330353 <input type="submit" value="Submit" />
331354 </form>
332355 </div>
356+ ) : (
357+ ""
333358 )}
334359 <div id="content">
335360 <div id="filename" safe>
336361 {filename}
337362 </div>
338- <div id="mediabox">{preview}</div>
363+ <div id="mediabox">{previewEl}</div>
339364 </div>
340365 <div id="sidebar">
341366 <h3>File info</h3>
342367 <hr />
343368 <p id="filesize">
344- size: {content ? humanFileSize(content.byteLength) : "unknown"}
369+ size: {size !== null ? humanFileSize(size) : "unknown"}
345370 </p>
346371 <p>declared type: {filetype}</p>
347- {delete_at ? (
372+ {deleteAt ? (
348373 <p>
349- delete at: {new Date(delete_at * 1000).toISOString()} (in{" "}
350- {humanReadableTime(
351- Math.floor((delete_at - Date.now() / 1000) / 60),
352- )}
374+ delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
375+ {humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
353376 )
354377 </p>
355378 ) : (
356379 ""
357380 )}
358- <form id="download-form" action={`/raw/${uuid}`} method="get">
381+ <form id="download-form" action={rawUrl} method="get">
359382 <button type="submit">Download</button>
360383 </form>
361384 </div>
362- {content ? "" : <script src="/dist/client-show.js" />}
385+ {awaiting ? <script src="/dist/client-show.js" /> : ""}
363386 </Template>
364387 );
365388 }
Msrc/config.ts
@@ -2,13 +2,15 @@
22 // Defaults are chosen to be secure-but-non-breaking; tighten them in production
33 // (see README for the full list and the "run behind TLS" note).
44
5-const env = process.env
5+const env = process.env;
66
7-/** parseInt with a default that distinguishes "unset" from "explicit 0". */
7+/** parseInt with a default that distinguishes "unset" from "explicit 0". All
8+ * settings here are non-negative quantities, so a negative value falls back to
9+ * the default rather than being passed through (e.g. busboy fileSize: -5). */
810 function intEnv(value: string | undefined, defaultValue: number): number {
911 if (value === undefined || value === "") return defaultValue;
1012 const parsed = parseInt(value, 10);
11- return Number.isFinite(parsed) ? parsed : defaultValue;
13+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
1214 }
1315
1416 /** Truthy only for "true"/"1"; anything else (incl. "false", "0", unset) is off. */
@@ -26,6 +28,15 @@ export const config = {
2628 : null,
2729 // Minimum seconds between uploads from the same client IP. 0 = disabled.
2830 uploadCooldownSeconds: intEnv(Bun.env.UPLOAD_COOLDOWN_SECONDS, 0),
31+ // Minimum seconds between server-side decryption attempts from the same client
32+ // IP. 0 = disabled. Bounds the PBKDF2 CPU cost an attacker who knows a UUID can
33+ // force by hammering /show or /raw with password cookies.
34+ decryptCooldownSeconds: intEnv(Bun.env.DECRYPT_COOLDOWN_SECONDS, 0),
35+ // Cap on total stored content bytes across all files. null = unlimited; when
36+ // set, an upload that would push the total over the cap is rejected (507).
37+ maxTotalBytes: Bun.env.MAX_TOTAL_BYTES
38+ ? intEnv(Bun.env.MAX_TOTAL_BYTES, 0) || null
39+ : null,
2940 // Set when running behind a trusted TLS-terminating reverse proxy (the usual
3041 // production setup). Enables reading X-Forwarded-For / X-Real-IP for the
3142 // client IP and adds the Secure flag to the password cookie. Leave off for
Msrc/crypto.test.ts
@@ -1,7 +1,49 @@
11 import { describe, expect, test } from "bun:test";
2-import { decrypt, encrypt } from "./crypto";
2+import {
3+ CHUNK,
4+ decrypt,
5+ decryptToStream,
6+ encrypt,
7+ encryptStream,
8+} from "./crypto";
39
4-describe("crypto (AES-256-GCM)", () => {
10+function streamOf(data: Uint8Array, pieces = 1): ReadableStream<Uint8Array> {
11+ const size = Math.ceil(data.length / pieces) || 1;
12+ let off = 0;
13+ return new ReadableStream<Uint8Array>({
14+ pull(c) {
15+ if (off >= data.length) {
16+ c.close();
17+ return;
18+ }
19+ c.enqueue(data.subarray(off, off + size));
20+ off += size;
21+ },
22+ });
23+}
24+
25+async function collect(
26+ stream: ReadableStream<Uint8Array>,
27+): Promise<Uint8Array> {
28+ const parts: Uint8Array[] = [];
29+ const reader = stream.getReader();
30+ while (true) {
31+ const { done, value } = await reader.read();
32+ if (done) break;
33+ parts.push(value);
34+ }
35+ let total = 0;
36+ for (const p of parts) total += p.length;
37+ const out = new Uint8Array(total);
38+ let off = 0;
39+ for (const p of parts) {
40+ out.set(p, off);
41+ off += p.length;
42+ }
43+ return out;
44+}
45+
46+describe("crypto (chunked AES-256-GCM)", () => {
547 test("round-trips content with the correct password", async () => {
648 const data = new TextEncoder().encode("hello zbin 🔐 multi-byte");
749 const enc = await encrypt(data, "correct horse battery staple");
@@ -9,10 +51,30 @@ describe("crypto (AES-256-GCM)", () => {
951 expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte");
1052 });
1153
12- test("wire format is salt[16] | iv[12] | ciphertext+tag", async () => {
54+ test("wire format is salt[16] | prefix[7] | ct+tag", async () => {
1355 const enc = await encrypt(new Uint8Array([1, 2, 3]), "pw");
14- // 16 (salt) + 12 (iv) + 3 (plaintext) + 16 (GCM tag)
15- expect(enc.length).toBe(16 + 12 + 3 + 16);
56+ // 16 (salt) + 7 (prefix) + 3 (plaintext) + 16 (GCM tag)
57+ expect(enc.length).toBe(16 + 7 + 3 + 16);
58+ });
59+
60+ test("round-trips empty input", async () => {
61+ const enc = await encrypt(new Uint8Array(0), "pw");
62+ expect(enc.length).toBe(16 + 7 + 16); // single empty final chunk (tag only)
63+ const dec = await decrypt(enc, "pw");
64+ expect(dec.length).toBe(0);
65+ });
66+
67+ test("round-trips multi-chunk content (> CHUNK)", async () => {
68+ const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 1234));
69+ const enc = await encrypt(data, "pw");
70+ const dec = await decrypt(enc, "pw");
71+ expect(dec).toEqual(data);
72+ });
73+
74+ test("round-trips content of exactly CHUNK bytes", async () => {
75+ const data = crypto.getRandomValues(new Uint8Array(CHUNK));
76+ const dec = await decrypt(await encrypt(data, "pw"), "pw");
77+ expect(dec).toEqual(data);
1678 });
1779
1880 test("rejects a wrong password (authenticated)", async () => {
@@ -25,4 +87,61 @@ describe("crypto (AES-256-GCM)", () => {
2587 enc[enc.length - 1] ^= 0xff; // flip a tag byte
2688 await expect(decrypt(enc, "pw")).rejects.toThrow();
2789 });
90+
91+ test("rejects truncation (dropping the final chunk)", async () => {
92+ const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2));
93+ const enc = await encrypt(data, "pw");
94+ // Drop the final (full) chunk; the now-last chunk was sealed with flag=0
95+ // but will be opened with flag=1, so authentication must fail.
96+ const truncated = enc.subarray(0, enc.length - (CHUNK + 16));
97+ await expect(decrypt(truncated, "pw")).rejects.toThrow();
98+ });
99+});
100+
101+describe("crypto streaming", () => {
102+ test("encryptStream output decrypts via one-shot decrypt", async () => {
103+ const data = crypto.getRandomValues(new Uint8Array(CHUNK * 3 + 7));
104+ const enc = await collect(await encryptStream(streamOf(data, 5), "pw"));
105+ expect(await decrypt(enc, "pw")).toEqual(data);
106+ });
107+
108+ test("encryptStream invokes onHead with leading plaintext", async () => {
109+ const data = new TextEncoder().encode("GIF89a-ish header then more data");
110+ let head: Uint8Array | undefined;
111+ await collect(
112+ await encryptStream(streamOf(data, 3), "pw", (h) => {
113+ head = h;
114+ }),
115+ );
116+ expect(head).toBeDefined();
117+ expect(new TextDecoder().decode((head as Uint8Array).subarray(0, 6))).toBe(
118+ "GIF89a",
119+ );
120+ });
121+
122+ test("decryptToStream round-trips and exposes the first chunk", async () => {
123+ const data = crypto.getRandomValues(new Uint8Array(CHUNK + 500));
124+ const enc = await encrypt(data, "pw");
125+ const { firstChunk, body } = await decryptToStream(
126+ streamOf(enc, 4),
127+ "pw",
128+ enc.length,
129+ );
130+ expect(firstChunk).toEqual(data.subarray(0, CHUNK));
131+ expect(await collect(body)).toEqual(data);
132+ });
133+
134+ test("decryptToStream rejects a wrong password", async () => {
135+ const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
136+ await expect(
137+ decryptToStream(streamOf(enc), "wrong", enc.length),
138+ ).rejects.toThrow();
139+ });
140+
141+ test("stream encrypt -> stream decrypt round-trips", async () => {
142+ const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 42));
143+ const enc = await collect(await encryptStream(streamOf(data, 7), "pw"));
144+ const { body } = await decryptToStream(streamOf(enc, 3), "pw", enc.length);
145+ expect(await collect(body)).toEqual(data);
146+ });
28147 });
Msrc/crypto.ts
@@ -1,9 +1,35 @@
1-// AES-256-GCM (authenticated) encryption with a PBKDF2-derived key, using the
2-// WebCrypto API (crypto.subtle) which is available both in Bun (server) and the
3-// browser (client), so encryption/decryption is defined once for both sides.
4-// GCM gives us integrity/authentication for free (the tag is appended to the
5-// ciphertext by WebCrypto), so tampering and padding-oracle attacks don't apply.
6-// Wire format: salt[16] | iv[12] | ciphertext+tag.
1+// Chunked, streaming AES-256-GCM (authenticated) encryption with a
2+// PBKDF2-derived key, using WebCrypto (crypto.subtle) so the exact same format
3+// works in Bun (server) and the browser (client), and is matched byte-for-byte
4+// by assets/encrypt.py.
5+//
6+// The content is split into fixed-size plaintext chunks, each sealed
7+// independently with AES-GCM. This lets the server encrypt on upload and
8+// decrypt on download *while streaming* (chunk by chunk) instead of holding the
9+// whole file in memory. The key is still derived only once per file, so the
10+// expensive PBKDF2 cost is paid once regardless of size.
11+//
12+// Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
13+// encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
14+// nonce_i(12) = noncePrefix[7] || uint32_be(i) || flag (flag=1 on the final
15+// chunk, else 0)
16+// The per-chunk counter prevents reordering and the final-chunk flag prevents
17+// truncation: dropping or rearranging chunks fails authentication. Each
18+// non-final plaintext chunk is exactly CHUNK bytes, so the decryptor can derive
19+// chunk boundaries (and which chunk is last) from the total length alone —
20+// no per-chunk length prefixes are stored.
21+
22+const SALT_LEN = 16;
23+const PREFIX_LEN = 7;
24+const TAG_LEN = 16;
25+const HEADER_LEN = SALT_LEN + PREFIX_LEN; // bytes before the first chunk
26+export const CHUNK = 64 * 1024; // plaintext bytes per chunk
27+const ENC_CHUNK = CHUNK + TAG_LEN; // ciphertext bytes for a full chunk
28+
29+// `crypto.subtle` wants a BufferSource; Bun's lib types are stricter about
30+// ArrayBuffer vs SharedArrayBuffer than the values we actually pass, so cast at
31+// the boundary rather than littering call sites with copies.
32+const src = (b: Uint8Array): BufferSource => b as unknown as BufferSource;
733
834 async function deriveKey(
935 password: string,
@@ -12,13 +38,13 @@ async function deriveKey(
1238 ): Promise<CryptoKey> {
1339 const material = await crypto.subtle.importKey(
1440 "raw",
15- new TextEncoder().encode(password),
41+ src(new TextEncoder().encode(password)),
1642 { name: "PBKDF2" },
1743 false,
1844 ["deriveKey"],
1945 );
2046 return crypto.subtle.deriveKey(
21- { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" },
47+ { name: "PBKDF2", salt: src(salt), iterations: 210000, hash: "SHA-512" },
2248 material,
2349 { name: "AES-GCM", length: 256 },
2450 false,
@@ -26,32 +52,276 @@ async function deriveKey(
2652 );
2753 }
2854
55+function nonce(prefix: Uint8Array, index: number, last: boolean): Uint8Array {
56+ const n = new Uint8Array(12);
57+ n.set(prefix, 0);
58+ // 4-byte big-endian chunk counter at offset 7.
59+ n[7] = (index >>> 24) & 0xff;
60+ n[8] = (index >>> 16) & 0xff;
61+ n[9] = (index >>> 8) & 0xff;
62+ n[10] = index & 0xff;
63+ n[11] = last ? 1 : 0;
64+ return n;
65+}
66+
67+function concat(parts: Uint8Array[]): Uint8Array {
68+ let total = 0;
69+ for (const p of parts) total += p.length;
70+ const out = new Uint8Array(total);
71+ let off = 0;
72+ for (const p of parts) {
73+ out.set(p, off);
74+ off += p.length;
75+ }
76+ return out;
77+}
78+
79+async function sealChunk(
80+ key: CryptoKey,
81+ prefix: Uint8Array,
82+ index: number,
83+ last: boolean,
84+ plaintext: Uint8Array,
85+): Promise<Uint8Array> {
86+ const ct = await crypto.subtle.encrypt(
87+ { name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
88+ key,
89+ src(plaintext),
90+ );
91+ return new Uint8Array(ct);
92+}
93+
94+async function openChunk(
95+ key: CryptoKey,
96+ prefix: Uint8Array,
97+ index: number,
98+ last: boolean,
99+ ciphertext: Uint8Array,
100+): Promise<Uint8Array> {
101+ const pt = await crypto.subtle.decrypt(
102+ { name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
103+ key,
104+ src(ciphertext),
105+ );
106+ return new Uint8Array(pt);
107+}
108+
109+// ----------------------------------------------------------------------------
110+// One-shot helpers (browser uploads/downloads, tests, python parity). These
111+// buffer the whole payload; the server uses the streaming variants below.
112+// ----------------------------------------------------------------------------
113+
29114 export async function encrypt(
30115 content: Uint8Array,
31116 password: string,
32117 ): Promise<Uint8Array> {
33- const iv = crypto.getRandomValues(new Uint8Array(12));
34- const salt = crypto.getRandomValues(new Uint8Array(16));
118+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
119+ const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
35120 const key = await deriveKey(password, salt, ["encrypt"]);
36- const ciphertext = await crypto.subtle.encrypt(
37- { name: "AES-GCM", iv },
38- key,
39- content,
40- );
41- return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]);
121+ const out: Uint8Array[] = [salt, prefix];
122+ // Always emit at least one (possibly empty) final chunk so empty input still
123+ // round-trips and the final-chunk flag is always present.
124+ const chunks = Math.max(1, Math.ceil(content.length / CHUNK));
125+ for (let i = 0; i < chunks; i++) {
126+ const start = i * CHUNK;
127+ const slice = content.subarray(
128+ start,
129+ Math.min(start + CHUNK, content.length),
130+ );
131+ out.push(await sealChunk(key, prefix, i, i === chunks - 1, slice));
132+ }
133+ return concat(out);
42134 }
43135
44136 export async function decrypt(
45137 data: Uint8Array,
46138 password: string,
47139 ): Promise<Uint8Array> {
48- const salt = data.slice(0, 16);
49- const iv = data.slice(16, 28);
140+ if (data.length < HEADER_LEN + TAG_LEN) {
141+ throw new Error("ciphertext too short");
142+ }
143+ const salt = data.subarray(0, SALT_LEN);
144+ const prefix = data.subarray(SALT_LEN, HEADER_LEN);
50145 const key = await deriveKey(password, salt, ["decrypt"]);
51- const plaintext = await crypto.subtle.decrypt(
52- { name: "AES-GCM", iv },
53- key,
54- data.slice(28),
55- );
56- return new Uint8Array(plaintext);
146+ const body = data.subarray(HEADER_LEN);
147+ const out: Uint8Array[] = [];
148+ let offset = 0;
149+ let index = 0;
150+ while (offset < body.length) {
151+ const encLen = Math.min(ENC_CHUNK, body.length - offset);
152+ const last = offset + encLen >= body.length;
153+ out.push(
154+ await openChunk(
155+ key,
156+ prefix,
157+ index,
158+ last,
159+ body.subarray(offset, offset + encLen),
160+ ),
161+ );
162+ offset += encLen;
163+ index++;
164+ }
165+ return concat(out);
166+}
167+
168+// ----------------------------------------------------------------------------
169+// Streaming helpers (server-side encrypt on upload / decrypt on download).
170+// ----------------------------------------------------------------------------
171+
172+// A small pull-based reader over a ReadableStream that can hand back exact byte
173+// counts, buffering only the unconsumed remainder.
174+class ByteStreamReader {
175+ #reader: ReadableStreamDefaultReader<Uint8Array>;
176+ #buf: Uint8Array = new Uint8Array(0);
177+ #done = false;
178+
179+ constructor(stream: ReadableStream<Uint8Array>) {
180+ this.#reader = stream.getReader();
181+ }
182+
183+ async #fill(): Promise<boolean> {
184+ if (this.#done) return false;
185+ const { done, value } = await this.#reader.read();
186+ if (done) {
187+ this.#done = true;
188+ return false;
189+ }
190+ this.#buf = this.#buf.length === 0 ? value : concat([this.#buf, value]);
191+ return true;
192+ }
193+
194+ // Reads exactly `n` bytes, or throws if the stream ends first.
195+ async readExact(n: number): Promise<Uint8Array> {
196+ while (this.#buf.length < n) {
197+ if (!(await this.#fill())) throw new Error("unexpected end of stream");
198+ }
199+ const out = this.#buf.subarray(0, n);
200+ this.#buf = this.#buf.subarray(n);
201+ return out;
202+ }
203+
204+ // Reads up to `n` bytes; returns fewer only at end of stream.
205+ async readUpTo(n: number): Promise<Uint8Array> {
206+ while (this.#buf.length < n) {
207+ if (!(await this.#fill())) break;
208+ }
209+ const take = Math.min(n, this.#buf.length);
210+ const out = this.#buf.subarray(0, take);
211+ this.#buf = this.#buf.subarray(take);
212+ return out;
213+ }
214+
215+ // True if more bytes remain, without consuming them.
216+ async hasMore(): Promise<boolean> {
217+ while (this.#buf.length === 0) {
218+ if (!(await this.#fill())) return false;
219+ }
220+ return true;
221+ }
222+}
223+
224+// Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
225+// is invoked once with up to `headBytes` of leading plaintext (used to sniff a
226+// media type for previews) before the stream completes.
227+export async function encryptStream(
228+ input: ReadableStream<Uint8Array>,
229+ password: string,
230+ onHead?: (head: Uint8Array) => void,
231+ headBytes = 4100,
232+): Promise<ReadableStream<Uint8Array>> {
233+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
234+ const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
235+ const key = await deriveKey(password, salt, ["encrypt"]);
236+ const reader = new ByteStreamReader(input);
237+ let index = 0;
238+ let headDone = onHead === undefined;
239+ const headParts: Uint8Array[] = [];
240+ let headLen = 0;
241+
242+ function recordHead(chunk: Uint8Array) {
243+ if (headDone) return;
244+ const need = headBytes - headLen;
245+ if (need > 0) {
246+ const slice = chunk.subarray(0, need);
247+ headParts.push(slice);
248+ headLen += slice.length;
249+ }
250+ }
251+ function flushHead() {
252+ if (!headDone) {
253+ headDone = true;
254+ onHead?.(concat(headParts));
255+ }
256+ }
257+
258+ return new ReadableStream<Uint8Array>({
259+ start(controller) {
260+ // salt + noncePrefix come first, before any chunk.
261+ controller.enqueue(concat([salt, prefix]));
262+ },
263+ async pull(controller) {
264+ const current = await reader.readUpTo(CHUNK);
265+ recordHead(current);
266+ // Peek (without consuming) whether more plaintext follows so the final
267+ // chunk's flag is set correctly. An empty input still yields one final
268+ // (empty) chunk on the first pull.
269+ const last = !(await reader.hasMore());
270+ controller.enqueue(await sealChunk(key, prefix, index, last, current));
271+ index++;
272+ if (last) {
273+ flushHead();
274+ controller.close();
275+ }
276+ },
277+ });
278+}
279+
280+// Decrypts a stream in the chunked wire format. `totalLen` is the full byte
281+// length of the source (e.g. the on-disk file size) so chunk boundaries and the
282+// final chunk can be derived. Returns the first plaintext chunk eagerly (for
283+// MIME sniffing) plus a `body` stream that re-emits it and then the rest, all
284+// from a single key derivation.
285+export async function decryptToStream(
286+ source: ReadableStream<Uint8Array>,
287+ password: string,
288+ totalLen: number,
289+): Promise<{ firstChunk: Uint8Array; body: ReadableStream<Uint8Array> }> {
290+ const dataLen = totalLen - HEADER_LEN;
291+ if (dataLen < TAG_LEN) throw new Error("ciphertext too short");
292+ const reader = new ByteStreamReader(source);
293+ const header = await reader.readExact(HEADER_LEN);
294+ const salt = header.subarray(0, SALT_LEN);
295+ const prefix = header.subarray(SALT_LEN, HEADER_LEN);
296+ const key = await deriveKey(password, salt, ["decrypt"]);
297+
298+ let consumed = 0;
299+ let index = 0;
300+ async function next(): Promise<Uint8Array | null> {
301+ if (consumed >= dataLen) return null;
302+ const encLen = Math.min(ENC_CHUNK, dataLen - consumed);
303+ const last = consumed + encLen >= dataLen;
304+ const ct = await reader.readExact(encLen);
305+ consumed += encLen;
306+ const pt = await openChunk(key, prefix, index, last, ct);
307+ index++;
308+ return pt;
309+ }
310+
311+ // Decrypt the first chunk now (throws on a wrong password / tamper).
312+ const firstChunk = (await next()) ?? new Uint8Array(0);
313+ let firstEmitted = false;
314+ const body = new ReadableStream<Uint8Array>({
315+ async pull(controller) {
316+ if (!firstEmitted) {
317+ firstEmitted = true;
318+ controller.enqueue(firstChunk);
319+ return;
320+ }
321+ const chunk = await next();
322+ if (chunk === null) controller.close();
323+ else controller.enqueue(chunk);
324+ },
325+ });
326+ return { firstChunk, body };
57327 }
Msrc/index.ts
@@ -1,8 +1,12 @@
1+import { randomUUID } from "node:crypto";
2+import { mkdirSync } from "node:fs";
3+import { unlink } from "node:fs/promises";
4+import { Readable } from "node:stream";
15 import { Database } from "bun:sqlite";
26 import cron from "@elysiajs/cron";
37 import { html } from "@elysiajs/html";
48 import staticPlugin from "@elysiajs/static";
5-import { randomUUIDv7 } from "bun";
9+import busboy from "busboy";
610 import { Elysia, StatusMap, t } from "elysia";
711 import { fileTypeFromBuffer } from "file-type";
812 import {
@@ -11,26 +15,51 @@ import {
1115 NotFound,
1216 SetCookie,
1317 ShowFile,
18+ textPreviewHtml,
1419 WrongPassword,
20+ type Preview,
1521 } from "./components";
1622 import { config } from "./config";
17-import { decrypt, encrypt } from "./crypto";
23+import { decryptToStream, encryptStream } from "./crypto";
24+
25+const BLOB_DIR = "./db/blobs";
26+mkdirSync(BLOB_DIR, { recursive: true });
27+const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`;
28+const safeUnlink = (path: string) => unlink(path).catch(() => {});
29+
30+const filetypeSet = new Set(filetypes);
31+const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection
1832
1933 const db = new Database("./db/db.sqlite");
2034 db.run("PRAGMA foreign_keys = ON");
2135 db.run("PRAGMA journal_mode = WAL");
36+// Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata.
37+// `size` is the on-disk byte count (post-encryption) and drives the total-bytes
38+// cap; `media_mime` is the MIME sniffed from the plaintext head at upload time,
39+// letting /show preview media without decrypting.
2240 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",
41+ "CREATE TABLE IF NOT EXISTS files (uuid TEXT PRIMARY KEY, filename TEXT NOT NULL, filetype TEXT NOT NULL, encrypted INTEGER NOT NULL, size INTEGER NOT NULL, media_mime TEXT, delete_at INTEGER) STRICT",
2442 );
2543 db.run("PRAGMA optimize");
2644
45+// Running total of stored content bytes, initialized once from the DB and then
46+// maintained in memory (incremented on upload, decremented when files expire).
47+let totalBytes = (
48+ db.prepare("SELECT COALESCE(SUM(size), 0) AS t FROM files").get() as {
49+ t: number;
50+ }
51+).t;
52+
2753 // 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.
54+// inject CRLF/extra directives into the Set-Cookie Path or content-disposition,
55+// or to escape the blob directory.
2956 const UUID_PATTERN = "^[0-9a-fA-F-]{36}$";
3057
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.
58+// Per-IP timestamp of the last accepted upload / last server-side decryption
59+// attempt, used for the respective cooldowns. Pruned by the cron below so they
60+// can't grow without bound.
3361 const lastUpload = new Map<string, number>();
62+const lastDecrypt = new Map<string, number>();
3463
3564 type MinimalServer = {
3665 requestIP(req: Request): { address: string } | null;
@@ -50,13 +79,34 @@ function clientIp(
5079 return server?.requestIP(request)?.address ?? "unknown";
5180 }
5281
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));
82+// An upload failure that maps to a specific HTTP status + message.
83+class UploadError extends Error {
84+ constructor(
85+ readonly status: number,
86+ message: string,
87+ ) {
88+ super(message);
89+ }
90+}
91+
92+async function pump(
93+ stream: ReadableStream<Uint8Array>,
94+ onChunk: (chunk: Uint8Array) => void | Promise<void>,
95+): Promise<void> {
96+ const reader = stream.getReader();
97+ while (true) {
98+ const { done, value } = await reader.read();
99+ if (done) break;
100+ // Await the callback so a slow sink applies backpressure (Bun's FileSink
101+ // .write returns a Promise when the write is still pending) instead of
102+ // buffering the whole upload in memory.
103+ await onChunk(value);
104+ }
105+}
106+
107+async function sniffMime(bytes: Uint8Array): Promise<string | null> {
108+ if (bytes.length === 0) return null;
109+ return (await fileTypeFromBuffer(bytes))?.mime ?? null;
60110 }
61111
62112 const app = new Elysia({
@@ -70,21 +120,58 @@ const app = new Elysia({
70120 cron({
71121 name: "delete",
72122 pattern: "*/5 * * * * *",
73- run() {
74- db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')");
123+ async run() {
124+ const expired = db
125+ .prepare(
126+ "SELECT uuid, size FROM files WHERE delete_at < strftime('%s', 'now')",
127+ )
128+ .all() as { uuid: string; size: number }[];
129+ if (expired.length > 0) {
130+ // Delete by the exact uuids selected above, not a second
131+ // strftime('now') comparison (which evaluates at a later instant and
132+ // could delete a row we didn't account for here — leaking the counter
133+ // and orphaning its blob).
134+ const placeholders = expired.map(() => "?").join(",");
135+ db.exec(
136+ `DELETE FROM files WHERE uuid IN (${placeholders})`,
137+ expired.map((e) => e.uuid),
138+ );
139+ for (const { uuid, size } of expired) {
140+ await safeUnlink(blobPath(uuid));
141+ totalBytes -= size;
142+ }
143+ if (totalBytes < 0) totalBytes = 0;
144+ }
145+ const now = Date.now();
75146 if (config.uploadCooldownSeconds > 0) {
76- const cutoff = Date.now() - config.uploadCooldownSeconds * 1000;
147+ const cutoff = now - config.uploadCooldownSeconds * 1000;
77148 for (const [ip, ts] of lastUpload) {
78149 if (ts < cutoff) lastUpload.delete(ip);
79150 }
80151 }
152+ if (config.decryptCooldownSeconds > 0) {
153+ const cutoff = now - config.decryptCooldownSeconds * 1000;
154+ for (const [ip, ts] of lastDecrypt) {
155+ if (ts < cutoff) lastDecrypt.delete(ip);
156+ }
157+ }
158+ },
159+ }),
160+ )
161+ .use(
162+ cron({
163+ // Run SQLite's optimizer periodically (not just at startup), per its docs.
164+ name: "optimize",
165+ pattern: "0 0 * * * *",
166+ run() {
167+ db.exec("PRAGMA optimize");
81168 },
82169 }),
83170 )
84171 .get("/", ({ server }) => Index(server?.url.toString() ?? ""))
85172 .post(
86173 "/upload",
87- async ({ set, body, server, request, headers }) => {
174+ async ({ set, server, request, headers }) => {
88175 const ip = clientIp(server, request, headers);
89176 const now = Date.now();
90177 if (config.uploadCooldownSeconds > 0) {
@@ -94,98 +181,290 @@ const app = new Elysia({
94181 return "Upload cooldown active, please wait before uploading again";
95182 }
96183 }
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- }
101184
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);
185+ const contentType = request.headers.get("content-type") ?? "";
186+ if (!contentType.includes("multipart/form-data") || !request.body) {
187+ set.status = 400;
188+ return "Expected a multipart/form-data upload";
114189 }
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;
190+
191+ const uuid = randomUUID();
192+ const path = blobPath(uuid);
193+
194+ // The in-flight blob write. A rejection can settle the upload while this
195+ // is still draining to disk; the catch awaits it before unlinking so a
196+ // late write can't recreate the blob after cleanup (orphan).
197+ let writing: Promise<unknown> | undefined;
198+
199+ try {
200+ // Parse the multipart body as it streams in. The file part must come
201+ // LAST so the other fields (password, filetype, ...) are known before
202+ // the bytes flow and can drive on-the-fly encryption to disk.
203+ const result = await new Promise<{
204+ filename: string;
205+ filetype: string;
206+ encrypted: boolean;
207+ size: number;
208+ mediaMime: string | null;
209+ deleteAt: number | null;
210+ }>((resolve, reject) => {
211+ const bb = busboy({
212+ headers: { "content-type": contentType },
213+ limits: { files: 1, fileSize: config.maxUploadBytes },
214+ });
215+ const fields: Record<string, string> = {};
216+ let fileSeen = false;
217+ // A form field arriving after the file part means the file wasn't
218+ // sent last. We don't act on it here (the file is still streaming) —
219+ // we record it and report it once the whole body is parsed, so the
220+ // "file must be last" error always wins over an incidental symptom
221+ // like a not-yet-seen filetype.
222+ let fieldAfterFile = false;
223+
224+ bb.on("field", (name, value) => {
225+ if (fileSeen) fieldAfterFile = true;
226+ else fields[name] = value;
227+ });
228+
229+ bb.on("file", (name, stream, info) => {
230+ if (name !== "file") {
231+ stream.resume();
232+ return;
233+ }
234+ fileSeen = true;
235+ writing = streamToBlob(stream, info);
236+ });
237+
238+ bb.on("error", reject);
239+
240+ // Settle once the entire body is parsed: by now the field set and the
241+ // file's position relative to other fields are both fully known.
242+ bb.on("close", async () => {
243+ try {
244+ if (!fileSeen) throw new UploadError(400, "No file provided");
245+ if (fieldAfterFile) {
246+ throw new UploadError(
247+ 400,
248+ "the file field must be the last form field",
249+ );
250+ }
251+ // Set because a file part was seen; await it to surface any
252+ // streaming error and read the stored size/type.
253+ const file = await (writing as ReturnType<typeof streamToBlob>);
254+
255+ const filetype = fields.filetype ?? "";
256+ if (!filetypeSet.has(filetype)) {
257+ throw new UploadError(400, "Invalid or missing filetype");
258+ }
259+ const dim = fields.delete_in_minutes;
260+ if (dim && !/^[0-9]+$/.test(dim)) {
261+ throw new UploadError(400, "Invalid delete_in_minutes");
262+ }
263+ let minutes: number | null =
264+ dim && Number(dim) > 0 ? Number(dim) : null;
265+ if (config.maxAgeMinutes !== null) {
266+ minutes = Math.min(
267+ minutes ?? config.maxAgeMinutes,
268+ config.maxAgeMinutes,
269+ );
270+ }
271+ const deleteAt =
272+ minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
273+
274+ resolve({
275+ filename: fields.filename || file.infoFilename || "file",
276+ filetype,
277+ encrypted: file.encrypted,
278+ size: file.size,
279+ mediaMime: file.mediaMime,
280+ deleteAt,
281+ });
282+ } catch (e) {
283+ reject(e);
284+ }
285+ });
286+
287+ // Streams the file part to ./db/blobs/<uuid>, encrypting on the fly
288+ // when a password was provided (server-side) or storing opaque bytes
289+ // for an already-encrypted upload. Uses the fields seen so far; if the
290+ // file wasn't last that set is incomplete, but the close handler
291+ // rejects such uploads before anything is persisted.
292+ async function streamToBlob(stream: Readable, info: busboy.FileInfo) {
293+ let limitExceeded = false;
294+ stream.on("limit", () => {
295+ limitExceeded = true;
296+ });
297+ const webIn = Readable.toWeb(
298+ stream,
299+ ) as unknown as ReadableStream<Uint8Array>;
300+
301+ const sink = Bun.file(path).writer();
302+ let size = 0;
303+ let mediaMime: string | null = null;
304+ let encrypted = false;
305+
306+ if (fields.encrypted === "on") {
307+ // Client-side encrypted: opaque bytes, store as-is, no sniffing.
308+ encrypted = true;
309+ await pump(webIn, async (c) => {
310+ size += c.length;
311+ await sink.write(c);
312+ });
313+ } else if (fields.password) {
314+ // Server-side encryption: encrypt the stream to disk, sniffing
315+ // the plaintext head for a preview MIME type.
316+ encrypted = true;
317+ let head: Uint8Array | undefined;
318+ const cipher = await encryptStream(
319+ webIn,
320+ fields.password,
321+ (h) => {
322+ head = h;
323+ },
324+ SNIFF_BYTES,
325+ );
326+ await pump(cipher, async (c) => {
327+ size += c.length;
328+ await sink.write(c);
329+ });
330+ if (head) mediaMime = await sniffMime(head);
331+ } else {
332+ // Plaintext: stream to disk, collecting the head for sniffing.
333+ const headParts: Uint8Array[] = [];
334+ let headLen = 0;
335+ await pump(webIn, async (c) => {
336+ size += c.length;
337+ await sink.write(c);
338+ if (headLen < SNIFF_BYTES) {
339+ const slice = c.subarray(0, SNIFF_BYTES - headLen);
340+ headParts.push(slice);
341+ headLen += slice.length;
342+ }
343+ });
344+ mediaMime = await sniffMime(Buffer.concat(headParts));
345+ }
346+
347+ await sink.end();
348+ if (limitExceeded) {
349+ throw new UploadError(
350+ 413,
351+ `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`,
352+ );
353+ }
354+ return {
355+ encrypted,
356+ size,
357+ mediaMime,
358+ infoFilename: info.filename ?? "",
359+ };
360+ }
361+
362+ Readable.fromWeb(
363+ request.body as unknown as import("node:stream/web").ReadableStream<Uint8Array>,
364+ ).pipe(bb);
365+ });
366+
367+ // Enforce the total-bytes cap against the in-memory counter. Concurrent
368+ // uploads can transiently overshoot by up to (concurrency * per-file)
369+ // before this check; acceptable at the expected scale.
370+ if (
371+ config.maxTotalBytes !== null &&
372+ totalBytes + result.size > config.maxTotalBytes
373+ ) {
374+ await safeUnlink(path);
375+ set.status = 507; // Insufficient Storage
376+ return "Server storage is full, try again later";
377+ }
378+
379+ db.exec(
380+ "INSERT INTO files (uuid, filename, filetype, encrypted, size, media_mime, delete_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
381+ [
382+ uuid,
383+ result.filename,
384+ result.filetype,
385+ result.encrypted,
386+ result.size,
387+ result.mediaMime,
388+ result.deleteAt,
389+ ],
390+ );
391+ totalBytes += result.size;
392+ if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now);
393+ set.status = StatusMap["See Other"];
394+ set.headers.location = `/show/${uuid}`;
395+ return `Created with id: ${uuid}`;
396+ } catch (e) {
397+ // Let any in-flight write finish so it can't recreate the blob after
398+ // we unlink it below.
399+ if (writing) await writing.catch(() => {});
400+ await safeUnlink(path);
401+ if (e instanceof UploadError) {
402+ set.status = e.status;
403+ return e.message;
404+ }
405+ throw e;
123406 }
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}`;
139407 },
140408 {
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- }),
409+ // Body parsing is handled manually from the raw stream, so disable
410+ // Elysia's parser (which would otherwise buffer the whole upload).
411+ parse: "none",
154412 },
155413 )
156414 .get(
157415 "/show/:uuid",
158- async ({ set, params, cookie }) => {
159- const result =
416+ async ({ set, params, cookie, server, request, headers }) => {
417+ const row =
160418 (db
161419 .prepare(
162- "SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?",
420+ "SELECT filename, filetype, encrypted, size, media_mime, delete_at FROM files WHERE uuid = ?",
163421 )
164422 .get(params.uuid) as {
165423 filename: string;
166- content: Uint8Array;
167424 filetype: string;
168425 encrypted: number;
426+ size: number;
427+ media_mime: string | null;
169428 delete_at: number | null;
170429 }) || null;
171- if (!result) {
430+ if (!row) {
172431 set.status = StatusMap["Not Found"];
173432 return NotFound();
174433 }
175- if (result.encrypted) {
434+
435+ const path = blobPath(params.uuid);
436+ let preview: Preview;
437+ let shownSize: number | null = row.size;
438+
439+ if (row.encrypted) {
176440 const password = cookie.password.value;
177441 if (!password) {
178- return ShowFile(
179- result.filename,
180- params.uuid,
181- null,
182- result.filetype,
183- result.delete_at,
184- );
442+ // No password yet: let the client-side flow handle decryption.
443+ preview = { kind: "await" };
444+ shownSize = null;
445+ } else if (row.filetype === "blob") {
446+ // Binary/media: don't decrypt here — preview points at /raw, which
447+ // performs the single decryption for this view.
448+ preview = row.media_mime
449+ ? { kind: "media", mime: row.media_mime }
450+ : { kind: "none" };
185451 } else {
452+ // Text: this is the one server-side decryption for the view.
453+ const ip = clientIp(server, request, headers);
454+ if (decryptBlocked(ip)) {
455+ set.status = 429;
456+ return "Decryption cooldown active, please wait and reload";
457+ }
186458 try {
187- result.content = await decrypt(result.content, password);
459+ const content = await readDecrypted(path, password, row.size);
460+ recordDecrypt(ip);
461+ const out = new Uint8Array(content);
462+ shownSize = out.byteLength;
463+ const text = textPreviewHtml(out, row.filetype);
464+ preview =
465+ text !== null ? { kind: "text", html: text } : { kind: "none" };
188466 } catch (_e) {
467+ recordDecrypt(ip);
189468 const secure = config.behindProxy ? "; Secure" : "";
190469 set.status = StatusMap.Forbidden;
191470 set.headers["set-cookie"] = [
@@ -195,14 +474,32 @@ const app = new Elysia({
195474 return WrongPassword();
196475 }
197476 }
477+ } else if (row.filetype !== "blob") {
478+ // Plaintext text: read from disk and render inline.
479+ const content = new Uint8Array(await Bun.file(path).bytes());
480+ shownSize = content.byteLength;
481+ const text = textPreviewHtml(content, row.filetype);
482+ preview =
483+ text !== null
484+ ? { kind: "text", html: text }
485+ : row.media_mime
486+ ? { kind: "media", mime: row.media_mime }
487+ : { kind: "none" };
488+ } else {
489+ // Plaintext binary/media: preview via /raw using the sniffed MIME.
490+ preview = row.media_mime
491+ ? { kind: "media", mime: row.media_mime }
492+ : { kind: "none" };
198493 }
199- return ShowFile(
200- result.filename,
201- params.uuid,
202- result.content,
203- result.filetype,
204- result.delete_at,
205- );
494+
495+ return ShowFile({
496+ filename: row.filename,
497+ uuid: params.uuid,
498+ filetype: row.filetype,
499+ deleteAt: row.delete_at,
500+ size: shownSize,
501+ preview,
502+ });
206503 },
207504 {
208505 params: t.Object({
@@ -237,35 +534,64 @@ const app = new Elysia({
237534 )
238535 .get(
239536 "/raw/:uuid",
240- async ({ set, params, cookie, query }) => {
241- const result =
537+ async ({ set, params, cookie, query, server, request, headers }) => {
538+ const row =
242539 (db
243540 .prepare(
244- "SELECT content, filename, encrypted, filetype FROM files WHERE uuid = ?",
541+ "SELECT filename, encrypted, filetype, size FROM files WHERE uuid = ?",
245542 )
246543 .get(params.uuid) as {
247- content: Uint8Array;
248544 filename: string;
249545 encrypted: number;
250546 filetype: string;
547+ size: number;
251548 }) || null;
252- if (!result) {
549+ if (!row) {
253550 set.status = StatusMap["Not Found"];
254551 return "File not found";
255552 }
553+
554+ const path = blobPath(params.uuid);
256555 const servingEncrypted =
257- result.encrypted && query.ignore_password === "true";
258- if (result.encrypted && !servingEncrypted) {
259- if (!cookie.password.value) {
556+ row.encrypted && query.ignore_password === "true";
557+
558+ let body: ReadableStream<Uint8Array> | ReturnType<typeof Bun.file>;
559+ let sniff: Uint8Array | null = null;
560+
561+ if (row.encrypted && !servingEncrypted) {
562+ const password = cookie.password.value;
563+ if (!password) {
260564 set.status = StatusMap.Unauthorized;
261565 return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
262566 }
567+ const ip = clientIp(server, request, headers);
568+ if (decryptBlocked(ip)) {
569+ set.status = 429;
570+ return "Decryption cooldown active, please wait and retry";
571+ }
263572 try {
264- result.content = await decrypt(result.content, cookie.password.value);
573+ const { firstChunk, stream } = await openDecrypted(
574+ path,
575+ password,
576+ row.size,
577+ );
578+ recordDecrypt(ip);
579+ body = stream;
580+ sniff = firstChunk.subarray(0, SNIFF_BYTES);
265581 } catch (_e) {
582+ recordDecrypt(ip);
266583 set.status = StatusMap.Forbidden;
267584 return "Incorrect password";
268585 }
586+ } else {
587+ // Serve the file as-is: plaintext, or (with ignore_password) the still
588+ // encrypted bytes. BunFile streams and supports range requests.
589+ body = Bun.file(path);
590+ if (!servingEncrypted) {
591+ sniff = new Uint8Array(
592+ await Bun.file(path).slice(0, SNIFF_BYTES).arrayBuffer(),
593+ );
594+ }
269595 }
270596
271597 // Never let the browser sniff stored content into an executable type
@@ -274,8 +600,8 @@ const app = new Elysia({
274600 // else (incl. still-encrypted bytes) is an octet-stream attachment.
275601 let mime = "application/octet-stream";
276602 let disposition = "attachment";
277- if (!servingEncrypted) {
278- const detected = await fileTypeFromBuffer(result.content);
603+ if (sniff) {
604+ const detected = await fileTypeFromBuffer(sniff);
279605 if (
280606 detected &&
281607 detected.mime !== "image/svg+xml" &&
@@ -288,15 +614,15 @@ const app = new Elysia({
288614 }
289615 }
290616
291- const safeName = encodeURIComponent(result.filename);
617+ const safeName = encodeURIComponent(row.filename);
292618 set.headers["x-content-type-options"] = "nosniff";
293619 set.headers["content-type"] = mime;
294- set.headers.encrypted = result.encrypted ? "true" : "false";
295- set.headers.filetype = result.filetype;
620+ set.headers.encrypted = row.encrypted ? "true" : "false";
621+ set.headers.filetype = row.filetype;
296622 set.headers.filename = safeName;
297623 set.headers["content-disposition"] =
298624 `${disposition}; filename*=UTF-8''${safeName}`;
299- return result.content;
625+ return body;
300626 },
301627 {
302628 params: t.Object({
@@ -308,6 +634,46 @@ const app = new Elysia({
308634 )
309635 .listen(3000);
310636
637+// --- server-side decryption cooldown ----------------------------------------
638+
639+function decryptBlocked(ip: string): boolean {
640+ if (config.decryptCooldownSeconds <= 0) return false;
641+ const last = lastDecrypt.get(ip) ?? 0;
642+ return Date.now() - last < config.decryptCooldownSeconds * 1000;
643+}
644+function recordDecrypt(ip: string): void {
645+ if (config.decryptCooldownSeconds > 0) lastDecrypt.set(ip, Date.now());
646+}
647+
648+// Fully decrypt an on-disk blob to memory (used for text previews in /show).
649+async function readDecrypted(
650+ path: string,
651+ password: string,
652+ size: number,
653+): Promise<Uint8Array> {
654+ const { stream } = await openDecrypted(path, password, size);
655+ const parts: Uint8Array[] = [];
656+ await pump(stream, (c) => {
657+ parts.push(c);
658+ });
659+ return Buffer.concat(parts);
660+}
661+
662+// Open an on-disk blob for streaming decryption, exposing the first plaintext
663+// chunk (for MIME sniffing) and the full plaintext stream from one derivation.
664+async function openDecrypted(
665+ path: string,
666+ password: string,
667+ size: number,
668+): Promise<{ firstChunk: Uint8Array; stream: ReadableStream<Uint8Array> }> {
669+ const { firstChunk, body } = await decryptToStream(
670+ Bun.file(path).stream(),
671+ password,
672+ size,
673+ );
674+ return { firstChunk, stream: body };
675+}
676+
311677 console.log(
312678 `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
313679 );