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):
| Variable | Default | Description |
| --- | --- | --- |
| `MAX_UPLOAD_BYTES` | `104857600` (100 MiB) | Hard cap on a single upload. |
| `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`). |
| `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). |
| `UPLOAD_COOLDOWN_SECONDS` | `0` (off) | Minimum seconds between uploads from the same client IP. |
| `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. |
| `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. |
> Passwords for server-side decryption travel in a cookie, so ZBin should always be
▾Aassets/decrypt.py
@@ -0,0 +1,72 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
import sys
CHUNK = 64 * 1024 # plaintext bytes per chunk (must match src/crypto.ts)
TAG = 16
ENC_CHUNK = CHUNK + TAG # ciphertext bytes for a full chunk
HEADER = 16 + 7 # salt[16] | noncePrefix[7]
def decrypt(data: bytes, password: str) -> bytes:
"""
Decrypts content produced by encrypt.py / the server / browser.
Uses chunked AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
implementation in src/crypto.ts.
Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
where encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
and nonce_i = noncePrefix[7] || uint32_be(i) || flag, flag=1 on the final
chunk else 0. Each non-final plaintext chunk is exactly CHUNK bytes, so chunk
boundaries (and which chunk is last) follow from the total length.
Args:
data (bytes): The encrypted wire format.
password (str): The password used for encryption.
Returns:
bytes: the decrypted content
Raises:
Exception: if the password is wrong or the data is corrupt/truncated (the
AES-GCM tag, chunk counter, and final-chunk flag are all authenticated).
"""
if len(data) < HEADER + TAG:
raise ValueError("ciphertext too short")
salt = data[:16]
prefix = data[16:HEADER]
body = data[HEADER:]
kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=salt,
iterations=210000,
backend=default_backend()
)
aes = AESGCM(kdf.derive(password.encode()))
out = bytearray()
offset = 0
i = 0
while offset < len(body):
enc_len = min(ENC_CHUNK, len(body) - offset)
last = 1 if offset + enc_len >= len(body) else 0
nonce = prefix + i.to_bytes(4, "big") + bytes([last])
out += aes.decrypt(nonce, body[offset:offset + enc_len], None)
offset += enc_len
i += 1
return bytes(out)
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: decrypt.py <file> <password> <outfile>")
sys.exit(1)
data = decrypt(open(sys.argv[1], "rb").read(), sys.argv[2])
open(sys.argv[3], "wb").write(data)
▾Massets/default.css
@@ -84,6 +84,13 @@ img {
input[type="reset"] {
max-width: 25rem;
align-self: center;
order: -2;
}
/* The file input lives last in the DOM (so the multipart upload sends the other
fields first) but belongs right below the reset button visually. */
#file {
order: -1;
}
dialog {
▾Massets/encrypt.py
@@ -5,23 +5,31 @@ from cryptography.hazmat.backends import default_backend
import os
import sys
CHUNK = 64 * 1024 # plaintext bytes per chunk (must match src/crypto.ts)
def encrypt(content: bytes, password: str) -> bytes:
"""
Encrypts the given content using the provided password.
Uses AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
Uses chunked AES-256-GCM with a PBKDF2-HMAC-SHA512 derived key, matching the
server/browser implementation in src/crypto.ts.
Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
where encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
and nonce_i = noncePrefix[7] || uint32_be(i) || flag, flag=1 on the final
chunk else 0. Each non-final plaintext chunk is exactly CHUNK bytes.
Args:
content (bytes): The content to be encrypted.
password (str): The password used for encryption.
Returns:
bytes: salt[16] | iv[12] | ciphertext+tag
bytes: the wire format described above
"""
# Generate a random initialization vector (IV) and salt
iv = os.urandom(12)
# Generate a random nonce prefix and salt
prefix = os.urandom(7)
salt = os.urandom(16)
# Derive a key from the password using PBKDF2
@@ -32,13 +40,20 @@ def encrypt(content: bytes, password: str) -> bytes:
iterations=210000,
backend=default_backend()
)
key = kdf.derive(password.encode())
aes = AESGCM(kdf.derive(password.encode()))
# Always emit at least one (possibly empty) final chunk so empty input still
# round-trips and the final-chunk flag is always present.
chunks = max(1, -(-len(content) // CHUNK)) # ceil division
out = bytearray(salt + prefix)
for i in range(chunks):
piece = content[i * CHUNK:(i + 1) * CHUNK]
last = 1 if i == chunks - 1 else 0
nonce = prefix + i.to_bytes(4, "big") + bytes([last])
out += aes.encrypt(nonce, piece, None)
return bytes(out)
# Encrypt with AES-256-GCM (the authentication tag is appended to the ciphertext)
encrypted_content = AESGCM(key).encrypt(iv, content, None)
# Return the salt, IV, and encrypted content concatenated
return salt + iv + encrypted_content
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: encrypt.py <file> <password> <outfile>")
▾Mbun.lock
@@ -5,10 +5,11 @@
"": {
"name": "zbin",
"dependencies": {
"@chneau/elysia-compression": "^1.0.11",
"@elysiajs/cron": "^1.2.0",
"@elysiajs/html": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"@types/busboy": "^1.5.4",
"busboy": "^1.6.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1",
@@ -40,8 +41,6 @@
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@chneau/elysia-compression": ["@chneau/elysia-compression@1.0.11", "", { "dependencies": { "elysia": "^1.1.9" } }, "sha512-J4wfz5Qs68p/DNvkg5DXxo8sxJKSibzaUBP4W56uvqgs9NtHG0LC6tnyv7sAPEjNKzh21l6kinMoXbtVDp/KSQ=="],
"@elysiajs/cron": ["@elysiajs/cron@1.4.2", "", { "dependencies": { "croner": "^6.0.3" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-osQrgInKlW0m0/NUuBUX7+O9CFnx5NnG3NszeCaokTChaCVYciv/S4mmXzZZ8+Zg9sI8BmEQbV+L19wKStXYZw=="],
"@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 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -66,6 +67,8 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"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 @@
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"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=="],
"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:
- 3000:3000
environment:
# See README for details. All optional.
# MAX_UPLOAD_BYTES: "104857600" # 100 MiB
# MAX_AGE_MINUTES: "10080" # auto-delete after 7 days
# UPLOAD_COOLDOWN_SECONDS: "10" # per-IP upload cooldown
# BEHIND_PROXY: "true" # trust forwarded headers + Secure cookies (TLS reverse proxy)
# MAX_UPLOAD_BYTES: "104857600" # 100 MiB
# MAX_TOTAL_BYTES: "10737418240" # cap total stored content at 10 GiB
# MAX_AGE_MINUTES: "10080" # auto-delete after 7 days
# UPLOAD_COOLDOWN_SECONDS: "10" # per-IP upload cooldown
# DECRYPT_COOLDOWN_SECONDS: "2" # per-IP server-side decryption cooldown
# BEHIND_PROXY: "true" # trust forwarded headers + Secure cookies (TLS reverse proxy)
▾Mpackage.json
@@ -7,19 +7,21 @@
"prod": "bun run build && bun run src/index.ts",
"format": "biome format --write",
"lint": "biome lint",
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@chneau/elysia-compression": "^1.0.11",
"@elysiajs/cron": "^1.2.0",
"@elysiajs/html": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"busboy": "^1.6.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/busboy": "^1.5.4",
"bun-types": "latest"
},
"module": "src/index.js"
▾Msrc/client-index.ts
@@ -49,7 +49,7 @@ async function uploadFile() {
const content = await file.arrayBuffer();
const encryptedContent = await encrypt(new Uint8Array(content), password);
const myFile = new File([encryptedContent], file.name);
const myFile = new File([encryptedContent as BlobPart], file.name);
const dataTransfer = new DataTransfer();
dataTransfer.items.add(myFile);
▾Msrc/client-show.ts
@@ -29,7 +29,7 @@ async function showContent(content: Uint8Array, filetype: string) {
} else {
const filetype = await fileTypeFromBuffer(content);
if (filetype) {
const blob = new Blob([content], { type: filetype.mime });
const blob = new Blob([content as BlobPart], { type: filetype.mime });
const url = URL.createObjectURL(blob);
if (filetype.mime.startsWith("audio/")) {
preview = document.createElement("audio");
@@ -108,7 +108,7 @@ async function decryptClientSide(
downloadForm?.setAttribute("action", "");
downloadForm?.addEventListener("submit", (e) => {
e.preventDefault();
const blob = new Blob([content]);
const blob = new Blob([content as BlobPart]);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = filename;
▾Msrc/components.test.ts
@@ -3,22 +3,30 @@ import { humanReadableTime, ShowFile } from "./components";
const UUID = "00000000-0000-0000-0000-000000000000";
test("escapes filename in the file view (no stored XSS)", async () => {
test("escapes filename in the file view (no stored XSS)", () => {
const evil = "<img src=x onerror=alert(1)>";
const html = await ShowFile(
evil,
UUID,
new TextEncoder().encode("hi"),
"none",
null,
);
const html = ShowFile({
filename: evil,
uuid: UUID,
filetype: "none",
deleteAt: null,
size: 2,
preview: { kind: "text", html: "hi" },
});
expect(html).not.toContain(evil);
expect(html).toContain("&lt;img");
});
test("escapes filename in the encrypted overlay (no stored XSS)", async () => {
test("escapes filename in the encrypted overlay (no stored XSS)", () => {
const evil = "<script>alert(1)</script>";
const html = await ShowFile(evil, UUID, null, "none", null);
const html = ShowFile({
filename: evil,
uuid: UUID,
filetype: "none",
deleteAt: null,
size: null,
preview: { kind: "await" },
});
expect(html).not.toContain(evil);
expect(html).toContain("&lt;script&gt;");
});
▾Msrc/components.tsx
@@ -2,7 +2,6 @@
import { Html } from "@elysiajs/html";
import type { PropsWithChildren } from "@kitajs/html";
import { escapeHTML } from "bun";
import { fileTypeFromBuffer } from "file-type";
import hljs from "highlight.js";
import { config } from "./config";
import { humanFileSize, isValidUTF8 } from "./shared";
@@ -49,6 +48,15 @@ function ServerLimits() {
<li>
<small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
</li>
{config.maxTotalBytes !== null ? (
<li>
<small>
Total storage capacity: {humanFileSize(config.maxTotalBytes)}
</small>
</li>
) : (
""
)}
{config.maxAgeMinutes !== null ? (
<li>
<small>
@@ -63,7 +71,20 @@ function ServerLimits() {
<li>
<small>
You can upload once every {config.uploadCooldownSeconds} second
{config.uploadCooldownSeconds > 1 ? "s" : ""} from the same address
{config.uploadCooldownSeconds > 1 ? "s" : ""} from the same
address
</small>
</li>
) : (
""
)}
{config.decryptCooldownSeconds > 0 ? (
<li>
<small>
Server-side decryption is limited to once every{" "}
{config.decryptCooldownSeconds} second
{config.decryptCooldownSeconds > 1 ? "s" : ""} from the same
address
</small>
</li>
) : (
@@ -88,7 +109,6 @@ export function Index(hostname: string) {
onsubmit="return onUploadSubmit()"
>
<input type="reset" value="Reset form" />
<input required={true} type="file" id="file" name="file" />
<label for="filename">File name override:</label>
<small>Optional. If empty, will use the uploaded file name.</small>
<input
@@ -136,8 +156,8 @@ export function Index(hostname: string) {
<label for="encrypt-mode-server">Encryption mode:</label>
<small>
Only relevant when a password is set. Client-side encrypts in your
browser so the password never reaches the server (requires JavaScript).
Server-side encrypts on upload.
browser so the password never reaches the server (requires
JavaScript). Server-side encrypts on upload.
</small>
<div>
<label>
@@ -168,20 +188,30 @@ export function Index(hostname: string) {
If set, file is already encrypted with an algorithm like in the python
file available below. Password is ignored in this case.
</small>
{/* The file input must stay LAST in the DOM so the multipart upload
sends the other fields before the file bytes — the server needs the
password/filetype to encrypt on the fly as the stream arrives. It is
moved back up to its usual spot visually with `order` in default.css. */}
<input required={true} type="file" id="file" name="file" />
<input type="submit" value="Upload File" />
<hr />
<h3>curl guide</h3>
You can use curl to upload and download files
<br />
Upload:
<p>You can use curl to upload and download files</p>
<p>
Upload (
<b>
<code>-F file=…</code> must come last
</b>
):
</p>
<pre>
curl {hostname}upload \{"\n"}
-F file=@/path/to/file \{"\n"}
-F filetype="plaintext" \{"\n"}# optional{"\n"}
-F filename="file name" \{"\n"}# optional{"\n"}
-F delete_in_minutes="60" \{"\n"}# optional{"\n"}
-F password="mypassword" \{"\n"}# optional{"\n"}
-F encrypted="on"
-F filetype="plaintext" `# optional` \{"\n"}
-F filename="file name" `# optional` \{"\n"}
-F delete_in_minutes="60" `# optional` \{"\n"}
-F password="mypassword" `# optional` \{"\n"}
-F encrypted="on" `# optional` \{"\n"}
-F file=@/path/to/file
</pre>
<p>
If you upload an already encrypted file, you should set the{" "}
@@ -190,17 +220,16 @@ export function Index(hostname: string) {
python script to encrypt a file locally:{" "}
<a href="/encrypt.py">encrypt.py</a>
</p>
<br />
Download:
<p>Download:</p>
<pre>
curl {hostname}raw/$uuid?ignore_password=true/false \{"\n"}# optional,
if encrypted with password{"\n"}
--cookie "password=mypassword"
curl {hostname}raw/$uuid \{"\n"}
--cookie "password=mypassword" `# optional, only if encrypted`
</pre>
<p>
If ignore_password is set to true, then encrypted files can be
downloaded directly (in encrypted form) without supplying the
password.
Append <code>?ignore_password=true</code> to the URL to download an
encrypted file in its still-encrypted form, without supplying the
password. You can then decrypt it locally with this python script:{" "}
<a href="/decrypt.py">decrypt.py</a>
</p>
</form>
<script src="/dist/client-index.js" />
@@ -237,59 +266,58 @@ export function humanReadableTime(minutes: number) {
return result.trim();
}
export async function ShowFile(
filename: string,
uuid: string,
content: Uint8Array | null,
// Renders content as escaped/highlighted HTML for a text preview, or returns
// null if it isn't previewable text (binary, or the "blob" override). The
// returned string is already HTML-safe and is injected raw into a <pre>.
export function textPreviewHtml(
content: Uint8Array,
filetype: string,
delete_at: number | null,
) {
//if content is null, this is for the js frontend
let preview: JSX.Element;
if (!content) {
preview = <>Please wait for the file to load</>;
} else {
preview = <>This file can't be previewed</>;
if (isValidUTF8(content) && filetype !== "blob") {
if (filetype === "none") {
preview = (
<pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre>
);
} else {
preview = (
<pre>
{
hljs.highlight(new TextDecoder("utf-8").decode(content), {
language: filetype,
}).value
}
</pre>
);
}
} else {
// Point media previews at /raw/:uuid rather than inlining the whole
// file as a base64 data URI (which would balloon the HTML and server
// memory for large files). /raw serves a safe content-type and, for
// encrypted files, decrypts using the path-scoped password cookie.
const detected = await fileTypeFromBuffer(content);
const rawUrl = `/raw/${uuid}`;
if (detected) {
if (detected.mime.startsWith("audio/")) {
preview = <audio controls="" src={rawUrl} />;
} else if (detected.mime.startsWith("video/")) {
preview = <video controls src={rawUrl} />;
} else if (detected.mime.startsWith("image/")) {
preview = <img src={rawUrl} alt={filename} />;
}
}
): string | null {
if (filetype === "blob" || !isValidUTF8(content)) return null;
const text = new TextDecoder("utf-8").decode(content);
if (filetype === "none") return escapeHTML(text);
return hljs.highlight(text, { language: filetype }).value;
}
// What the /show page should render in the preview area. Computed server-side
// in index.ts so that ShowFile never has to decrypt or read content itself.
export type Preview =
| { kind: "await" } // encrypted, needs client-side decryption (overlay + JS)
| { kind: "text"; html: string } // pre-rendered (escaped/highlighted) text
| { kind: "media"; mime: string } // <img>/<audio>/<video> pointing at /raw
| { kind: "none" }; // not previewable
export function ShowFile(opts: {
filename: string;
uuid: string;
filetype: string;
deleteAt: number | null;
size: number | null;
preview: Preview;
}) {
const { filename, uuid, filetype, deleteAt, size, preview } = opts;
const awaiting = preview.kind === "await";
const rawUrl = `/raw/${uuid}`;
let previewEl: JSX.Element = <>This file can't be previewed</>;
if (preview.kind === "await") {
previewEl = <>Please wait for the file to load</>;
} else if (preview.kind === "text") {
// Already escaped/highlighted by textPreviewHtml, injected raw.
previewEl = <pre>{preview.html}</pre>;
} else if (preview.kind === "media") {
if (preview.mime.startsWith("audio/")) {
previewEl = <audio controls="" src={rawUrl} />;
} else if (preview.mime.startsWith("video/")) {
previewEl = <video controls src={rawUrl} />;
} else if (preview.mime.startsWith("image/")) {
previewEl = <img src={rawUrl} alt={filename} />;
}
}
return (
<Template css="/show.css">
{content ? (
""
) : (
{awaiting ? (
<div id="decrypt-overlay">
<h1 safe>Encrypted file: {filename}</h1>
<form
@@ -301,12 +329,7 @@ export async function ShowFile(
<label id="password-label" for="password">
Enter a password to decrypt the file:
</label>
<input
required
type="password"
id="password"
name="password"
/>
<input required type="password" id="password" name="password" />
<div>
<label>
<input
@@ -330,36 +353,36 @@ export async function ShowFile(
<input type="submit" value="Submit" />
</form>
</div>
) : (
""
)}
<div id="content">
<div id="filename" safe>
{filename}
</div>
<div id="mediabox">{preview}</div>
<div id="mediabox">{previewEl}</div>
</div>
<div id="sidebar">
<h3>File info</h3>
<hr />
<p id="filesize">
size: {content ? humanFileSize(content.byteLength) : "unknown"}
size: {size !== null ? humanFileSize(size) : "unknown"}
</p>
<p>declared type: {filetype}</p>
{delete_at ? (
{deleteAt ? (
<p>
delete at: {new Date(delete_at * 1000).toISOString()} (in{" "}
{humanReadableTime(
Math.floor((delete_at - Date.now() / 1000) / 60),
)}
delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
{humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
)
</p>
) : (
""
)}
<form id="download-form" action={`/raw/${uuid}`} method="get">
<form id="download-form" action={rawUrl} method="get">
<button type="submit">Download</button>
</form>
</div>
{content ? "" : <script src="/dist/client-show.js" />}
{awaiting ? <script src="/dist/client-show.js" /> : ""}
</Template>
);
}
▾Msrc/config.ts
@@ -2,13 +2,15 @@
// Defaults are chosen to be secure-but-non-breaking; tighten them in production
// (see README for the full list and the "run behind TLS" note).
const env = process.env
const env = process.env;
/** parseInt with a default that distinguishes "unset" from "explicit 0". */
/** parseInt with a default that distinguishes "unset" from "explicit 0". All
* settings here are non-negative quantities, so a negative value falls back to
* the default rather than being passed through (e.g. busboy fileSize: -5). */
function intEnv(value: string | undefined, defaultValue: number): number {
if (value === undefined || value === "") return defaultValue;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
}
/** Truthy only for "true"/"1"; anything else (incl. "false", "0", unset) is off. */
@@ -26,6 +28,15 @@ export const config = {
: null,
// Minimum seconds between uploads from the same client IP. 0 = disabled.
uploadCooldownSeconds: intEnv(Bun.env.UPLOAD_COOLDOWN_SECONDS, 0),
// Minimum seconds between server-side decryption attempts from the same client
// IP. 0 = disabled. Bounds the PBKDF2 CPU cost an attacker who knows a UUID can
// force by hammering /show or /raw with password cookies.
decryptCooldownSeconds: intEnv(Bun.env.DECRYPT_COOLDOWN_SECONDS, 0),
// Cap on total stored content bytes across all files. null = unlimited; when
// set, an upload that would push the total over the cap is rejected (507).
maxTotalBytes: Bun.env.MAX_TOTAL_BYTES
? intEnv(Bun.env.MAX_TOTAL_BYTES, 0) || null
: null,
// Set when running behind a trusted TLS-terminating reverse proxy (the usual
// production setup). Enables reading X-Forwarded-For / X-Real-IP for the
// client IP and adds the Secure flag to the password cookie. Leave off for
▾Msrc/crypto.test.ts
@@ -1,7 +1,49 @@
import { describe, expect, test } from "bun:test";
import { decrypt, encrypt } from "./crypto";
import {
CHUNK,
decrypt,
decryptToStream,
encrypt,
encryptStream,
} from "./crypto";
describe("crypto (AES-256-GCM)", () => {
function streamOf(data: Uint8Array, pieces = 1): ReadableStream<Uint8Array> {
const size = Math.ceil(data.length / pieces) || 1;
let off = 0;
return new ReadableStream<Uint8Array>({
pull(c) {
if (off >= data.length) {
c.close();
return;
}
c.enqueue(data.subarray(off, off + size));
off += size;
},
});
}
async function collect(
stream: ReadableStream<Uint8Array>,
): Promise<Uint8Array> {
const parts: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
}
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
describe("crypto (chunked AES-256-GCM)", () => {
test("round-trips content with the correct password", async () => {
const data = new TextEncoder().encode("hello zbin 🔐 multi-byte");
const enc = await encrypt(data, "correct horse battery staple");
@@ -9,10 +51,30 @@ describe("crypto (AES-256-GCM)", () => {
expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte");
});
test("wire format is salt[16] | iv[12] | ciphertext+tag", async () => {
test("wire format is salt[16] | prefix[7] | ct+tag", async () => {
const enc = await encrypt(new Uint8Array([1, 2, 3]), "pw");
// 16 (salt) + 12 (iv) + 3 (plaintext) + 16 (GCM tag)
expect(enc.length).toBe(16 + 12 + 3 + 16);
// 16 (salt) + 7 (prefix) + 3 (plaintext) + 16 (GCM tag)
expect(enc.length).toBe(16 + 7 + 3 + 16);
});
test("round-trips empty input", async () => {
const enc = await encrypt(new Uint8Array(0), "pw");
expect(enc.length).toBe(16 + 7 + 16); // single empty final chunk (tag only)
const dec = await decrypt(enc, "pw");
expect(dec.length).toBe(0);
});
test("round-trips multi-chunk content (> CHUNK)", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 1234));
const enc = await encrypt(data, "pw");
const dec = await decrypt(enc, "pw");
expect(dec).toEqual(data);
});
test("round-trips content of exactly CHUNK bytes", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK));
const dec = await decrypt(await encrypt(data, "pw"), "pw");
expect(dec).toEqual(data);
});
test("rejects a wrong password (authenticated)", async () => {
@@ -25,4 +87,61 @@ describe("crypto (AES-256-GCM)", () => {
enc[enc.length - 1] ^= 0xff; // flip a tag byte
await expect(decrypt(enc, "pw")).rejects.toThrow();
});
test("rejects truncation (dropping the final chunk)", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2));
const enc = await encrypt(data, "pw");
// Drop the final (full) chunk; the now-last chunk was sealed with flag=0
// but will be opened with flag=1, so authentication must fail.
const truncated = enc.subarray(0, enc.length - (CHUNK + 16));
await expect(decrypt(truncated, "pw")).rejects.toThrow();
});
});
describe("crypto streaming", () => {
test("encryptStream output decrypts via one-shot decrypt", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK * 3 + 7));
const enc = await collect(await encryptStream(streamOf(data, 5), "pw"));
expect(await decrypt(enc, "pw")).toEqual(data);
});
test("encryptStream invokes onHead with leading plaintext", async () => {
const data = new TextEncoder().encode("GIF89a-ish header then more data");
let head: Uint8Array | undefined;
await collect(
await encryptStream(streamOf(data, 3), "pw", (h) => {
head = h;
}),
);
expect(head).toBeDefined();
expect(new TextDecoder().decode((head as Uint8Array).subarray(0, 6))).toBe(
"GIF89a",
);
});
test("decryptToStream round-trips and exposes the first chunk", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK + 500));
const enc = await encrypt(data, "pw");
const { firstChunk, body } = await decryptToStream(
streamOf(enc, 4),
"pw",
enc.length,
);
expect(firstChunk).toEqual(data.subarray(0, CHUNK));
expect(await collect(body)).toEqual(data);
});
test("decryptToStream rejects a wrong password", async () => {
const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
await expect(
decryptToStream(streamOf(enc), "wrong", enc.length),
).rejects.toThrow();
});
test("stream encrypt -> stream decrypt round-trips", async () => {
const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 42));
const enc = await collect(await encryptStream(streamOf(data, 7), "pw"));
const { body } = await decryptToStream(streamOf(enc, 3), "pw", enc.length);
expect(await collect(body)).toEqual(data);
});
});
▾Msrc/crypto.ts
@@ -1,9 +1,35 @@
// AES-256-GCM (authenticated) encryption with a PBKDF2-derived key, using the
// WebCrypto API (crypto.subtle) which is available both in Bun (server) and the
// browser (client), so encryption/decryption is defined once for both sides.
// GCM gives us integrity/authentication for free (the tag is appended to the
// ciphertext by WebCrypto), so tampering and padding-oracle attacks don't apply.
// Wire format: salt[16] | iv[12] | ciphertext+tag.
// Chunked, streaming AES-256-GCM (authenticated) encryption with a
// PBKDF2-derived key, using WebCrypto (crypto.subtle) so the exact same format
// works in Bun (server) and the browser (client), and is matched byte-for-byte
// by assets/encrypt.py.
//
// The content is split into fixed-size plaintext chunks, each sealed
// independently with AES-GCM. This lets the server encrypt on upload and
// decrypt on download *while streaming* (chunk by chunk) instead of holding the
// whole file in memory. The key is still derived only once per file, so the
// expensive PBKDF2 cost is paid once regardless of size.
//
// Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
// encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
// nonce_i(12) = noncePrefix[7] || uint32_be(i) || flag (flag=1 on the final
// chunk, else 0)
// The per-chunk counter prevents reordering and the final-chunk flag prevents
// truncation: dropping or rearranging chunks fails authentication. Each
// non-final plaintext chunk is exactly CHUNK bytes, so the decryptor can derive
// chunk boundaries (and which chunk is last) from the total length alone —
// no per-chunk length prefixes are stored.
const SALT_LEN = 16;
const PREFIX_LEN = 7;
const TAG_LEN = 16;
const HEADER_LEN = SALT_LEN + PREFIX_LEN; // bytes before the first chunk
export const CHUNK = 64 * 1024; // plaintext bytes per chunk
const ENC_CHUNK = CHUNK + TAG_LEN; // ciphertext bytes for a full chunk
// `crypto.subtle` wants a BufferSource; Bun's lib types are stricter about
// ArrayBuffer vs SharedArrayBuffer than the values we actually pass, so cast at
// the boundary rather than littering call sites with copies.
const src = (b: Uint8Array): BufferSource => b as unknown as BufferSource;
async function deriveKey(
password: string,
@@ -12,13 +38,13 @@ async function deriveKey(
): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
src(new TextEncoder().encode(password)),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" },
{ name: "PBKDF2", salt: src(salt), iterations: 210000, hash: "SHA-512" },
material,
{ name: "AES-GCM", length: 256 },
false,
@@ -26,32 +52,276 @@ async function deriveKey(
);
}
function nonce(prefix: Uint8Array, index: number, last: boolean): Uint8Array {
const n = new Uint8Array(12);
n.set(prefix, 0);
// 4-byte big-endian chunk counter at offset 7.
n[7] = (index >>> 24) & 0xff;
n[8] = (index >>> 16) & 0xff;
n[9] = (index >>> 8) & 0xff;
n[10] = index & 0xff;
n[11] = last ? 1 : 0;
return n;
}
function concat(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
async function sealChunk(
key: CryptoKey,
prefix: Uint8Array,
index: number,
last: boolean,
plaintext: Uint8Array,
): Promise<Uint8Array> {
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
key,
src(plaintext),
);
return new Uint8Array(ct);
}
async function openChunk(
key: CryptoKey,
prefix: Uint8Array,
index: number,
last: boolean,
ciphertext: Uint8Array,
): Promise<Uint8Array> {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
key,
src(ciphertext),
);
return new Uint8Array(pt);
}
// ----------------------------------------------------------------------------
// One-shot helpers (browser uploads/downloads, tests, python parity). These
// buffer the whole payload; the server uses the streaming variants below.
// ----------------------------------------------------------------------------
export async function encrypt(
content: Uint8Array,
password: string,
): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const salt = crypto.getRandomValues(new Uint8Array(16));
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
const key = await deriveKey(password, salt, ["encrypt"]);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
content,
);
return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]);
const out: Uint8Array[] = [salt, prefix];
// Always emit at least one (possibly empty) final chunk so empty input still
// round-trips and the final-chunk flag is always present.
const chunks = Math.max(1, Math.ceil(content.length / CHUNK));
for (let i = 0; i < chunks; i++) {
const start = i * CHUNK;
const slice = content.subarray(
start,
Math.min(start + CHUNK, content.length),
);
out.push(await sealChunk(key, prefix, i, i === chunks - 1, slice));
}
return concat(out);
}
export async function decrypt(
data: Uint8Array,
password: string,
): Promise<Uint8Array> {
const salt = data.slice(0, 16);
const iv = data.slice(16, 28);
if (data.length < HEADER_LEN + TAG_LEN) {
throw new Error("ciphertext too short");
}
const salt = data.subarray(0, SALT_LEN);
const prefix = data.subarray(SALT_LEN, HEADER_LEN);
const key = await deriveKey(password, salt, ["decrypt"]);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
data.slice(28),
);
return new Uint8Array(plaintext);
const body = data.subarray(HEADER_LEN);
const out: Uint8Array[] = [];
let offset = 0;
let index = 0;
while (offset < body.length) {
const encLen = Math.min(ENC_CHUNK, body.length - offset);
const last = offset + encLen >= body.length;
out.push(
await openChunk(
key,
prefix,
index,
last,
body.subarray(offset, offset + encLen),
),
);
offset += encLen;
index++;
}
return concat(out);
}
// ----------------------------------------------------------------------------
// Streaming helpers (server-side encrypt on upload / decrypt on download).
// ----------------------------------------------------------------------------
// A small pull-based reader over a ReadableStream that can hand back exact byte
// counts, buffering only the unconsumed remainder.
class ByteStreamReader {
#reader: ReadableStreamDefaultReader<Uint8Array>;
#buf: Uint8Array = new Uint8Array(0);
#done = false;
constructor(stream: ReadableStream<Uint8Array>) {
this.#reader = stream.getReader();
}
async #fill(): Promise<boolean> {
if (this.#done) return false;
const { done, value } = await this.#reader.read();
if (done) {
this.#done = true;
return false;
}
this.#buf = this.#buf.length === 0 ? value : concat([this.#buf, value]);
return true;
}
// Reads exactly `n` bytes, or throws if the stream ends first.
async readExact(n: number): Promise<Uint8Array> {
while (this.#buf.length < n) {
if (!(await this.#fill())) throw new Error("unexpected end of stream");
}
const out = this.#buf.subarray(0, n);
this.#buf = this.#buf.subarray(n);
return out;
}
// Reads up to `n` bytes; returns fewer only at end of stream.
async readUpTo(n: number): Promise<Uint8Array> {
while (this.#buf.length < n) {
if (!(await this.#fill())) break;
}
const take = Math.min(n, this.#buf.length);
const out = this.#buf.subarray(0, take);
this.#buf = this.#buf.subarray(take);
return out;
}
// True if more bytes remain, without consuming them.
async hasMore(): Promise<boolean> {
while (this.#buf.length === 0) {
if (!(await this.#fill())) return false;
}
return true;
}
}
// Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
// is invoked once with up to `headBytes` of leading plaintext (used to sniff a
// media type for previews) before the stream completes.
export async function encryptStream(
input: ReadableStream<Uint8Array>,
password: string,
onHead?: (head: Uint8Array) => void,
headBytes = 4100,
): Promise<ReadableStream<Uint8Array>> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
const key = await deriveKey(password, salt, ["encrypt"]);
const reader = new ByteStreamReader(input);
let index = 0;
let headDone = onHead === undefined;
const headParts: Uint8Array[] = [];
let headLen = 0;
function recordHead(chunk: Uint8Array) {
if (headDone) return;
const need = headBytes - headLen;
if (need > 0) {
const slice = chunk.subarray(0, need);
headParts.push(slice);
headLen += slice.length;
}
}
function flushHead() {
if (!headDone) {
headDone = true;
onHead?.(concat(headParts));
}
}
return new ReadableStream<Uint8Array>({
start(controller) {
// salt + noncePrefix come first, before any chunk.
controller.enqueue(concat([salt, prefix]));
},
async pull(controller) {
const current = await reader.readUpTo(CHUNK);
recordHead(current);
// Peek (without consuming) whether more plaintext follows so the final
// chunk's flag is set correctly. An empty input still yields one final
// (empty) chunk on the first pull.
const last = !(await reader.hasMore());
controller.enqueue(await sealChunk(key, prefix, index, last, current));
index++;
if (last) {
flushHead();
controller.close();
}
},
});
}
// Decrypts a stream in the chunked wire format. `totalLen` is the full byte
// length of the source (e.g. the on-disk file size) so chunk boundaries and the
// final chunk can be derived. Returns the first plaintext chunk eagerly (for
// MIME sniffing) plus a `body` stream that re-emits it and then the rest, all
// from a single key derivation.
export async function decryptToStream(
source: ReadableStream<Uint8Array>,
password: string,
totalLen: number,
): Promise<{ firstChunk: Uint8Array; body: ReadableStream<Uint8Array> }> {
const dataLen = totalLen - HEADER_LEN;
if (dataLen < TAG_LEN) throw new Error("ciphertext too short");
const reader = new ByteStreamReader(source);
const header = await reader.readExact(HEADER_LEN);
const salt = header.subarray(0, SALT_LEN);
const prefix = header.subarray(SALT_LEN, HEADER_LEN);
const key = await deriveKey(password, salt, ["decrypt"]);
let consumed = 0;
let index = 0;
async function next(): Promise<Uint8Array | null> {
if (consumed >= dataLen) return null;
const encLen = Math.min(ENC_CHUNK, dataLen - consumed);
const last = consumed + encLen >= dataLen;
const ct = await reader.readExact(encLen);
consumed += encLen;
const pt = await openChunk(key, prefix, index, last, ct);
index++;
return pt;
}
// Decrypt the first chunk now (throws on a wrong password / tamper).
const firstChunk = (await next()) ?? new Uint8Array(0);
let firstEmitted = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!firstEmitted) {
firstEmitted = true;
controller.enqueue(firstChunk);
return;
}
const chunk = await next();
if (chunk === null) controller.close();
else controller.enqueue(chunk);
},
});
return { firstChunk, body };
}
▾Msrc/index.ts
@@ -1,8 +1,12 @@
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { Readable } from "node:stream";
import { Database } from "bun:sqlite";
import cron from "@elysiajs/cron";
import { html } from "@elysiajs/html";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import busboy from "busboy";
import { Elysia, StatusMap, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import {
@@ -11,26 +15,51 @@ import {
NotFound,
SetCookie,
ShowFile,
textPreviewHtml,
WrongPassword,
type Preview,
} from "./components";
import { config } from "./config";
import { decrypt, encrypt } from "./crypto";
import { decryptToStream, encryptStream } from "./crypto";
const BLOB_DIR = "./db/blobs";
mkdirSync(BLOB_DIR, { recursive: true });
const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`;
const safeUnlink = (path: string) => unlink(path).catch(() => {});
const filetypeSet = new Set(filetypes);
const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection
const db = new Database("./db/db.sqlite");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA journal_mode = WAL");
// Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata.
// `size` is the on-disk byte count (post-encryption) and drives the total-bytes
// cap; `media_mime` is the MIME sniffed from the plaintext head at upload time,
// letting /show preview media without decrypting.
db.run(
"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",
"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",
);
db.run("PRAGMA optimize");
// Running total of stored content bytes, initialized once from the DB and then
// maintained in memory (incremented on upload, decremented when files expire).
let totalBytes = (
db.prepare("SELECT COALESCE(SUM(size), 0) AS t FROM files").get() as {
t: number;
}
).t;
// uuid route params are constrained to this shape so they can't be used to
// inject CRLF/extra directives into the Set-Cookie Path or content-disposition.
// inject CRLF/extra directives into the Set-Cookie Path or content-disposition,
// or to escape the blob directory.
const UUID_PATTERN = "^[0-9a-fA-F-]{36}$";
// Per-IP timestamp of the last accepted upload, used for the upload cooldown.
// Pruned by the cron below so it can't grow without bound.
// Per-IP timestamp of the last accepted upload / last server-side decryption
// attempt, used for the respective cooldowns. Pruned by the cron below so they
// can't grow without bound.
const lastUpload = new Map<string, number>();
const lastDecrypt = new Map<string, number>();
type MinimalServer = {
requestIP(req: Request): { address: string } | null;
@@ -50,13 +79,34 @@ function clientIp(
return server?.requestIP(request)?.address ?? "unknown";
}
function stringArrayToEnum<T extends string>(
arr: readonly T[],
): { [K in T]: K } {
return arr.reduce((acc, key) => {
acc[key] = key;
return acc;
}, Object.create(null));
// An upload failure that maps to a specific HTTP status + message.
class UploadError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function pump(
stream: ReadableStream<Uint8Array>,
onChunk: (chunk: Uint8Array) => void | Promise<void>,
): Promise<void> {
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Await the callback so a slow sink applies backpressure (Bun's FileSink
// .write returns a Promise when the write is still pending) instead of
// buffering the whole upload in memory.
await onChunk(value);
}
}
async function sniffMime(bytes: Uint8Array): Promise<string | null> {
if (bytes.length === 0) return null;
return (await fileTypeFromBuffer(bytes))?.mime ?? null;
}
const app = new Elysia({
@@ -70,21 +120,58 @@ const app = new Elysia({
cron({
name: "delete",
pattern: "*/5 * * * * *",
run() {
db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')");
async run() {
const expired = db
.prepare(
"SELECT uuid, size FROM files WHERE delete_at < strftime('%s', 'now')",
)
.all() as { uuid: string; size: number }[];
if (expired.length > 0) {
// Delete by the exact uuids selected above, not a second
// strftime('now') comparison (which evaluates at a later instant and
// could delete a row we didn't account for here — leaking the counter
// and orphaning its blob).
const placeholders = expired.map(() => "?").join(",");
db.exec(
`DELETE FROM files WHERE uuid IN (${placeholders})`,
expired.map((e) => e.uuid),
);
for (const { uuid, size } of expired) {
await safeUnlink(blobPath(uuid));
totalBytes -= size;
}
if (totalBytes < 0) totalBytes = 0;
}
const now = Date.now();
if (config.uploadCooldownSeconds > 0) {
const cutoff = Date.now() - config.uploadCooldownSeconds * 1000;
const cutoff = now - config.uploadCooldownSeconds * 1000;
for (const [ip, ts] of lastUpload) {
if (ts < cutoff) lastUpload.delete(ip);
}
}
if (config.decryptCooldownSeconds > 0) {
const cutoff = now - config.decryptCooldownSeconds * 1000;
for (const [ip, ts] of lastDecrypt) {
if (ts < cutoff) lastDecrypt.delete(ip);
}
}
},
}),
)
.use(
cron({
// Run SQLite's optimizer periodically (not just at startup), per its docs.
name: "optimize",
pattern: "0 0 * * * *",
run() {
db.exec("PRAGMA optimize");
},
}),
)
.get("/", ({ server }) => Index(server?.url.toString() ?? ""))
.post(
"/upload",
async ({ set, body, server, request, headers }) => {
async ({ set, server, request, headers }) => {
const ip = clientIp(server, request, headers);
const now = Date.now();
if (config.uploadCooldownSeconds > 0) {
@@ -94,98 +181,290 @@ const app = new Elysia({
return "Upload cooldown active, please wait before uploading again";
}
}
if (body.file.size > config.maxUploadBytes) {
set.status = 413; // Payload Too Large
return `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`;
}
const uuid = randomUUIDv7();
let content: Uint8Array = Buffer.from(await body.file.bytes());
let encrypted = false;
// Retention: take the requested minutes (if any) and clamp it to the
// configured maximum age, so storage is time-bounded when MAX_AGE_MINUTES is set.
let minutes: number | null =
body.delete_in_minutes && Number(body.delete_in_minutes) > 0
? Number(body.delete_in_minutes)
: null;
if (config.maxAgeMinutes !== null) {
minutes = Math.min(minutes ?? config.maxAgeMinutes, config.maxAgeMinutes);
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("multipart/form-data") || !request.body) {
set.status = 400;
return "Expected a multipart/form-data upload";
}
const delete_at =
minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
if (body.encrypted === "on") {
encrypted = true;
} else if (body.password) {
content = await encrypt(content, body.password);
encrypted = true;
const uuid = randomUUID();
const path = blobPath(uuid);
// The in-flight blob write. A rejection can settle the upload while this
// is still draining to disk; the catch awaits it before unlinking so a
// late write can't recreate the blob after cleanup (orphan).
let writing: Promise<unknown> | undefined;
try {
// Parse the multipart body as it streams in. The file part must come
// LAST so the other fields (password, filetype, ...) are known before
// the bytes flow and can drive on-the-fly encryption to disk.
const result = await new Promise<{
filename: string;
filetype: string;
encrypted: boolean;
size: number;
mediaMime: string | null;
deleteAt: number | null;
}>((resolve, reject) => {
const bb = busboy({
headers: { "content-type": contentType },
limits: { files: 1, fileSize: config.maxUploadBytes },
});
const fields: Record<string, string> = {};
let fileSeen = false;
// A form field arriving after the file part means the file wasn't
// sent last. We don't act on it here (the file is still streaming) —
// we record it and report it once the whole body is parsed, so the
// "file must be last" error always wins over an incidental symptom
// like a not-yet-seen filetype.
let fieldAfterFile = false;
bb.on("field", (name, value) => {
if (fileSeen) fieldAfterFile = true;
else fields[name] = value;
});
bb.on("file", (name, stream, info) => {
if (name !== "file") {
stream.resume();
return;
}
fileSeen = true;
writing = streamToBlob(stream, info);
});
bb.on("error", reject);
// Settle once the entire body is parsed: by now the field set and the
// file's position relative to other fields are both fully known.
bb.on("close", async () => {
try {
if (!fileSeen) throw new UploadError(400, "No file provided");
if (fieldAfterFile) {
throw new UploadError(
400,
"the file field must be the last form field",
);
}
// Set because a file part was seen; await it to surface any
// streaming error and read the stored size/type.
const file = await (writing as ReturnType<typeof streamToBlob>);
const filetype = fields.filetype ?? "";
if (!filetypeSet.has(filetype)) {
throw new UploadError(400, "Invalid or missing filetype");
}
const dim = fields.delete_in_minutes;
if (dim && !/^[0-9]+$/.test(dim)) {
throw new UploadError(400, "Invalid delete_in_minutes");
}
let minutes: number | null =
dim && Number(dim) > 0 ? Number(dim) : null;
if (config.maxAgeMinutes !== null) {
minutes = Math.min(
minutes ?? config.maxAgeMinutes,
config.maxAgeMinutes,
);
}
const deleteAt =
minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
resolve({
filename: fields.filename || file.infoFilename || "file",
filetype,
encrypted: file.encrypted,
size: file.size,
mediaMime: file.mediaMime,
deleteAt,
});
} catch (e) {
reject(e);
}
});
// Streams the file part to ./db/blobs/<uuid>, encrypting on the fly
// when a password was provided (server-side) or storing opaque bytes
// for an already-encrypted upload. Uses the fields seen so far; if the
// file wasn't last that set is incomplete, but the close handler
// rejects such uploads before anything is persisted.
async function streamToBlob(stream: Readable, info: busboy.FileInfo) {
let limitExceeded = false;
stream.on("limit", () => {
limitExceeded = true;
});
const webIn = Readable.toWeb(
stream,
) as unknown as ReadableStream<Uint8Array>;
const sink = Bun.file(path).writer();
let size = 0;
let mediaMime: string | null = null;
let encrypted = false;
if (fields.encrypted === "on") {
// Client-side encrypted: opaque bytes, store as-is, no sniffing.
encrypted = true;
await pump(webIn, async (c) => {
size += c.length;
await sink.write(c);
});
} else if (fields.password) {
// Server-side encryption: encrypt the stream to disk, sniffing
// the plaintext head for a preview MIME type.
encrypted = true;
let head: Uint8Array | undefined;
const cipher = await encryptStream(
webIn,
fields.password,
(h) => {
head = h;
},
SNIFF_BYTES,
);
await pump(cipher, async (c) => {
size += c.length;
await sink.write(c);
});
if (head) mediaMime = await sniffMime(head);
} else {
// Plaintext: stream to disk, collecting the head for sniffing.
const headParts: Uint8Array[] = [];
let headLen = 0;
await pump(webIn, async (c) => {
size += c.length;
await sink.write(c);
if (headLen < SNIFF_BYTES) {
const slice = c.subarray(0, SNIFF_BYTES - headLen);
headParts.push(slice);
headLen += slice.length;
}
});
mediaMime = await sniffMime(Buffer.concat(headParts));
}
await sink.end();
if (limitExceeded) {
throw new UploadError(
413,
`File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`,
);
}
return {
encrypted,
size,
mediaMime,
infoFilename: info.filename ?? "",
};
}
Readable.fromWeb(
request.body as unknown as import("node:stream/web").ReadableStream<Uint8Array>,
).pipe(bb);
});
// Enforce the total-bytes cap against the in-memory counter. Concurrent
// uploads can transiently overshoot by up to (concurrency * per-file)
// before this check; acceptable at the expected scale.
if (
config.maxTotalBytes !== null &&
totalBytes + result.size > config.maxTotalBytes
) {
await safeUnlink(path);
set.status = 507; // Insufficient Storage
return "Server storage is full, try again later";
}
db.exec(
"INSERT INTO files (uuid, filename, filetype, encrypted, size, media_mime, delete_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
uuid,
result.filename,
result.filetype,
result.encrypted,
result.size,
result.mediaMime,
result.deleteAt,
],
);
totalBytes += result.size;
if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now);
set.status = StatusMap["See Other"];
set.headers.location = `/show/${uuid}`;
return `Created with id: ${uuid}`;
} catch (e) {
// Let any in-flight write finish so it can't recreate the blob after
// we unlink it below.
if (writing) await writing.catch(() => {});
await safeUnlink(path);
if (e instanceof UploadError) {
set.status = e.status;
return e.message;
}
throw e;
}
db.exec(
"INSERT INTO files (uuid, filename, content, filetype, encrypted, delete_at) VALUES (?, ?, ?, ?, ?, ?)",
[
uuid,
body.filename || body.file.name,
content,
body.filetype,
encrypted,
delete_at,
],
);
if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now);
set.status = StatusMap["See Other"];
set.headers.location = `/show/${uuid}`;
return `Created with id: ${uuid}`;
},
{
body: t.Object({
file: t.File(),
filename: t.Optional(t.String()),
filetype: t.Enum(stringArrayToEnum(filetypes)),
password: t.Optional(t.String()),
encrypted: t.Optional(t.String()),
delete_in_minutes: t.Optional(
t.String({
format: "regex",
pattern: "(^$|^[0-9]+$)",
}),
),
}),
// Body parsing is handled manually from the raw stream, so disable
// Elysia's parser (which would otherwise buffer the whole upload).
parse: "none",
},
)
.get(
"/show/:uuid",
async ({ set, params, cookie }) => {
const result =
async ({ set, params, cookie, server, request, headers }) => {
const row =
(db
.prepare(
"SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?",
"SELECT filename, filetype, encrypted, size, media_mime, delete_at FROM files WHERE uuid = ?",
)
.get(params.uuid) as {
filename: string;
content: Uint8Array;
filetype: string;
encrypted: number;
size: number;
media_mime: string | null;
delete_at: number | null;
}) || null;
if (!result) {
if (!row) {
set.status = StatusMap["Not Found"];
return NotFound();
}
if (result.encrypted) {
const path = blobPath(params.uuid);
let preview: Preview;
let shownSize: number | null = row.size;
if (row.encrypted) {
const password = cookie.password.value;
if (!password) {
return ShowFile(
result.filename,
params.uuid,
null,
result.filetype,
result.delete_at,
);
// No password yet: let the client-side flow handle decryption.
preview = { kind: "await" };
shownSize = null;
} else if (row.filetype === "blob") {
// Binary/media: don't decrypt here — preview points at /raw, which
// performs the single decryption for this view.
preview = row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
} else {
// Text: this is the one server-side decryption for the view.
const ip = clientIp(server, request, headers);
if (decryptBlocked(ip)) {
set.status = 429;
return "Decryption cooldown active, please wait and reload";
}
try {
result.content = await decrypt(result.content, password);
const content = await readDecrypted(path, password, row.size);
recordDecrypt(ip);
const out = new Uint8Array(content);
shownSize = out.byteLength;
const text = textPreviewHtml(out, row.filetype);
preview =
text !== null ? { kind: "text", html: text } : { kind: "none" };
} catch (_e) {
recordDecrypt(ip);
const secure = config.behindProxy ? "; Secure" : "";
set.status = StatusMap.Forbidden;
set.headers["set-cookie"] = [
@@ -195,14 +474,32 @@ const app = new Elysia({
return WrongPassword();
}
}
} else if (row.filetype !== "blob") {
// Plaintext text: read from disk and render inline.
const content = new Uint8Array(await Bun.file(path).bytes());
shownSize = content.byteLength;
const text = textPreviewHtml(content, row.filetype);
preview =
text !== null
? { kind: "text", html: text }
: row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
} else {
// Plaintext binary/media: preview via /raw using the sniffed MIME.
preview = row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
}
return ShowFile(
result.filename,
params.uuid,
result.content,
result.filetype,
result.delete_at,
);
return ShowFile({
filename: row.filename,
uuid: params.uuid,
filetype: row.filetype,
deleteAt: row.delete_at,
size: shownSize,
preview,
});
},
{
params: t.Object({
@@ -237,35 +534,64 @@ const app = new Elysia({
)
.get(
"/raw/:uuid",
async ({ set, params, cookie, query }) => {
const result =
async ({ set, params, cookie, query, server, request, headers }) => {
const row =
(db
.prepare(
"SELECT content, filename, encrypted, filetype FROM files WHERE uuid = ?",
"SELECT filename, encrypted, filetype, size FROM files WHERE uuid = ?",
)
.get(params.uuid) as {
content: Uint8Array;
filename: string;
encrypted: number;
filetype: string;
size: number;
}) || null;
if (!result) {
if (!row) {
set.status = StatusMap["Not Found"];
return "File not found";
}
const path = blobPath(params.uuid);
const servingEncrypted =
result.encrypted && query.ignore_password === "true";
if (result.encrypted && !servingEncrypted) {
if (!cookie.password.value) {
row.encrypted && query.ignore_password === "true";
let body: ReadableStream<Uint8Array> | ReturnType<typeof Bun.file>;
let sniff: Uint8Array | null = null;
if (row.encrypted && !servingEncrypted) {
const password = cookie.password.value;
if (!password) {
set.status = StatusMap.Unauthorized;
return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
}
const ip = clientIp(server, request, headers);
if (decryptBlocked(ip)) {
set.status = 429;
return "Decryption cooldown active, please wait and retry";
}
try {
result.content = await decrypt(result.content, cookie.password.value);
const { firstChunk, stream } = await openDecrypted(
path,
password,
row.size,
);
recordDecrypt(ip);
body = stream;
sniff = firstChunk.subarray(0, SNIFF_BYTES);
} catch (_e) {
recordDecrypt(ip);
set.status = StatusMap.Forbidden;
return "Incorrect password";
}
} else {
// Serve the file as-is: plaintext, or (with ignore_password) the still
// encrypted bytes. BunFile streams and supports range requests.
body = Bun.file(path);
if (!servingEncrypted) {
sniff = new Uint8Array(
await Bun.file(path).slice(0, SNIFF_BYTES).arrayBuffer(),
);
}
}
// Never let the browser sniff stored content into an executable type
@@ -274,8 +600,8 @@ const app = new Elysia({
// else (incl. still-encrypted bytes) is an octet-stream attachment.
let mime = "application/octet-stream";
let disposition = "attachment";
if (!servingEncrypted) {
const detected = await fileTypeFromBuffer(result.content);
if (sniff) {
const detected = await fileTypeFromBuffer(sniff);
if (
detected &&
detected.mime !== "image/svg+xml" &&
@@ -288,15 +614,15 @@ const app = new Elysia({
}
}
const safeName = encodeURIComponent(result.filename);
const safeName = encodeURIComponent(row.filename);
set.headers["x-content-type-options"] = "nosniff";
set.headers["content-type"] = mime;
set.headers.encrypted = result.encrypted ? "true" : "false";
set.headers.filetype = result.filetype;
set.headers.encrypted = row.encrypted ? "true" : "false";
set.headers.filetype = row.filetype;
set.headers.filename = safeName;
set.headers["content-disposition"] =
`${disposition}; filename*=UTF-8''${safeName}`;
return result.content;
return body;
},
{
params: t.Object({
@@ -308,6 +634,46 @@ const app = new Elysia({
)
.listen(3000);
// --- server-side decryption cooldown ----------------------------------------
function decryptBlocked(ip: string): boolean {
if (config.decryptCooldownSeconds <= 0) return false;
const last = lastDecrypt.get(ip) ?? 0;
return Date.now() - last < config.decryptCooldownSeconds * 1000;
}
function recordDecrypt(ip: string): void {
if (config.decryptCooldownSeconds > 0) lastDecrypt.set(ip, Date.now());
}
// Fully decrypt an on-disk blob to memory (used for text previews in /show).
async function readDecrypted(
path: string,
password: string,
size: number,
): Promise<Uint8Array> {
const { stream } = await openDecrypted(path, password, size);
const parts: Uint8Array[] = [];
await pump(stream, (c) => {
parts.push(c);
});
return Buffer.concat(parts);
}
// Open an on-disk blob for streaming decryption, exposing the first plaintext
// chunk (for MIME sniffing) and the full plaintext stream from one derivation.
async function openDecrypted(
path: string,
password: string,
size: number,
): Promise<{ firstChunk: Uint8Array; stream: ReadableStream<Uint8Array> }> {
const { firstChunk, body } = await decryptToStream(
Bun.file(path).stream(),
password,
size,
);
return { firstChunk, stream: body };
}
console.log(
`⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
);