diff --git a/HOSTING.md b/HOSTING.md index 26c5582..0af4706 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -241,6 +241,8 @@ Files in `storage/` are untouched by updates and keep working. - The app binds to loopback; only the proxy is exposed. - 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 when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`. - Files never expire. Deletion requires possession of the key diff --git a/README.md b/README.md index ce5b908..46bd687 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# File Drop +# File Drop (VYBECODED) A minimal, self-hosted private file hosting service built with **Bun** and **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 - 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. - A cryptographically secure, unguessable download key for every file. - 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 `https://files.example.com/d/8fJ...random-secret...`. 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 deletion. @@ -76,6 +77,7 @@ Then open http://localhost:3000. ``` storage/ +├── .secret # server signing secret (0600), auto-generated ├── files/ │ ├── # the raw uploaded bytes │ └── @@ -98,7 +100,10 @@ Example metadata record (`storage/metadata/.json`): ``` 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 `.part` temp file and atomically renamed into place on completion. At startup the server sweeps for orphans (a file @@ -110,10 +115,12 @@ touched. ### `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 curl -F "file=@example.zip" http://localhost:3000/api/upload +curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/upload ``` ```json @@ -122,19 +129,39 @@ curl -F "file=@example.zip" http://localhost:3000/api/upload "key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "originalName": "example.zip", - "size": 123456 + "size": 123456, + "protected": false } ``` ### `GET /d/:key` (also `HEAD`) 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 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` 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. - **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use 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, regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata 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 + -TypeScript + filesystem + a simple frontend + secret URLs. \ No newline at end of file +TypeScript + filesystem + a simple frontend + secret URLs. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a305cc3..ecdded7 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -73,6 +73,18 @@ check "delete with key succeeds" \ check "file gone after delete" test \ "$(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 "passed: $pass, failed: $fail" [ "$fail" -eq 0 ] || exit 1 \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..05268e3 --- /dev/null +++ b/src/auth.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function renderPasswordForm( + key: string, + filename: string, + error: boolean, +): Response { + const message = error + ? '

Incorrect password. Please try again.

' + : ""; + const html = ` + + + + + + Protected file + + + +
+

Protected file

+

Enter the password to download ${escapeHtml(filename)}.

+ ${message} +
+ + +
+
+ +`; + 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'", + }, + }); +} \ No newline at end of file diff --git a/src/multipart.ts b/src/multipart.ts index e030a63..3d1536c 100644 --- a/src/multipart.ts +++ b/src/multipart.ts @@ -2,8 +2,9 @@ const CR = 0x0d; const LF = 0x0a; const MAX_HEADER_LINE_BYTES = 64 * 1024; -export interface MultipartFilePart { - filename: string; +export interface MultipartPart { + name: string; + filename: string | null; contentType: string; chunks: AsyncIterable; } @@ -36,7 +37,7 @@ async function* streamChunks(stream: ReadableStream): AsyncGenerator export async function* multipartParts( body: ReadableStream, boundary: string, -): AsyncGenerator { +): AsyncGenerator { const DELIMITER = Buffer.from(`--${boundary}`); const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]); const FINAL_MARKER = `--${boundary}--`; @@ -128,18 +129,14 @@ export async function* multipartParts( const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition); filename = quoted?.[1]; } + const name = /name\s*=\s*"([^"]*)"/i.exec(disposition)?.[1] ?? ""; - if (!filename) { - for await (const _ of contentChunks()) { - void _; - } - } else { - yield { - filename, - contentType: headers.get("content-type") ?? "application/octet-stream", - chunks: contentChunks(), - }; - } + yield { + name, + filename: filename ?? null, + contentType: headers.get("content-type") ?? "application/octet-stream", + chunks: contentChunks(), + }; for (;;) { const marker = await readLine(); diff --git a/src/public/app.js b/src/public/app.js index 98c8766..92a0320 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -6,6 +6,7 @@ const STORAGE_KEY = "filehost.uploads"; const dropzone = $("dropzone"); const fileInput = $("fileInput"); +const pwInput = $("pwInput"); const progress = $("progress"); const progressBar = $("progressBar"); const progressPct = $("progressPct"); @@ -14,6 +15,7 @@ const result = $("result"); const resultUrl = $("resultUrl"); const resultName = $("resultName"); const resultSize = $("resultSize"); +const resultLock = $("resultLock"); const copyBtn = $("copyBtn"); const deleteBtn = $("deleteBtn"); const againBtn = $("againBtn"); @@ -57,10 +59,12 @@ function reset() { uploading = false; current = null; result.classList.add("hidden"); + resultLock.classList.add("hidden"); progress.classList.add("hidden"); progressBar.style.width = "0%"; progressPct.textContent = "0%"; fileInput.value = ""; + pwInput.value = ""; clearNotice(); } @@ -160,6 +164,13 @@ function renderList() { name.textContent = 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"); sub.className = "file-item-sub"; let subText = formatBytes(item.size || 0); @@ -182,7 +193,6 @@ function renderList() { dl.className = "btn-link"; dl.href = downloadUrl(item); dl.textContent = "Download"; - dl.setAttribute("download", ""); dl.title = "Open download link"; const copy = document.createElement("button"); @@ -243,6 +253,7 @@ function showResult(data) { resultName.textContent = data.originalName; resultSize.textContent = formatBytes(data.size); resultUrl.value = downloadUrl(data); + resultLock.classList.toggle("hidden", !data.protected); current = data; addUpload({ id: data.id, @@ -250,6 +261,7 @@ function showResult(data) { url: data.url, originalName: data.originalName, size: data.size, + protected: !!data.protected, createdAt: new Date().toISOString(), }); } @@ -264,6 +276,7 @@ function startUpload(file) { const xhr = new XMLHttpRequest(); const form = new FormData(); + if (pwInput.value) form.append("password", pwInput.value); form.append("file", file); xhr.upload.addEventListener("progress", (e) => { diff --git a/src/public/index.html b/src/public/index.html index 1e4065e..db3b93d 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -19,6 +19,16 @@

or click to choose

+
+ +
+