From 6e76189e46323d0972c3104c8a4e764a4e823f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:42:20 +0200 Subject: [PATCH] optional password --- HOSTING.md | 3 ++- README.md | 38 +++++++++++++++++++------------------- src/public/app.js | 17 ++++++++--------- src/public/index.html | 10 +++++----- src/public/style.css | 12 ++++++++++++ src/routes/download.ts | 2 +- src/routes/upload.ts | 6 +++--- 7 files changed, 50 insertions(+), 38 deletions(-) diff --git a/HOSTING.md b/HOSTING.md index 23c6180..8c81460 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -244,8 +244,9 @@ 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`. -- Every file requires a password, stored only as an scrypt hash; entry mints a +- Files uploaded with a password store only its scrypt hash; entry mints a short-lived HMAC-signed cookie (server secret in `storage/.secret`). + Password-less files stream immediately to anyone with the key. - 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 auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped diff --git a/README.md b/README.md index aea0b80..9c701b5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ database, no object storage, and no queue. Expired files are swept automatically ## Features - Drag & drop / file-picker uploads with progress, straight from the browser. -- **Required per-file password**: every download is protected by a password. +- **Optional per-file password**: set a password to gate downloads behind a prompt. - 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. @@ -71,9 +71,8 @@ Then open http://localhost:3000. 2. Drop `example.zip` (or click to pick a file). 3. The server stores it and returns a URL like `https://files.example.com/8fJ...random-secret...`. -4. Copy the URL. Anyone possessing it can download the file — **after entering - the password you set at upload**. Nobody without the secret URL and the - password can download it. +4. Copy the URL. Anyone possessing it can download the file. If you set a + password, they must enter it before the download starts. 5. The file is removed automatically after 48 hours, or earlier by explicit deletion. @@ -119,9 +118,9 @@ touched. ### `POST /api/upload` -Multipart/form-data with a `file` field and a **required** `password` field -(this is what the web UI uses). Uploads without a password are rejected with -`400`. +Multipart/form-data with a `file` field and an **optional** `password` field +(this is what the web UI uses). When no password is sent, the file is +downloadable by anyone with the URL. ```bash curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/upload @@ -139,23 +138,23 @@ curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/uploa ``` `id` and `key` are the same 12-character base64url token (~72 bits of entropy); -the short length keeps share URLs tidy, and the required password adds a second -factor of protection. +the short length keeps share URLs tidy, and an optional password can add a +second factor of protection. ### `GET /:key` (also `HEAD`) The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`). -The legacy `/d/:key` form still works as an alias. Every file is -password-protected: without a valid auth cookie the server shows a password -prompt; a wrong or non-existent key returns a generic `404`. +The legacy `/d/:key` form still works as an alias. A file without a password +streams immediately; if a password is set, the server shows a password prompt +before streaming. A wrong or non-existent key returns a generic `404`. ```bash -curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first +curl -O http://localhost:3000/xmK5lLMRErlN # streams, or prompts if password set ``` ### Password protection -Every file requires its password: +Files uploaded with a password are gated like this: 1. `GET /:key` returns a small HTML password form. 2. The browser submits it to `POST /api/auth/:key` with `password=...`. @@ -168,8 +167,9 @@ curl -b cookies.txt -O http://localhost:3000/:key ``` The cookie is signed with a server secret (`storage/.secret`) so it cannot be -forged, and it survives server restarts. Without it, a file is never -downloadable — not even by the uploader. +forged, and it survives server restarts. Without it, a password-protected file +is never downloadable — not even by the uploader. Files uploaded without a +password stream immediately and have no auth gate. ### `DELETE /api/files/:id` @@ -196,12 +196,12 @@ paths or stack traces. - **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via `crypto.randomBytes`, used as both the share id and the download key. Keys are - encoded base64url; guessing one is infeasible at scale, and the required - password is an added second factor. No timestamps, incrementing ids, + encoded base64url; guessing one is infeasible at scale, and an optional + password can be an added second factor. No timestamps, incrementing ids, `Math.random()`, 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.** Every file has a password stored only as an **scrypt** hash +- **Passwords.** A file's password (if any) 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, diff --git a/src/public/app.js b/src/public/app.js index 177a01b..0ba5612 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -18,6 +18,7 @@ const progressName = $("progressName"); const result = $("result"); const resultUrl = $("resultUrl"); const resultName = $("resultName"); +const resultLock = $("resultLock"); const copyBtn = $("copyBtn"); const notice = $("notice"); const fileList = $("fileList"); @@ -171,7 +172,8 @@ function renderList() { name.title = item.originalName; const lock = document.createElement("span"); lock.className = "lock"; - lock.textContent = "Protected"; + lock.textContent = item.protected ? "Protected" : "Public"; + if (!item.protected) lock.classList.add("public"); nameWrap.appendChild(name); nameWrap.appendChild(lock); nameTd.appendChild(nameWrap); @@ -254,6 +256,8 @@ function showResult(data) { progress.classList.add("hidden"); result.classList.remove("hidden"); resultName.textContent = data.originalName; + resultLock.textContent = data.protected ? "Protected" : "Public"; + resultLock.classList.toggle("public", !data.protected); resultUrl.value = downloadUrl(data); addUpload({ id: data.id, @@ -262,6 +266,7 @@ function showResult(data) { originalName: data.originalName, size: data.size, createdAt: new Date().toISOString(), + protected: !!data.protected, }); } @@ -277,7 +282,7 @@ function startUpload(file, password) { const xhr = new XMLHttpRequest(); const form = new FormData(); - form.append("password", password); + if (password) form.append("password", password); form.append("file", file); xhr.upload.addEventListener("progress", (e) => { @@ -344,13 +349,7 @@ function submitUpload() { showNotice("Choose a file first.", "error"); return; } - const password = pwInput.value; - if (!password) { - showNotice("A password is required to protect this file.", "error"); - pwInput.focus(); - return; - } - startUpload(currentFile, password); + startUpload(currentFile, pwInput.value); } dropzone.addEventListener("click", () => { diff --git a/src/public/index.html b/src/public/index.html index 2f85d76..577c8c7 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -15,7 +15,7 @@

File Drop

- Private, password-protected file hosting + Private file hosting
@@ -36,11 +36,11 @@
- +
@@ -60,7 +60,7 @@