configurable limits, security fixes

AuthorKonata <konata@posteo.jp>
Date
Commit08f36f98b294d525bc10af0132f230076f519558
Parentf55e964
11 files changed, 332 insertions(+), 67 deletions(-)
▾MREADME.md
@@ -2,5 +2,18 @@
Pastebin with encryption, syntax highlighting and media previews, optionally without JS enabled in the browser. Also supports easy interaction via curl
## Setup
A `Containerfile` and `compose.yml` is provided in this repo, just run `docker-compose up` or `podman-compose up` to start the server at port 3000. For manual setup without containers just follow the setup done in `Containerfile`
## Configuration
ZBin is configured through environment variables (all optional):
| Variable | Default | Description |
| --- | --- | --- |
| `MAX_UPLOAD_BYTES` | `104857600` (100 MiB) | Hard cap on a single upload. |
| `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. |
| `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
> served over HTTPS behind a reverse proxy (with `BEHIND_PROXY=true`) in any real deployment.
## Screenshot
![screenshot](screenshot.png)
▾Massets/default.css
@@ -62,6 +62,18 @@ img {
margin-bottom: 0;
}
#server-limits {
align-self: flex-start;
ul {
margin: 0.25rem 0 1rem;
}
li {
margin: 0;
}
}
.help-icon {
width: 2rem;
height: 2rem;
▾Massets/encrypt.py
@@ -1,7 +1,6 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
import os
import sys
@@ -10,16 +9,19 @@ 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
server/browser implementation in src/crypto.ts.
Args:
content (bytes): The content to be encrypted.
password (str): The password used for encryption.
Returns:
bytes: The encrypted content.
bytes: salt[16] | iv[12] | ciphertext+tag
"""
# Generate a random initialization vector (IV) and salt
iv = os.urandom(16)
iv = os.urandom(12)
salt = os.urandom(16)
# Derive a key from the password using PBKDF2
@@ -27,21 +29,13 @@ def encrypt(content: bytes, password: str) -> bytes:
algorithm=hashes.SHA512(),
length=32,
salt=salt,
iterations=100000,
iterations=210000,
backend=default_backend()
)
key = kdf.derive(password.encode())
# Create a cipher object with AES-256-CBC
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
# Pad the content to a multiple of the block length
padder = padding.PKCS7(128).padder()
padded_content = padder.update(content) + padder.finalize()
# Encrypt the padded content
encrypted_content = encryptor.update(padded_content) + encryptor.finalize()
# 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
@@ -51,4 +45,3 @@ if __name__ == "__main__":
sys.exit(1)
data = encrypt(open(sys.argv[1], "rb").read(), sys.argv[2])
open(sys.argv[3], "wb").write(data)
▾Mcompose.yaml
@@ -6,3 +6,9 @@ services:
- ./db:/app/db
ports:
- 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)
▾Mpackage.json
@@ -6,7 +6,8 @@
"dev": "bun run build && bun run --watch src/index.ts",
"prod": "bun run build && bun run src/index.ts",
"format": "biome format --write",
"lint": "biome lint"
"lint": "biome lint",
"test": "bun test"
},
"dependencies": {
"@chneau/elysia-compression": "^1.0.11",
▾Asrc/components.test.ts
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { humanReadableTime, ShowFile } from "./components";
const UUID = "00000000-0000-0000-0000-000000000000";
test("escapes filename in the file view (no stored XSS)", async () => {
const evil = "<img src=x onerror=alert(1)>";
const html = await ShowFile(
evil,
UUID,
new TextEncoder().encode("hi"),
"none",
null,
);
expect(html).not.toContain(evil);
expect(html).toContain("&lt;img");
});
test("escapes filename in the encrypted overlay (no stored XSS)", async () => {
const evil = "<script>alert(1)</script>";
const html = await ShowFile(evil, UUID, null, "none", null);
expect(html).not.toContain(evil);
expect(html).toContain("&lt;script&gt;");
});
test("humanReadableTime breaks a total down (no spurious 'expired')", () => {
expect(humanReadableTime(4320)).toBe("3 days"); // exact multiple of a day
expect(humanReadableTime(120)).toBe("2 hours"); // exact multiple of an hour
expect(humanReadableTime(1)).toBe("1 minute");
expect(humanReadableTime(1501)).toBe("1 day 1 hour 1 minute");
expect(humanReadableTime(0)).toBe("expired");
expect(humanReadableTime(-5)).toBe("expired");
});
▾Msrc/components.tsx
@@ -4,6 +4,7 @@ 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";
export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
@@ -38,10 +39,46 @@ export function NotFound() {
);
}
// Surfaces the server's configured abuse limits so users know the rules before
// uploading. Each line only shows when the corresponding limit is actually set.
function ServerLimits() {
return (
<div id="server-limits">
<small>Server limits:</small>
<ul>
<li>
<small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
</li>
{config.maxAgeMinutes !== null ? (
<li>
<small>
Files are kept for at most{" "}
{humanReadableTime(config.maxAgeMinutes)} before being deleted
</small>
</li>
) : (
""
)}
{config.uploadCooldownSeconds > 0 ? (
<li>
<small>
You can upload once every {config.uploadCooldownSeconds} second
{config.uploadCooldownSeconds > 1 ? "s" : ""} from the same address
</small>
</li>
) : (
""
)}
</ul>
</div>
);
}
export function Index(hostname: string) {
return (
<Template css="/default.css">
<h1>⚡ZBin⚡</h1>
<ServerLimits />
<dialog id="upload-dialog">File is being uploaded, please wait</dialog>
<form
action="/upload"
@@ -180,7 +217,10 @@ export function WrongPassword() {
);
}
function humanReadableTime(minutes: number) {
export function humanReadableTime(minutes: number) {
// Guard on the total before it is broken into units below; an already-expired
// (or sub-minute) duration has no meaningful breakdown.
if (minutes <= 0) return "expired";
const years = Math.floor(minutes / 525600);
minutes %= 525600;
const days = Math.floor(minutes / 1440);
@@ -227,15 +267,19 @@ export async function ShowFile(
);
}
} else {
const filetype = await fileTypeFromBuffer(content);
if (filetype) {
const base64Content = `data:${filetype.mime};base64,${Buffer.from(content).toString("base64")}`;
if (filetype.mime.startsWith("audio/")) {
preview = <audio controls="" src={base64Content} />;
} else if (filetype.mime.startsWith("video/")) {
preview = <video controls src={base64Content} />;
} else if (filetype.mime.startsWith("image/")) {
preview = <img src={base64Content} alt={filename} />;
// 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} />;
}
}
}
@@ -247,7 +291,7 @@ export async function ShowFile(
""
) : (
<div id="decrypt-overlay">
<h1>Encrypted file: {filename}</h1>
<h1 safe>Encrypted file: {filename}</h1>
<form
id="decrypt-form"
action={`/set-cookie/${uuid}`}
@@ -288,7 +332,9 @@ export async function ShowFile(
</div>
)}
<div id="content">
<div id="filename">{filename}</div>
<div id="filename" safe>
{filename}
</div>
<div id="mediabox">{preview}</div>
</div>
<div id="sidebar">
@@ -321,7 +367,10 @@ export async function ShowFile(
export function SetCookie(uuid: string) {
return (
<Template css="/default.css">
Redirecting you back to <a href={`/show/${uuid}`}>/show/{uuid}</a>
Redirecting you back to{" "}
<a safe href={`/show/${uuid}`}>
/show/{uuid}
</a>
</Template>
);
}
▾Asrc/config.ts
@@ -0,0 +1,35 @@
// Runtime configuration, read once from the environment at startup.
// 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
/** parseInt with a default that distinguishes "unset" from "explicit 0". */
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;
}
/** Truthy only for "true"/"1"; anything else (incl. "false", "0", unset) is off. */
function boolEnv(value: string | undefined): boolean {
return value === "true" || value === "1";
}
export const config = {
// Hard cap on a single upload, in bytes (default 100 MiB).
maxUploadBytes: intEnv(Bun.env.MAX_UPLOAD_BYTES, 100 * 1024 * 1024),
// Maximum retention in minutes. null = unlimited; when set, every upload's
// deletion time is clamped to at most now + maxAgeMinutes.
maxAgeMinutes: Bun.env.MAX_AGE_MINUTES
? intEnv(Bun.env.MAX_AGE_MINUTES, 0) || null
: null,
// Minimum seconds between uploads from the same client IP. 0 = disabled.
uploadCooldownSeconds: intEnv(Bun.env.UPLOAD_COOLDOWN_SECONDS, 0),
// 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
// direct/local HTTP, otherwise clients can spoof the cooldown key and the
// Secure cookie won't be sent over plain HTTP.
behindProxy: boolEnv(env.BEHIND_PROXY),
};
▾Asrc/crypto.test.ts
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { decrypt, encrypt } from "./crypto";
describe("crypto (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");
const dec = await decrypt(enc, "correct horse battery staple");
expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte");
});
test("wire format is salt[16] | iv[12] | ciphertext+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);
});
test("rejects a wrong password (authenticated)", async () => {
const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
await expect(decrypt(enc, "wrong")).rejects.toThrow();
});
test("rejects tampered ciphertext", async () => {
const enc = await encrypt(new Uint8Array([9, 9, 9]), "pw");
enc[enc.length - 1] ^= 0xff; // flip a tag byte
await expect(decrypt(enc, "pw")).rejects.toThrow();
});
});
▾Msrc/crypto.ts
@@ -1,7 +1,9 @@
// AES-256-CBC 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.
// Wire format: salt[16] | iv[16] | ciphertext.
// 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.
async function deriveKey(
password: string,
@@ -16,9 +18,9 @@ async function deriveKey(
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-512" },
{ name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" },
material,
{ name: "AES-CBC", length: 256 },
{ name: "AES-GCM", length: 256 },
false,
usage,
);
@@ -28,11 +30,11 @@ export async function encrypt(
content: Uint8Array,
password: string,
): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt, ["encrypt"]);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv },
{ name: "AES-GCM", iv },
key,
content,
);
@@ -44,12 +46,12 @@ export async function decrypt(
password: string,
): Promise<Uint8Array> {
const salt = data.slice(0, 16);
const iv = data.slice(16, 32);
const iv = data.slice(16, 28);
const key = await deriveKey(password, salt, ["decrypt"]);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv },
{ name: "AES-GCM", iv },
key,
data.slice(32),
data.slice(28),
);
return new Uint8Array(plaintext);
}
▾Msrc/index.ts
@@ -4,6 +4,7 @@ import { html } from "@elysiajs/html";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import { Elysia, StatusMap, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import {
filetypes,
Index,
@@ -12,16 +13,43 @@ import {
ShowFile,
WrongPassword,
} from "./components";
import { config } from "./config";
import { decrypt, encrypt } from "./crypto";
const db = new Database("./db/db.sqlite");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA journal_mode = WAL2");
db.run("PRAGMA journal_mode = WAL");
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",
);
db.run("PRAGMA optimize");
// 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.
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.
const lastUpload = new Map<string, number>();
type MinimalServer = {
requestIP(req: Request): { address: string } | null;
} | null;
function clientIp(
server: MinimalServer,
request: Request,
headers: Record<string, string | undefined>,
): string {
if (config.behindProxy) {
const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim();
if (xff) return xff;
const real = headers["x-real-ip"];
if (real) return real;
}
return server?.requestIP(request)?.address ?? "unknown";
}
function stringArrayToEnum<T extends string>(
arr: readonly T[],
): { [K in T]: K } {
@@ -33,7 +61,7 @@ function stringArrayToEnum<T extends string>(
const app = new Elysia({
serve: {
maxRequestBodySize: 1024 * 1024 * 1024, // 1GB
maxRequestBodySize: config.maxUploadBytes,
},
})
.use(staticPlugin({ assets: "./assets", prefix: "/" }))
@@ -44,21 +72,49 @@ const app = new Elysia({
pattern: "*/5 * * * * *",
run() {
db.exec("DELETE FROM files WHERE delete_at < strftime('%s', 'now')");
if (config.uploadCooldownSeconds > 0) {
const cutoff = Date.now() - config.uploadCooldownSeconds * 1000;
for (const [ip, ts] of lastUpload) {
if (ts < cutoff) lastUpload.delete(ip);
}
}
},
}),
)
.get("/", ({ server }) => Index(server?.url.toString() ?? ""))
.post(
"/upload",
async ({ set, body }) => {
async ({ set, body, server, request, headers }) => {
const ip = clientIp(server, request, headers);
const now = Date.now();
if (config.uploadCooldownSeconds > 0) {
const last = lastUpload.get(ip) ?? 0;
if (now - last < config.uploadCooldownSeconds * 1000) {
set.status = 429; // Too Many Requests
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;
let delete_at: number | null = null;
if (body.delete_in_minutes) {
delete_at =
Math.floor(Date.now() / 1000) + Number(body.delete_in_minutes) * 60;
// 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 delete_at =
minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
if (body.encrypted === "on") {
encrypted = true;
} else if (body.password) {
@@ -76,6 +132,7 @@ const app = new Elysia({
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}`;
@@ -84,15 +141,15 @@ const app = new Elysia({
body: t.Object({
file: t.File(),
filename: t.Optional(t.String()),
//filetype: t.String()/*todo:verifiy via t.Enum*/,
filetype: t.Enum(stringArrayToEnum(filetypes)),
password: t.Optional(t.String()),
encrypted: t.Optional(t.String()),
encrypt_mode: t.Optional(t.String()),
delete_in_minutes: t.String({
format: "regex",
pattern: "(^$|^[0-9]+$)",
}),
delete_in_minutes: t.Optional(
t.String({
format: "regex",
pattern: "(^$|^[0-9]+$)",
}),
),
}),
},
)
@@ -108,7 +165,7 @@ const app = new Elysia({
filename: string;
content: Uint8Array;
filetype: string;
encrypted: boolean;
encrypted: number;
delete_at: number | null;
}) || null;
if (!result) {
@@ -129,10 +186,11 @@ const app = new Elysia({
try {
result.content = await decrypt(result.content, password);
} catch (_e) {
const secure = config.behindProxy ? "; Secure" : "";
set.status = StatusMap.Forbidden;
set.headers["set-cookie"] = [
`password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
];
return WrongPassword();
}
@@ -147,16 +205,22 @@ const app = new Elysia({
);
},
{
params: t.Object({ uuid: t.String() }),
params: t.Object({
uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
}),
cookie: t.Object({ password: t.Optional(t.String()) }),
},
)
.post(
"/set-cookie/:uuid",
({ set, body, params }) => {
// encodeURIComponent keeps ';', CR/LF and other separators out of the
// cookie value; Elysia URL-decodes the value again when it reads it back.
const value = encodeURIComponent(body.password);
const secure = config.behindProxy ? "; Secure" : "";
set.headers["set-cookie"] = [
`password=${body.password}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly`,
`password=${body.password}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly`,
`password=${value}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
`password=${value}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
];
set.headers.location = `/show/${params.uuid}`;
set.status = StatusMap["See Other"];
@@ -165,9 +229,10 @@ const app = new Elysia({
{
body: t.Object({
password: t.String(),
decrypt_mode: t.Optional(t.String()),
}),
params: t.Object({ uuid: t.String() }),
params: t.Object({
uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
}),
},
)
.get(
@@ -181,14 +246,16 @@ const app = new Elysia({
.get(params.uuid) as {
content: Uint8Array;
filename: string;
encrypted: boolean;
encrypted: number;
filetype: string;
}) || null;
if (!result) {
set.status = StatusMap["Not Found"];
return "File not found";
}
if (result.encrypted && query.ignore_password !== "true") {
const servingEncrypted =
result.encrypted && query.ignore_password === "true";
if (result.encrypted && !servingEncrypted) {
if (!cookie.password.value) {
set.status = StatusMap.Unauthorized;
return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
@@ -200,15 +267,41 @@ const app = new Elysia({
return "Incorrect password";
}
}
// Never let the browser sniff stored content into an executable type
// (e.g. HTML/SVG running as same-origin script). Only whitelisted,
// non-scriptable media is served inline with its real type; everything
// 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 (
detected &&
detected.mime !== "image/svg+xml" &&
(detected.mime.startsWith("image/") ||
detected.mime.startsWith("audio/") ||
detected.mime.startsWith("video/"))
) {
mime = detected.mime;
disposition = "inline";
}
}
const safeName = encodeURIComponent(result.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.filename = result.filename;
set.headers.filename = safeName;
set.headers["content-disposition"] =
`inline; filename=${result.filename}`;
`${disposition}; filename*=UTF-8''${safeName}`;
return result.content;
},
{
params: t.Object({ uuid: t.String() }),
params: t.Object({
uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
}),
cookie: t.Object({ password: t.Optional(t.String()) }),
query: t.Object({ ignore_password: t.Optional(t.String()) }),
},