This commit is contained in:
Anaïs Saraiva
2026-08-15 00:28:32 +02:00
parent 5f201cf725
commit 954384d1e4
14 changed files with 393 additions and 37 deletions
+2
View File
@@ -241,6 +241,8 @@ Files in `storage/` are untouched by updates and keep working.
- The app binds to loopback; only the proxy is exposed. - The app binds to loopback; only the proxy is exposed.
- Download/deletion require the unguessable key; everything else is `404`. - Download/deletion require the unguessable key; everything else is `404`.
- Optional per-file passwords are stored only as scrypt hashes; entry mints a
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set - Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`. when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
- Files never expire. Deletion requires possession of the key - Files never expire. Deletion requires possession of the key
+37 -7
View File
@@ -1,4 +1,4 @@
# File Drop # File Drop (VYBECODED)
A minimal, self-hosted private file hosting service built with **Bun** and A minimal, self-hosted private file hosting service built with **Bun** and
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are **TypeScript**. Upload a file, get a secret, unguessable download link. Files are
@@ -10,6 +10,7 @@ database, no object storage, no queue, and no automatic cleanup.
## Features ## Features
- Drag & drop / file-picker uploads with progress, straight from the browser. - Drag & drop / file-picker uploads with progress, straight from the browser.
- **Optional per-file password**: protect a download link with a password.
- Permanent storage on the local filesystem; files survive restarts. - Permanent storage on the local filesystem; files survive restarts.
- A cryptographically secure, unguessable download key for every file. - A cryptographically secure, unguessable download key for every file.
- Streaming uploads and downloads — no full buffering in RAM, large files work. - Streaming uploads and downloads — no full buffering in RAM, large files work.
@@ -68,7 +69,7 @@ Then open http://localhost:3000.
3. The server stores it permanently and returns a URL like 3. The server stores it permanently and returns a URL like
`https://files.example.com/d/8fJ...random-secret...`. `https://files.example.com/d/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file; nobody without the 4. Copy the URL. Anyone possessing it can download the file; nobody without the
secret can. secret can. If a password was set, the downloader must also enter it.
5. The file stays available indefinitely. It is removed only by explicit 5. The file stays available indefinitely. It is removed only by explicit
deletion. deletion.
@@ -76,6 +77,7 @@ Then open http://localhost:3000.
``` ```
storage/ storage/
├── .secret # server signing secret (0600), auto-generated
├── files/ ├── files/
│ ├── <file-id> # the raw uploaded bytes │ ├── <file-id> # the raw uploaded bytes
│ └── <file-id> │ └── <file-id>
@@ -98,7 +100,10 @@ Example metadata record (`storage/metadata/<file-id>.json`):
``` ```
Only a SHA-256 **hash** of the download key is stored — the plaintext key exists Only a SHA-256 **hash** of the download key is stored — the plaintext key exists
only inside the URL shown to the uploader. only inside the URL shown to the uploader. If a download password is set, only
its scrypt hash is stored (see [password protection](#password-protection)).
The server signing secret for download cookies lives in `storage/.secret`
(`0600`), created on first start and reused across restarts.
Uploads are first streamed to a `<file-id>.part` temp file and atomically renamed Uploads are first streamed to a `<file-id>.part` temp file and atomically renamed
into place on completion. At startup the server sweeps for orphans (a file into place on completion. At startup the server sweeps for orphans (a file
@@ -110,10 +115,12 @@ touched.
### `POST /api/upload` ### `POST /api/upload`
Multipart/form-data with a `file` field (this is what the web UI uses). Multipart/form-data with a `file` field (this is what the web UI uses). An
optional `password` field protects the download with a password.
```bash ```bash
curl -F "[email protected]" http://localhost:3000/api/upload curl -F "[email protected]" http://localhost:3000/api/upload
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
``` ```
```json ```json
@@ -122,19 +129,39 @@ curl -F "[email protected]" http://localhost:3000/api/upload
"key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456 "size": 123456,
"protected": false
} }
``` ```
### `GET /d/:key` (also `HEAD`) ### `GET /d/:key` (also `HEAD`)
Streams the file. Requires the full secret key; any wrong or non-existent key Streams the file. Requires the full secret key; any wrong or non-existent key
returns a generic `404`. returns a generic `404`. If the file has a password, the server first shows a
password prompt (see below).
```bash ```bash
curl -O http://localhost:3000/d/YOWJyh...7OQ curl -O http://localhost:3000/d/YOWJyh...7OQ
``` ```
### Password protection
For files uploaded with a `password` field:
1. `GET /d/:key` returns a small HTML password form.
2. The browser submits it to `POST /api/auth/:key` with `password=...`.
3. On success the server sets a short-lived (30 min), HttpOnly, SameSite=Lax,
signed cookie and redirects back to `/d/:key`, which then streams the file.
```bash
curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key
curl -b cookies.txt -O http://localhost:3000/d/:key
```
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
forged, and it survives server restarts. Without it, a protected file is never
downloadable — not even by the uploader.
### `DELETE /api/files/:id` ### `DELETE /api/files/:id`
Permanently deletes the file and its metadata. The correct download key must be Permanently deletes the file and its metadata. The correct download key must be
@@ -164,6 +191,9 @@ paths or stack traces.
or filenames are ever used as secrets. or filenames are ever used as secrets.
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use - **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
constant-time `timingSafeEqual`. constant-time `timingSafeEqual`.
- **Passwords.** An optional per-file password is stored only as an **scrypt**
hash (salted), never in plaintext. Successful entry mints a short-lived
HMAC-signed cookie, so access is granted by the server and cannot be forged.
- **Path safety.** Filesystem paths are built only from server-generated, - **Path safety.** Filesystem paths are built only from server-generated,
regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata
only and is sanitized (basename, control/null bytes stripped) before being only and is sanitized (basename, control/null bytes stripped) before being
@@ -314,4 +344,4 @@ src/
``` ```
Intentionally small: no framework, no DI, no ORM, no repositories. Bun + Intentionally small: no framework, no DI, no ORM, no repositories. Bun +
TypeScript + filesystem + a simple frontend + secret URLs. TypeScript + filesystem + a simple frontend + secret URLs.
+12
View File
@@ -73,6 +73,18 @@ check "delete with key succeeds" \
check "file gone after delete" test \ check "file gone after delete" test \
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE$URL")" = "404" "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE$URL")" = "404"
echo
echo "-- password protection --"
check "upload with password" curl -sS -f -F "password=hunter2" -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/upP.json"
KP="$(get "$TMP/upP.json" key)"
check "protected file serves a password form" test \
"$(curl -sS -o /dev/null -w '%{content_type}' "$BASE/d/$KP")" = "text/html; charset=utf-8"
check "wrong password is rejected" test \
"$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d 'password=nope' "$BASE/api/auth/$KP")" = "302"
check "correct password returns redirect" curl -sS -c "$TMP/cj.txt" -X POST -d "password=hunter2" "$BASE/api/auth/$KP" >/dev/null
curl -sS -b "$TMP/cj.txt" -o "$TMP/downP.txt" "$BASE/d/$KP"
check "download with cookie matches" cmp "$TMP/plain.txt" "$TMP/downP.txt"
echo echo
echo "passed: $pass, failed: $fail" echo "passed: $pass, failed: $fail"
[ "$fail" -eq 0 ] || exit 1 [ "$fail" -eq 0 ] || exit 1
+98
View File
@@ -0,0 +1,98 @@
import { makeAuthToken, parseAuthToken } from "./security";
export const AUTH_COOKIE = "fh_auth";
export const AUTH_TTL_MS = 30 * 60 * 1000;
export function parseCookie(header: string | null, name: string): string | null {
if (!header) return null;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
}
return null;
}
export function hasValidAuth(req: Request, secret: string, key: string): boolean {
const token = parseCookie(req.headers.get("cookie"), AUTH_COOKIE);
if (!token) return false;
const parsed = parseAuthToken(token, secret);
return parsed !== null && parsed.key === key;
}
export function authCookieValue(key: string, secret: string): string {
const token = makeAuthToken(key, secret, AUTH_TTL_MS);
return `${AUTH_COOKIE}=${token}`;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function renderPasswordForm(
key: string,
filename: string,
error: boolean,
): Response {
const message = error
? '<p class="err">Incorrect password. Please try again.</p>'
: "";
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Protected file</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f1115; color: #e6e8ee; min-height: 100vh;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.card {
width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a;
border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
}
h1 { margin: 0 0 4px; font-size: 19px; }
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; }
.err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
border: 1px solid rgba(224,82,82,.3); border-radius: 8px; padding: 9px 12px; }
input[type="password"] {
width: 100%; background: #0f1115; border: 1px solid #2a2f3a; color: #e6e8ee;
border-radius: 8px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
}
button { width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
padding: 11px 14px; font-size: 14px; background: #4f8cff; color: #fff; }
button:hover { background: #3a74e0; }
</style>
</head>
<body>
<main class="card">
<h1>Protected file</h1>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message}
<form method="post" action="/api/auth/${key}">
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
<button type="submit">Download</button>
</form>
</main>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "private, no-store",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
},
});
}
+11 -14
View File
@@ -2,8 +2,9 @@ const CR = 0x0d;
const LF = 0x0a; const LF = 0x0a;
const MAX_HEADER_LINE_BYTES = 64 * 1024; const MAX_HEADER_LINE_BYTES = 64 * 1024;
export interface MultipartFilePart { export interface MultipartPart {
filename: string; name: string;
filename: string | null;
contentType: string; contentType: string;
chunks: AsyncIterable<Uint8Array>; chunks: AsyncIterable<Uint8Array>;
} }
@@ -36,7 +37,7 @@ async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator
export async function* multipartParts( export async function* multipartParts(
body: ReadableStream<Uint8Array>, body: ReadableStream<Uint8Array>,
boundary: string, boundary: string,
): AsyncGenerator<MultipartFilePart> { ): AsyncGenerator<MultipartPart> {
const DELIMITER = Buffer.from(`--${boundary}`); const DELIMITER = Buffer.from(`--${boundary}`);
const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]); const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]);
const FINAL_MARKER = `--${boundary}--`; const FINAL_MARKER = `--${boundary}--`;
@@ -128,18 +129,14 @@ export async function* multipartParts(
const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition); const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition);
filename = quoted?.[1]; filename = quoted?.[1];
} }
const name = /name\s*=\s*"([^"]*)"/i.exec(disposition)?.[1] ?? "";
if (!filename) { yield {
for await (const _ of contentChunks()) { name,
void _; filename: filename ?? null,
} contentType: headers.get("content-type") ?? "application/octet-stream",
} else { chunks: contentChunks(),
yield { };
filename,
contentType: headers.get("content-type") ?? "application/octet-stream",
chunks: contentChunks(),
};
}
for (;;) { for (;;) {
const marker = await readLine(); const marker = await readLine();
+14 -1
View File
@@ -6,6 +6,7 @@ const STORAGE_KEY = "filehost.uploads";
const dropzone = $("dropzone"); const dropzone = $("dropzone");
const fileInput = $("fileInput"); const fileInput = $("fileInput");
const pwInput = $("pwInput");
const progress = $("progress"); const progress = $("progress");
const progressBar = $("progressBar"); const progressBar = $("progressBar");
const progressPct = $("progressPct"); const progressPct = $("progressPct");
@@ -14,6 +15,7 @@ const result = $("result");
const resultUrl = $("resultUrl"); const resultUrl = $("resultUrl");
const resultName = $("resultName"); const resultName = $("resultName");
const resultSize = $("resultSize"); const resultSize = $("resultSize");
const resultLock = $("resultLock");
const copyBtn = $("copyBtn"); const copyBtn = $("copyBtn");
const deleteBtn = $("deleteBtn"); const deleteBtn = $("deleteBtn");
const againBtn = $("againBtn"); const againBtn = $("againBtn");
@@ -57,10 +59,12 @@ function reset() {
uploading = false; uploading = false;
current = null; current = null;
result.classList.add("hidden"); result.classList.add("hidden");
resultLock.classList.add("hidden");
progress.classList.add("hidden"); progress.classList.add("hidden");
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressPct.textContent = "0%"; progressPct.textContent = "0%";
fileInput.value = ""; fileInput.value = "";
pwInput.value = "";
clearNotice(); clearNotice();
} }
@@ -160,6 +164,13 @@ function renderList() {
name.textContent = item.originalName; name.textContent = item.originalName;
name.title = item.originalName; name.title = item.originalName;
if (item.protected) {
const lock = document.createElement("span");
lock.className = "lock";
lock.textContent = "Password protected";
name.appendChild(lock);
}
const sub = document.createElement("span"); const sub = document.createElement("span");
sub.className = "file-item-sub"; sub.className = "file-item-sub";
let subText = formatBytes(item.size || 0); let subText = formatBytes(item.size || 0);
@@ -182,7 +193,6 @@ function renderList() {
dl.className = "btn-link"; dl.className = "btn-link";
dl.href = downloadUrl(item); dl.href = downloadUrl(item);
dl.textContent = "Download"; dl.textContent = "Download";
dl.setAttribute("download", "");
dl.title = "Open download link"; dl.title = "Open download link";
const copy = document.createElement("button"); const copy = document.createElement("button");
@@ -243,6 +253,7 @@ function showResult(data) {
resultName.textContent = data.originalName; resultName.textContent = data.originalName;
resultSize.textContent = formatBytes(data.size); resultSize.textContent = formatBytes(data.size);
resultUrl.value = downloadUrl(data); resultUrl.value = downloadUrl(data);
resultLock.classList.toggle("hidden", !data.protected);
current = data; current = data;
addUpload({ addUpload({
id: data.id, id: data.id,
@@ -250,6 +261,7 @@ function showResult(data) {
url: data.url, url: data.url,
originalName: data.originalName, originalName: data.originalName,
size: data.size, size: data.size,
protected: !!data.protected,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
} }
@@ -264,6 +276,7 @@ function startUpload(file) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const form = new FormData(); const form = new FormData();
if (pwInput.value) form.append("password", pwInput.value);
form.append("file", file); form.append("file", file);
xhr.upload.addEventListener("progress", (e) => { xhr.upload.addEventListener("progress", (e) => {
+11
View File
@@ -19,6 +19,16 @@
<p class="dz-sub">or click to choose</p> <p class="dz-sub">or click to choose</p>
</div> </div>
<div class="pw-row">
<input
type="password"
id="pwInput"
placeholder="Optional password (protects the download)"
autocomplete="off"
aria-label="Optional download password"
/>
</div>
<div id="progress" class="progress hidden"> <div id="progress" class="progress hidden">
<div class="progress-label"> <div class="progress-label">
<span id="progressName"></span> <span id="progressName"></span>
@@ -33,6 +43,7 @@
<div class="result-meta"> <div class="result-meta">
<strong id="resultName"></strong> <strong id="resultName"></strong>
<span id="resultSize"></span> <span id="resultSize"></span>
<span id="resultLock" class="lock hidden">Password protected</span>
</div> </div>
<div class="url-row"> <div class="url-row">
<input id="resultUrl" type="text" readonly aria-label="Download URL" /> <input id="resultUrl" type="text" readonly aria-label="Download URL" />
+32
View File
@@ -75,6 +75,38 @@ h1 {
font-size: 13px; font-size: 13px;
} }
.pw-row {
margin-top: 12px;
}
.pw-row input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.pw-row input:focus {
outline: none;
border-color: var(--accent);
}
.lock {
display: inline-block;
margin-left: 8px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
background: rgba(79, 140, 255, 0.12);
border: 1px solid rgba(79, 140, 255, 0.3);
border-radius: 99px;
padding: 2px 8px;
vertical-align: middle;
}
.progress { .progress {
margin-top: 20px; margin-top: 20px;
} }
+45
View File
@@ -0,0 +1,45 @@
import { AUTH_COOKIE, AUTH_TTL_MS } from "../auth";
import { notFound } from "../http";
import { log } from "../log";
import { fileIdFromKey, KEY_PATTERN, makeAuthToken, passwordMatches } from "../security";
import type { Storage } from "../storage";
export async function handleAuth(
req: Request,
storage: Storage,
rawKey: string,
secret: string,
secure: boolean,
): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound();
const id = fileIdFromKey(rawKey);
if (!id) return notFound();
const meta = await storage.readMetadata(id);
if (!meta) return notFound();
if (!meta.passwordHash) {
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}` } });
}
const password = await req.formData().then(
(form) => {
const value = form.get("password");
return typeof value === "string" ? value : "";
},
() => "",
);
if (!passwordMatches(meta.passwordHash, password)) {
log("warn", `auth failed id=${id}`);
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}?e=1` } });
}
const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS);
const cookie = `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${
secure ? "; Secure" : ""
}; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`;
log("info", `auth ok id=${id}`);
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}`, "set-cookie": cookie } });
}
+7
View File
@@ -1,4 +1,5 @@
import { lstat } from "node:fs/promises"; import { lstat } from "node:fs/promises";
import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http"; import { notFound } from "../http";
import { log } from "../log"; import { log } from "../log";
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
@@ -8,6 +9,7 @@ export async function handleDownload(
req: Request, req: Request,
storage: Storage, storage: Storage,
rawKey: string, rawKey: string,
secret: string,
): Promise<Response> { ): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound(); if (!KEY_PATTERN.test(rawKey)) return notFound();
const id = fileIdFromKey(rawKey); const id = fileIdFromKey(rawKey);
@@ -17,6 +19,11 @@ export async function handleDownload(
if (!meta) return notFound(); if (!meta) return notFound();
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) {
const url = new URL(req.url);
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
}
const filePath = storage.filePath(id); const filePath = storage.filePath(id);
let fileStat; let fileStat;
try { try {
+45 -13
View File
@@ -1,9 +1,27 @@
import { json, jsonError } from "../http"; import { json, jsonError } from "../http";
import { log } from "../log"; import { log } from "../log";
import { getBoundary, MultipartError, multipartParts } from "../multipart"; import { getBoundary, MultipartError, multipartParts, type MultipartPart } from "../multipart";
import { hashSecret, newDownloadKey, newFileId, sanitizeMime, sanitizeOriginalName } from "../security"; import {
hashPassword,
hashSecret,
newDownloadKey,
newFileId,
sanitizeMime,
sanitizeOriginalName,
} from "../security";
import { UploadTooLargeError, type Storage } from "../storage"; import { UploadTooLargeError, type Storage } from "../storage";
const MAX_PASSWORD_LENGTH = 256;
async function readField(part: MultipartPart, limit: number): Promise<string> {
let value = "";
for await (const chunk of part.chunks) {
value += Buffer.from(chunk).toString("utf8");
if (value.length > limit) throw new MultipartError("field too long");
}
return value;
}
export async function handleUpload( export async function handleUpload(
req: Request, req: Request,
storage: Storage, storage: Storage,
@@ -21,34 +39,48 @@ export async function handleUpload(
let mime = "application/octet-stream"; let mime = "application/octet-stream";
let size = 0; let size = 0;
let hasFile = false; let hasFile = false;
let password = "";
for await (const part of multipartParts(req.body, boundary)) { for await (const part of multipartParts(req.body, boundary)) {
if (!hasFile) { if (part.filename != null) {
hasFile = true; if (!hasFile) {
filename = part.filename; hasFile = true;
mime = sanitizeMime(part.contentType); filename = part.filename;
size = await storage.saveFile(id, part.chunks, maxFileSize); mime = sanitizeMime(part.contentType);
break; size = await storage.saveFile(id, part.chunks, maxFileSize);
} } else {
for await (const _ of part.chunks) { for await (const _ of part.chunks) {
void _; void _;
}
}
} else if (part.name === "password" && password === "") {
password = await readField(part, MAX_PASSWORD_LENGTH);
} else {
for await (const _ of part.chunks) {
void _;
}
} }
} }
if (!hasFile) return jsonError(400, "no file part in the upload"); if (!hasFile) return jsonError(400, "no file part in the upload");
const originalName = sanitizeOriginalName(filename); const originalName = sanitizeOriginalName(filename);
const protected_ = password.trim().length > 0;
await storage.writeMetadata({ await storage.writeMetadata({
id, id,
originalName, originalName,
size, size,
mimeType: mime, mimeType: mime,
downloadKeyHash: hashSecret(key), downloadKeyHash: hashSecret(key),
passwordHash: protected_ ? hashPassword(password) : undefined,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); log(
return json({ id, key, url: `/d/${key}`, originalName, size }); "info",
`upload ok id=${id} size=${size} name=${JSON.stringify(originalName)} protected=${protected_}`,
);
return json({ id, key, url: `/d/${key}`, originalName, size, protected: protected_ });
} catch (err) { } catch (err) {
await storage.delete(id); await storage.delete(id);
await storage.deletePartial(id); await storage.deletePartial(id);
+49 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
export const ID_BYTES = 16; export const ID_BYTES = 16;
export const KEY_BYTES = 32; export const KEY_BYTES = 32;
@@ -54,3 +54,51 @@ export function contentDisposition(name: string): string {
); );
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
} }
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `scrypt:${salt}:${hash}`;
}
export function passwordMatches(stored: string, password: string): boolean {
const parts = stored.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const salt = parts[1]!;
const expected = Buffer.from(parts[2]!, "hex");
const computed = scryptSync(password, salt, 64);
if (expected.length !== computed.length) return false;
return timingSafeEqual(expected, computed);
}
export function safeStringEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
function hmacSign(payload: string, secret: string): string {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function makeAuthToken(key: string, secret: string, ttlMs: number): string {
const expires = Date.now() + ttlMs;
const payload = `${key}.${expires}`;
return `${payload}.${hmacSign(payload, secret)}`;
}
export function parseAuthToken(token: string, secret: string): { key: string } | null {
const lastDot = token.lastIndexOf(".");
if (lastDot <= 0) return null;
const payload = token.slice(0, lastDot);
const signature = token.slice(lastDot + 1);
if (!safeStringEqual(signature, hmacSign(payload, secret))) return null;
const dot = payload.lastIndexOf(".");
if (dot <= 0) return null;
const key = payload.slice(0, dot);
const expires = Number(payload.slice(dot + 1));
if (!Number.isFinite(expires) || expires <= Date.now()) return null;
if (!KEY_PATTERN.test(key)) return null;
return { key };
}
+15 -1
View File
@@ -3,6 +3,7 @@ import { loadConfig } from "./config";
import { json, jsonError, notFound, securityHeaders } from "./http"; import { json, jsonError, notFound, securityHeaders } from "./http";
import { log } from "./log"; import { log } from "./log";
import { RateLimiter } from "./ratelimit"; import { RateLimiter } from "./ratelimit";
import { handleAuth } from "./routes/auth";
import { handleDelete } from "./routes/delete"; import { handleDelete } from "./routes/delete";
import { handleDownload } from "./routes/download"; import { handleDownload } from "./routes/download";
import { handleUpload } from "./routes/upload"; import { handleUpload } from "./routes/upload";
@@ -12,6 +13,7 @@ const cfg = loadConfig();
const storage = new Storage(cfg.storageDir); const storage = new Storage(cfg.storageDir);
await storage.init(); await storage.init();
const secret = await storage.loadSecret();
const sweep = await storage.sweep(); const sweep = await storage.sweep();
if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) { if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
log( log(
@@ -77,6 +79,11 @@ function serveStatic(path: string): Response {
}); });
} }
function isSecure(req: Request, url: URL): boolean {
if (cfg.trustProxy) return req.headers.get("x-forwarded-proto") === "https";
return url.protocol === "https:";
}
async function route(req: Request, srv: Server<undefined>): Promise<Response> { async function route(req: Request, srv: Server<undefined>): Promise<Response> {
const url = new URL(req.url); const url = new URL(req.url);
const path = url.pathname; const path = url.pathname;
@@ -92,11 +99,18 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
return handleUpload(req, storage, cfg.maxFileSize); return handleUpload(req, storage, cfg.maxFileSize);
} }
if (path.startsWith("/api/auth/") && method === "POST") {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests");
}
return handleAuth(req, storage, path.slice("/api/auth/".length), secret, isSecure(req, url));
}
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) { if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests"); return jsonError(429, "too many requests");
} }
return handleDownload(req, storage, path.slice(3)); return handleDownload(req, storage, path.slice(3), secret);
} }
if (path.startsWith("/api/files/") && method === "DELETE") { if (path.startsWith("/api/files/") && method === "DELETE") {
+15
View File
@@ -1,4 +1,5 @@
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import { ID_PATTERN } from "./security"; import { ID_PATTERN } from "./security";
@@ -8,6 +9,7 @@ export interface FileMeta {
size: number; size: number;
mimeType: string; mimeType: string;
downloadKeyHash: string; downloadKeyHash: string;
passwordHash?: string;
createdAt: string; createdAt: string;
} }
@@ -35,6 +37,19 @@ export class Storage {
await mkdir(this.metadataDir, { recursive: true }); await mkdir(this.metadataDir, { recursive: true });
} }
async loadSecret(): Promise<string> {
const path = join(this.root, ".secret");
try {
const existing = (await readFile(path, "utf8")).trim();
if (existing) return existing;
} catch {
/* missing or unreadable: create below */
}
const secret = randomBytes(32).toString("base64url");
await writeFile(path, secret, { encoding: "utf8", mode: 0o600 });
return secret;
}
filePath(id: string): string { filePath(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error("invalid file id"); if (!ID_PATTERN.test(id)) throw new Error("invalid file id");
return join(this.filesDir, id); return join(this.filesDir, id);