streaming + various fixes
MREADME.md
| @@ -8,8 +8,10 @@ ZBin is configured through environment variables (all optional): | |||
|---|---|---|---|
| 8 | 8 | | Variable | Default | Description | | |
| 9 | 9 | | --- | --- | --- | | |
| 10 | 10 | | `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`). | | |
| 11 | 12 | | `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). | | |
| 12 | 13 | | `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. | | |
| 13 | 15 | | `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. | | |
| 14 | 16 | ||
| 15 | 17 | > 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 { | |||
|---|---|---|---|
| 84 | 84 | input[type="reset"] { | |
| 85 | 85 | max-width: 25rem; | |
| 86 | 86 | 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; | |
| 87 | 94 | } | |
| 88 | 95 | ||
| 89 | 96 | dialog { | |
Massets/encrypt.py
| @@ -5,23 +5,31 @@ from cryptography.hazmat.backends import default_backend | |||
|---|---|---|---|
| 5 | 5 | import os | |
| 6 | 6 | import sys | |
| 7 | 7 | ||
| 8 | + | CHUNK = 64 * 1024 # plaintext bytes per chunk (must match src/crypto.ts) | |
| 9 | + | ||
| 10 | + | ||
| 8 | 11 | def encrypt(content: bytes, password: str) -> bytes: | |
| 9 | 12 | """ | |
| 10 | 13 | Encrypts the given content using the provided password. | |
| 11 | 14 | ||
| 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 | |
| 13 | 16 | server/browser implementation in src/crypto.ts. | |
| 14 | 17 | ||
| 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 | + | ||
| 15 | 23 | Args: | |
| 16 | 24 | content (bytes): The content to be encrypted. | |
| 17 | 25 | password (str): The password used for encryption. | |
| 18 | 26 | ||
| 19 | 27 | Returns: | |
| 20 | - | bytes: salt[16] | iv[12] | ciphertext+tag | |
| 28 | + | bytes: the wire format described above | |
| 21 | 29 | """ | |
| 22 | 30 | ||
| 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) | |
| 25 | 33 | salt = os.urandom(16) | |
| 26 | 34 | ||
| 27 | 35 | # Derive a key from the password using PBKDF2 | |
| @@ -32,13 +40,20 @@ def encrypt(content: bytes, password: str) -> bytes: | |||
|---|---|---|---|
| 32 | 40 | iterations=210000, | |
| 33 | 41 | backend=default_backend() | |
| 34 | 42 | ) | |
| 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) | |
| 36 | 55 | ||
| 37 | - | # Encrypt with AES-256-GCM (the authentication tag is appended to the ciphertext) | |
| 38 | - | encrypted_content = AESGCM(key).encrypt(iv, content, None) | |
| 39 | 56 | ||
| 40 | - | # Return the salt, IV, and encrypted content concatenated | |
| 41 | - | return salt + iv + encrypted_content | |
| 42 | 57 | if __name__ == "__main__": | |
| 43 | 58 | if len(sys.argv) != 4: | |
| 44 | 59 | print("Usage: encrypt.py <file> <password> <outfile>") | |
Mbun.lock
| @@ -5,10 +5,11 @@ | |||
|---|---|---|---|
| 5 | 5 | "": { | |
| 6 | 6 | "name": "zbin", | |
| 7 | 7 | "dependencies": { | |
| 8 | - | "@chneau/elysia-compression": "^1.0.11", | |
| 9 | 8 | "@elysiajs/cron": "^1.2.0", | |
| 10 | 9 | "@elysiajs/html": "^1.2.0", | |
| 11 | 10 | "@elysiajs/static": "^1.2.0", | |
| 11 | + | "@types/busboy": "^1.5.4", | |
| 12 | + | "busboy": "^1.6.0", | |
| 12 | 13 | "elysia": "^1.2.0", | |
| 13 | 14 | "file-type": "^20.0.1", | |
| 14 | 15 | "highlight.js": "^11.11.1", | |
| @@ -40,8 +41,6 @@ | |||
|---|---|---|---|
| 40 | 41 | ||
| 41 | 42 | "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], | |
| 42 | 43 | ||
| 43 | - | "@chneau/elysia-compression": ["@chneau/elysia-compression@1.0.11", "", { "dependencies": { "elysia": "^1.1.9" } }, "sha512-J4wfz5Qs68p/DNvkg5DXxo8sxJKSibzaUBP4W56uvqgs9NtHG0LC6tnyv7sAPEjNKzh21l6kinMoXbtVDp/KSQ=="], | |
| 44 | - | ||
| 45 | 44 | "@elysiajs/cron": ["@elysiajs/cron@1.4.2", "", { "dependencies": { "croner": "^6.0.3" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-osQrgInKlW0m0/NUuBUX7+O9CFnx5NnG3NszeCaokTChaCVYciv/S4mmXzZZ8+Zg9sI8BmEQbV+L19wKStXYZw=="], | |
| 46 | 45 | ||
| 47 | 46 | "@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 @@ | |||
|---|---|---|---|
| 58 | 57 | ||
| 59 | 58 | "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], | |
| 60 | 59 | ||
| 60 | + | "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], | |
| 61 | + | ||
| 61 | 62 | "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], | |
| 62 | 63 | ||
| 63 | 64 | "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], | |
| @@ -66,6 +67,8 @@ | |||
|---|---|---|---|
| 66 | 67 | ||
| 67 | 68 | "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], | |
| 68 | 69 | ||
| 70 | + | "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], | |
| 71 | + | ||
| 69 | 72 | "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], | |
| 70 | 73 | ||
| 71 | 74 | "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 @@ | |||
|---|---|---|---|
| 106 | 109 | ||
| 107 | 110 | "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], | |
| 108 | 111 | ||
| 112 | + | "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], | |
| 113 | + | ||
| 109 | 114 | "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=="], | |
| 110 | 115 | ||
| 111 | 116 | "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: | |||
|---|---|---|---|
| 8 | 8 | - 3000:3000 | |
| 9 | 9 | environment: | |
| 10 | 10 | # 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 @@ | |||
|---|---|---|---|
| 7 | 7 | "prod": "bun run build && bun run src/index.ts", | |
| 8 | 8 | "format": "biome format --write", | |
| 9 | 9 | "lint": "biome lint", | |
| 10 | + | "typecheck": "tsc --noEmit", | |
| 10 | 11 | "test": "bun test" | |
| 11 | 12 | }, | |
| 12 | 13 | "dependencies": { | |
| 13 | - | "@chneau/elysia-compression": "^1.0.11", | |
| 14 | 14 | "@elysiajs/cron": "^1.2.0", | |
| 15 | 15 | "@elysiajs/html": "^1.2.0", | |
| 16 | 16 | "@elysiajs/static": "^1.2.0", | |
| 17 | + | "busboy": "^1.6.0", | |
| 17 | 18 | "elysia": "^1.2.0", | |
| 18 | 19 | "file-type": "^20.0.1", | |
| 19 | 20 | "highlight.js": "^11.11.1" | |
| 20 | 21 | }, | |
| 21 | 22 | "devDependencies": { | |
| 22 | 23 | "@biomejs/biome": "2.4.16", | |
| 24 | + | "@types/busboy": "^1.5.4", | |
| 23 | 25 | "bun-types": "latest" | |
| 24 | 26 | }, | |
| 25 | 27 | "module": "src/index.js" | |
Msrc/client-index.ts
| @@ -49,7 +49,7 @@ async function uploadFile() { | |||
|---|---|---|---|
| 49 | 49 | const content = await file.arrayBuffer(); | |
| 50 | 50 | const encryptedContent = await encrypt(new Uint8Array(content), password); | |
| 51 | 51 | ||
| 52 | - | const myFile = new File([encryptedContent], file.name); | |
| 52 | + | const myFile = new File([encryptedContent as BlobPart], file.name); | |
| 53 | 53 | const dataTransfer = new DataTransfer(); | |
| 54 | 54 | dataTransfer.items.add(myFile); | |
| 55 | 55 | ||
Msrc/client-show.ts
| @@ -29,7 +29,7 @@ async function showContent(content: Uint8Array, filetype: string) { | |||
|---|---|---|---|
| 29 | 29 | } else { | |
| 30 | 30 | const filetype = await fileTypeFromBuffer(content); | |
| 31 | 31 | if (filetype) { | |
| 32 | - | const blob = new Blob([content], { type: filetype.mime }); | |
| 32 | + | const blob = new Blob([content as BlobPart], { type: filetype.mime }); | |
| 33 | 33 | const url = URL.createObjectURL(blob); | |
| 34 | 34 | if (filetype.mime.startsWith("audio/")) { | |
| 35 | 35 | preview = document.createElement("audio"); | |
| @@ -108,7 +108,7 @@ async function decryptClientSide( | |||
|---|---|---|---|
| 108 | 108 | downloadForm?.setAttribute("action", ""); | |
| 109 | 109 | downloadForm?.addEventListener("submit", (e) => { | |
| 110 | 110 | e.preventDefault(); | |
| 111 | - | const blob = new Blob([content]); | |
| 111 | + | const blob = new Blob([content as BlobPart]); | |
| 112 | 112 | const url = URL.createObjectURL(blob); | |
| 113 | 113 | const link = document.createElement("a"); | |
| 114 | 114 | link.download = filename; | |
Msrc/components.test.ts
| @@ -3,22 +3,30 @@ import { humanReadableTime, ShowFile } from "./components"; | |||
|---|---|---|---|
| 3 | 3 | ||
| 4 | 4 | const UUID = "00000000-0000-0000-0000-000000000000"; | |
| 5 | 5 | ||
| 6 | - | test("escapes filename in the file view (no stored XSS)", async () => { | |
| 6 | + | test("escapes filename in the file view (no stored XSS)", () => { | |
| 7 | 7 | 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 | + | }); | |
| 15 | 16 | expect(html).not.toContain(evil); | |
| 16 | 17 | expect(html).toContain("<img"); | |
| 17 | 18 | }); | |
| 18 | 19 | ||
| 19 | - | test("escapes filename in the encrypted overlay (no stored XSS)", async () => { | |
| 20 | + | test("escapes filename in the encrypted overlay (no stored XSS)", () => { | |
| 20 | 21 | 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 | + | }); | |
| 22 | 30 | expect(html).not.toContain(evil); | |
| 23 | 31 | expect(html).toContain("<script>"); | |
| 24 | 32 | }); | |
Msrc/components.tsx
| @@ -2,7 +2,6 @@ | |||
|---|---|---|---|
| 2 | 2 | import { Html } from "@elysiajs/html"; | |
| 3 | 3 | import type { PropsWithChildren } from "@kitajs/html"; | |
| 4 | 4 | import { escapeHTML } from "bun"; | |
| 5 | - | import { fileTypeFromBuffer } from "file-type"; | |
| 6 | 5 | import hljs from "highlight.js"; | |
| 7 | 6 | import { config } from "./config"; | |
| 8 | 7 | import { humanFileSize, isValidUTF8 } from "./shared"; | |
| @@ -49,6 +48,15 @@ function ServerLimits() { | |||
|---|---|---|---|
| 49 | 48 | <li> | |
| 50 | 49 | <small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small> | |
| 51 | 50 | </li> | |
| 51 | + | {config.maxTotalBytes !== null ? ( | |
| 52 | + | <li> | |
| 53 | + | <small> | |
| 54 | + | Total storage capacity: {humanFileSize(config.maxTotalBytes)} | |
| 55 | + | </small> | |
| 56 | + | </li> | |
| 57 | + | ) : ( | |
| 58 | + | "" | |
| 59 | + | )} | |
| 52 | 60 | {config.maxAgeMinutes !== null ? ( | |
| 53 | 61 | <li> | |
| 54 | 62 | <small> | |
| @@ -63,7 +71,20 @@ function ServerLimits() { | |||
|---|---|---|---|
| 63 | 71 | <li> | |
| 64 | 72 | <small> | |
| 65 | 73 | 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 | |
| 67 | 88 | </small> | |
| 68 | 89 | </li> | |
| 69 | 90 | ) : ( | |
| @@ -88,7 +109,6 @@ export function Index(hostname: string) { | |||
|---|---|---|---|
| 88 | 109 | onsubmit="return onUploadSubmit()" | |
| 89 | 110 | > | |
| 90 | 111 | <input type="reset" value="Reset form" /> | |
| 91 | - | <input required={true} type="file" id="file" name="file" /> | |
| 92 | 112 | <label for="filename">File name override:</label> | |
| 93 | 113 | <small>Optional. If empty, will use the uploaded file name.</small> | |
| 94 | 114 | <input | |
| @@ -136,8 +156,8 @@ export function Index(hostname: string) { | |||
|---|---|---|---|
| 136 | 156 | <label for="encrypt-mode-server">Encryption mode:</label> | |
| 137 | 157 | <small> | |
| 138 | 158 | 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. | |
| 141 | 161 | </small> | |
| 142 | 162 | <div> | |
| 143 | 163 | <label> | |
| @@ -168,20 +188,30 @@ export function Index(hostname: string) { | |||
|---|---|---|---|
| 168 | 188 | If set, file is already encrypted with an algorithm like in the python | |
| 169 | 189 | file available below. Password is ignored in this case. | |
| 170 | 190 | </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" /> | |
| 171 | 196 | <input type="submit" value="Upload File" /> | |
| 172 | 197 | <hr /> | |
| 173 | 198 | <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> | |
| 177 | 207 | <pre> | |
| 178 | 208 | 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 | |
| 185 | 215 | </pre> | |
| 186 | 216 | <p> | |
| 187 | 217 | If you upload an already encrypted file, you should set the{" "} | |
| @@ -190,17 +220,16 @@ export function Index(hostname: string) { | |||
|---|---|---|---|
| 190 | 220 | python script to encrypt a file locally:{" "} | |
| 191 | 221 | <a href="/encrypt.py">encrypt.py</a> | |
| 192 | 222 | </p> | |
| 193 | - | <br /> | |
| 194 | - | Download: | |
| 223 | + | <p>Download:</p> | |
| 195 | 224 | <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` | |
| 199 | 227 | </pre> | |
| 200 | 228 | <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> | |
| 204 | 233 | </p> | |
| 205 | 234 | </form> | |
| 206 | 235 | <script src="/dist/client-index.js" /> | |
| @@ -237,59 +266,58 @@ export function humanReadableTime(minutes: number) { | |||
|---|---|---|---|
| 237 | 266 | return result.trim(); | |
| 238 | 267 | } | |
| 239 | 268 | ||
| 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, | |
| 244 | 274 | 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} />; | |
| 285 | 315 | } | |
| 286 | 316 | } | |
| 287 | 317 | ||
| 288 | 318 | return ( | |
| 289 | 319 | <Template css="/show.css"> | |
| 290 | - | {content ? ( | |
| 291 | - | "" | |
| 292 | - | ) : ( | |
| 320 | + | {awaiting ? ( | |
| 293 | 321 | <div id="decrypt-overlay"> | |
| 294 | 322 | <h1 safe>Encrypted file: {filename}</h1> | |
| 295 | 323 | <form | |
| @@ -301,12 +329,7 @@ export async function ShowFile( | |||
|---|---|---|---|
| 301 | 329 | <label id="password-label" for="password"> | |
| 302 | 330 | Enter a password to decrypt the file: | |
| 303 | 331 | </label> | |
| 304 | - | <input | |
| 305 | - | required | |
| 306 | - | type="password" | |
| 307 | - | id="password" | |
| 308 | - | name="password" | |
| 309 | - | /> | |
| 332 | + | <input required type="password" id="password" name="password" /> | |
| 310 | 333 | <div> | |
| 311 | 334 | <label> | |
| 312 | 335 | <input | |
| @@ -330,36 +353,36 @@ export async function ShowFile( | |||
|---|---|---|---|
| 330 | 353 | <input type="submit" value="Submit" /> | |
| 331 | 354 | </form> | |
| 332 | 355 | </div> | |
| 356 | + | ) : ( | |
| 357 | + | "" | |
| 333 | 358 | )} | |
| 334 | 359 | <div id="content"> | |
| 335 | 360 | <div id="filename" safe> | |
| 336 | 361 | {filename} | |
| 337 | 362 | </div> | |
| 338 | - | <div id="mediabox">{preview}</div> | |
| 363 | + | <div id="mediabox">{previewEl}</div> | |
| 339 | 364 | </div> | |
| 340 | 365 | <div id="sidebar"> | |
| 341 | 366 | <h3>File info</h3> | |
| 342 | 367 | <hr /> | |
| 343 | 368 | <p id="filesize"> | |
| 344 | - | size: {content ? humanFileSize(content.byteLength) : "unknown"} | |
| 369 | + | size: {size !== null ? humanFileSize(size) : "unknown"} | |
| 345 | 370 | </p> | |
| 346 | 371 | <p>declared type: {filetype}</p> | |
| 347 | - | {delete_at ? ( | |
| 372 | + | {deleteAt ? ( | |
| 348 | 373 | <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))} | |
| 353 | 376 | ) | |
| 354 | 377 | </p> | |
| 355 | 378 | ) : ( | |
| 356 | 379 | "" | |
| 357 | 380 | )} | |
| 358 | - | <form id="download-form" action={`/raw/${uuid}`} method="get"> | |
| 381 | + | <form id="download-form" action={rawUrl} method="get"> | |
| 359 | 382 | <button type="submit">Download</button> | |
| 360 | 383 | </form> | |
| 361 | 384 | </div> | |
| 362 | - | {content ? "" : <script src="/dist/client-show.js" />} | |
| 385 | + | {awaiting ? <script src="/dist/client-show.js" /> : ""} | |
| 363 | 386 | </Template> | |
| 364 | 387 | ); | |
| 365 | 388 | } | |
Msrc/config.ts
| @@ -2,13 +2,15 @@ | |||
|---|---|---|---|
| 2 | 2 | // Defaults are chosen to be secure-but-non-breaking; tighten them in production | |
| 3 | 3 | // (see README for the full list and the "run behind TLS" note). | |
| 4 | 4 | ||
| 5 | - | const env = process.env | |
| 5 | + | const env = process.env; | |
| 6 | 6 | ||
| 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). */ | |
| 8 | 10 | function intEnv(value: string | undefined, defaultValue: number): number { | |
| 9 | 11 | if (value === undefined || value === "") return defaultValue; | |
| 10 | 12 | const parsed = parseInt(value, 10); | |
| 11 | - | return Number.isFinite(parsed) ? parsed : defaultValue; | |
| 13 | + | return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue; | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 14 | 16 | /** Truthy only for "true"/"1"; anything else (incl. "false", "0", unset) is off. */ | |
| @@ -26,6 +28,15 @@ export const config = { | |||
|---|---|---|---|
| 26 | 28 | : null, | |
| 27 | 29 | // Minimum seconds between uploads from the same client IP. 0 = disabled. | |
| 28 | 30 | 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, | |
| 29 | 40 | // Set when running behind a trusted TLS-terminating reverse proxy (the usual | |
| 30 | 41 | // production setup). Enables reading X-Forwarded-For / X-Real-IP for the | |
| 31 | 42 | // client IP and adds the Secure flag to the password cookie. Leave off for | |
Msrc/crypto.test.ts
| @@ -1,7 +1,49 @@ | |||
|---|---|---|---|
| 1 | 1 | 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"; | |
| 3 | 9 | ||
| 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)", () => { | |
| 5 | 47 | test("round-trips content with the correct password", async () => { | |
| 6 | 48 | const data = new TextEncoder().encode("hello zbin 🔐 multi-byte"); | |
| 7 | 49 | const enc = await encrypt(data, "correct horse battery staple"); | |
| @@ -9,10 +51,30 @@ describe("crypto (AES-256-GCM)", () => { | |||
|---|---|---|---|
| 9 | 51 | expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte"); | |
| 10 | 52 | }); | |
| 11 | 53 | ||
| 12 | - | test("wire format is salt[16] | iv[12] | ciphertext+tag", async () => { | |
| 54 | + | test("wire format is salt[16] | prefix[7] | ct+tag", async () => { | |
| 13 | 55 | 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); | |
| 16 | 78 | }); | |
| 17 | 79 | ||
| 18 | 80 | test("rejects a wrong password (authenticated)", async () => { | |
| @@ -25,4 +87,61 @@ describe("crypto (AES-256-GCM)", () => { | |||
|---|---|---|---|
| 25 | 87 | enc[enc.length - 1] ^= 0xff; // flip a tag byte | |
| 26 | 88 | await expect(decrypt(enc, "pw")).rejects.toThrow(); | |
| 27 | 89 | }); | |
| 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 | + | }); | |
| 28 | 147 | }); | |
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; | |
| 7 | 33 | ||
| 8 | 34 | async function deriveKey( | |
| 9 | 35 | password: string, | |
| @@ -12,13 +38,13 @@ async function deriveKey( | |||
|---|---|---|---|
| 12 | 38 | ): Promise<CryptoKey> { | |
| 13 | 39 | const material = await crypto.subtle.importKey( | |
| 14 | 40 | "raw", | |
| 15 | - | new TextEncoder().encode(password), | |
| 41 | + | src(new TextEncoder().encode(password)), | |
| 16 | 42 | { name: "PBKDF2" }, | |
| 17 | 43 | false, | |
| 18 | 44 | ["deriveKey"], | |
| 19 | 45 | ); | |
| 20 | 46 | return crypto.subtle.deriveKey( | |
| 21 | - | { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" }, | |
| 47 | + | { name: "PBKDF2", salt: src(salt), iterations: 210000, hash: "SHA-512" }, | |
| 22 | 48 | material, | |
| 23 | 49 | { name: "AES-GCM", length: 256 }, | |
| 24 | 50 | false, | |
| @@ -26,32 +52,276 @@ async function deriveKey( | |||
|---|---|---|---|
| 26 | 52 | ); | |
| 27 | 53 | } | |
| 28 | 54 | ||
| 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 | + | ||
| 29 | 114 | export async function encrypt( | |
| 30 | 115 | content: Uint8Array, | |
| 31 | 116 | password: string, | |
| 32 | 117 | ): 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)); | |
| 35 | 120 | 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); | |
| 42 | 134 | } | |
| 43 | 135 | ||
| 44 | 136 | export async function decrypt( | |
| 45 | 137 | data: Uint8Array, | |
| 46 | 138 | password: string, | |
| 47 | 139 | ): 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); | |
| 50 | 145 | 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 }; | |
| 57 | 327 | } | |
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"; | |
| 1 | 5 | import { Database } from "bun:sqlite"; | |
| 2 | 6 | import cron from "@elysiajs/cron"; | |
| 3 | 7 | import { html } from "@elysiajs/html"; | |
| 4 | 8 | import staticPlugin from "@elysiajs/static"; | |
| 5 | - | import { randomUUIDv7 } from "bun"; | |
| 9 | + | import busboy from "busboy"; | |
| 6 | 10 | import { Elysia, StatusMap, t } from "elysia"; | |
| 7 | 11 | import { fileTypeFromBuffer } from "file-type"; | |
| 8 | 12 | import { | |
| @@ -11,26 +15,51 @@ import { | |||
|---|---|---|---|
| 11 | 15 | NotFound, | |
| 12 | 16 | SetCookie, | |
| 13 | 17 | ShowFile, | |
| 18 | + | textPreviewHtml, | |
| 14 | 19 | WrongPassword, | |
| 20 | + | type Preview, | |
| 15 | 21 | } from "./components"; | |
| 16 | 22 | 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 | |
| 18 | 32 | ||
| 19 | 33 | const db = new Database("./db/db.sqlite"); | |
| 20 | 34 | db.run("PRAGMA foreign_keys = ON"); | |
| 21 | 35 | 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. | |
| 22 | 40 | 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", | |
| 24 | 42 | ); | |
| 25 | 43 | db.run("PRAGMA optimize"); | |
| 26 | 44 | ||
| 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 | + | ||
| 27 | 53 | // 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. | |
| 29 | 56 | const UUID_PATTERN = "^[0-9a-fA-F-]{36}$"; | |
| 30 | 57 | ||
| 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. | |
| 33 | 61 | const lastUpload = new Map<string, number>(); | |
| 62 | + | const lastDecrypt = new Map<string, number>(); | |
| 34 | 63 | ||
| 35 | 64 | type MinimalServer = { | |
| 36 | 65 | requestIP(req: Request): { address: string } | null; | |
| @@ -50,13 +79,34 @@ function clientIp( | |||
|---|---|---|---|
| 50 | 79 | return server?.requestIP(request)?.address ?? "unknown"; | |
| 51 | 80 | } | |
| 52 | 81 | ||
| 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; | |
| 60 | 110 | } | |
| 61 | 111 | ||
| 62 | 112 | const app = new Elysia({ | |
| @@ -70,21 +120,58 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 70 | 120 | cron({ | |
| 71 | 121 | name: "delete", | |
| 72 | 122 | 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(); | |
| 75 | 146 | if (config.uploadCooldownSeconds > 0) { | |
| 76 | - | const cutoff = Date.now() - config.uploadCooldownSeconds * 1000; | |
| 147 | + | const cutoff = now - config.uploadCooldownSeconds * 1000; | |
| 77 | 148 | for (const [ip, ts] of lastUpload) { | |
| 78 | 149 | if (ts < cutoff) lastUpload.delete(ip); | |
| 79 | 150 | } | |
| 80 | 151 | } | |
| 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"); | |
| 81 | 168 | }, | |
| 82 | 169 | }), | |
| 83 | 170 | ) | |
| 84 | 171 | .get("/", ({ server }) => Index(server?.url.toString() ?? "")) | |
| 85 | 172 | .post( | |
| 86 | 173 | "/upload", | |
| 87 | - | async ({ set, body, server, request, headers }) => { | |
| 174 | + | async ({ set, server, request, headers }) => { | |
| 88 | 175 | const ip = clientIp(server, request, headers); | |
| 89 | 176 | const now = Date.now(); | |
| 90 | 177 | if (config.uploadCooldownSeconds > 0) { | |
| @@ -94,98 +181,290 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 94 | 181 | return "Upload cooldown active, please wait before uploading again"; | |
| 95 | 182 | } | |
| 96 | 183 | } | |
| 97 | - | if (body.file.size > config.maxUploadBytes) { | |
| 98 | - | set.status = 413; // Payload Too Large | |
| 99 | - | return `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`; | |
| 100 | - | } | |
| 101 | 184 | ||
| 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"; | |
| 114 | 189 | } | |
| 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; | |
| 123 | 406 | } | |
| 124 | - | db.exec( | |
| 125 | - | "INSERT INTO files (uuid, filename, content, filetype, encrypted, delete_at) VALUES (?, ?, ?, ?, ?, ?)", | |
| 126 | - | [ | |
| 127 | - | uuid, | |
| 128 | - | body.filename || body.file.name, | |
| 129 | - | content, | |
| 130 | - | body.filetype, | |
| 131 | - | encrypted, | |
| 132 | - | delete_at, | |
| 133 | - | ], | |
| 134 | - | ); | |
| 135 | - | if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now); | |
| 136 | - | set.status = StatusMap["See Other"]; | |
| 137 | - | set.headers.location = `/show/${uuid}`; | |
| 138 | - | return `Created with id: ${uuid}`; | |
| 139 | 407 | }, | |
| 140 | 408 | { | |
| 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", | |
| 154 | 412 | }, | |
| 155 | 413 | ) | |
| 156 | 414 | .get( | |
| 157 | 415 | "/show/:uuid", | |
| 158 | - | async ({ set, params, cookie }) => { | |
| 159 | - | const result = | |
| 416 | + | async ({ set, params, cookie, server, request, headers }) => { | |
| 417 | + | const row = | |
| 160 | 418 | (db | |
| 161 | 419 | .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 = ?", | |
| 163 | 421 | ) | |
| 164 | 422 | .get(params.uuid) as { | |
| 165 | 423 | filename: string; | |
| 166 | - | content: Uint8Array; | |
| 167 | 424 | filetype: string; | |
| 168 | 425 | encrypted: number; | |
| 426 | + | size: number; | |
| 427 | + | media_mime: string | null; | |
| 169 | 428 | delete_at: number | null; | |
| 170 | 429 | }) || null; | |
| 171 | - | if (!result) { | |
| 430 | + | if (!row) { | |
| 172 | 431 | set.status = StatusMap["Not Found"]; | |
| 173 | 432 | return NotFound(); | |
| 174 | 433 | } | |
| 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) { | |
| 176 | 440 | const password = cookie.password.value; | |
| 177 | 441 | 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" }; | |
| 185 | 451 | } 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 | + | } | |
| 186 | 458 | 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" }; | |
| 188 | 466 | } catch (_e) { | |
| 467 | + | recordDecrypt(ip); | |
| 189 | 468 | const secure = config.behindProxy ? "; Secure" : ""; | |
| 190 | 469 | set.status = StatusMap.Forbidden; | |
| 191 | 470 | set.headers["set-cookie"] = [ | |
| @@ -195,14 +474,32 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 195 | 474 | return WrongPassword(); | |
| 196 | 475 | } | |
| 197 | 476 | } | |
| 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" }; | |
| 198 | 493 | } | |
| 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 | + | }); | |
| 206 | 503 | }, | |
| 207 | 504 | { | |
| 208 | 505 | params: t.Object({ | |
| @@ -237,35 +534,64 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 237 | 534 | ) | |
| 238 | 535 | .get( | |
| 239 | 536 | "/raw/:uuid", | |
| 240 | - | async ({ set, params, cookie, query }) => { | |
| 241 | - | const result = | |
| 537 | + | async ({ set, params, cookie, query, server, request, headers }) => { | |
| 538 | + | const row = | |
| 242 | 539 | (db | |
| 243 | 540 | .prepare( | |
| 244 | - | "SELECT content, filename, encrypted, filetype FROM files WHERE uuid = ?", | |
| 541 | + | "SELECT filename, encrypted, filetype, size FROM files WHERE uuid = ?", | |
| 245 | 542 | ) | |
| 246 | 543 | .get(params.uuid) as { | |
| 247 | - | content: Uint8Array; | |
| 248 | 544 | filename: string; | |
| 249 | 545 | encrypted: number; | |
| 250 | 546 | filetype: string; | |
| 547 | + | size: number; | |
| 251 | 548 | }) || null; | |
| 252 | - | if (!result) { | |
| 549 | + | if (!row) { | |
| 253 | 550 | set.status = StatusMap["Not Found"]; | |
| 254 | 551 | return "File not found"; | |
| 255 | 552 | } | |
| 553 | + | ||
| 554 | + | const path = blobPath(params.uuid); | |
| 256 | 555 | 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) { | |
| 260 | 564 | set.status = StatusMap.Unauthorized; | |
| 261 | 565 | return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it'; | |
| 262 | 566 | } | |
| 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 | + | } | |
| 263 | 572 | 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); | |
| 265 | 581 | } catch (_e) { | |
| 582 | + | recordDecrypt(ip); | |
| 266 | 583 | set.status = StatusMap.Forbidden; | |
| 267 | 584 | return "Incorrect password"; | |
| 268 | 585 | } | |
| 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 | + | } | |
| 269 | 595 | } | |
| 270 | 596 | ||
| 271 | 597 | // Never let the browser sniff stored content into an executable type | |
| @@ -274,8 +600,8 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 274 | 600 | // else (incl. still-encrypted bytes) is an octet-stream attachment. | |
| 275 | 601 | let mime = "application/octet-stream"; | |
| 276 | 602 | let disposition = "attachment"; | |
| 277 | - | if (!servingEncrypted) { | |
| 278 | - | const detected = await fileTypeFromBuffer(result.content); | |
| 603 | + | if (sniff) { | |
| 604 | + | const detected = await fileTypeFromBuffer(sniff); | |
| 279 | 605 | if ( | |
| 280 | 606 | detected && | |
| 281 | 607 | detected.mime !== "image/svg+xml" && | |
| @@ -288,15 +614,15 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 288 | 614 | } | |
| 289 | 615 | } | |
| 290 | 616 | ||
| 291 | - | const safeName = encodeURIComponent(result.filename); | |
| 617 | + | const safeName = encodeURIComponent(row.filename); | |
| 292 | 618 | set.headers["x-content-type-options"] = "nosniff"; | |
| 293 | 619 | 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; | |
| 296 | 622 | set.headers.filename = safeName; | |
| 297 | 623 | set.headers["content-disposition"] = | |
| 298 | 624 | `${disposition}; filename*=UTF-8''${safeName}`; | |
| 299 | - | return result.content; | |
| 625 | + | return body; | |
| 300 | 626 | }, | |
| 301 | 627 | { | |
| 302 | 628 | params: t.Object({ | |
| @@ -308,6 +634,46 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 308 | 634 | ) | |
| 309 | 635 | .listen(3000); | |
| 310 | 636 | ||
| 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 | + | ||
| 311 | 677 | console.log( | |
| 312 | 678 | `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`, | |
| 313 | 679 | ); | |