optional password

This commit is contained in:
Anaïs Saraiva
2026-08-15 10:42:20 +02:00
parent 12d9e99aa2
commit 6e76189e46
7 changed files with 50 additions and 38 deletions
+2 -1
View File
@@ -244,8 +244,9 @@ 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`.
- 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`). 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 - 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 auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped - Files auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped
+19 -19
View File
@@ -10,7 +10,7 @@ database, no object storage, and no queue. Expired files are swept automatically
## Features ## Features
- Drag & drop / file-picker uploads with progress, straight from the browser. - 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. - 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.
@@ -71,9 +71,8 @@ Then open http://localhost:3000.
2. Drop `example.zip` (or click to pick a file). 2. Drop `example.zip` (or click to pick a file).
3. The server stores it and returns a URL like 3. The server stores it and returns a URL like
`https://files.example.com/8fJ...random-secret...`. `https://files.example.com/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file — **after entering 4. Copy the URL. Anyone possessing it can download the file. If you set a
the password you set at upload**. Nobody without the secret URL and the password, they must enter it before the download starts.
password can download it.
5. The file is removed automatically after 48 hours, or earlier by explicit 5. The file is removed automatically after 48 hours, or earlier by explicit
deletion. deletion.
@@ -119,9 +118,9 @@ touched.
### `POST /api/upload` ### `POST /api/upload`
Multipart/form-data with a `file` field and a **required** `password` field Multipart/form-data with a `file` field and an **optional** `password` field
(this is what the web UI uses). Uploads without a password are rejected with (this is what the web UI uses). When no password is sent, the file is
`400`. downloadable by anyone with the URL.
```bash ```bash
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
@@ -139,23 +138,23 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
``` ```
`id` and `key` are the same 12-character base64url token (~72 bits of entropy); `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 the short length keeps share URLs tidy, and an optional password can add a
factor of protection. second factor of protection.
### `GET /:key` (also `HEAD`) ### `GET /:key` (also `HEAD`)
The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`). 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 The legacy `/d/:key` form still works as an alias. A file without a password
password-protected: without a valid auth cookie the server shows a password streams immediately; if a password is set, the server shows a password prompt
prompt; a wrong or non-existent key returns a generic `404`. before streaming. A wrong or non-existent key returns a generic `404`.
```bash ```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 ### 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. 1. `GET /:key` returns a small HTML password form.
2. The browser submits it to `POST /api/auth/:key` with `password=...`. 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 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 forged, and it survives server restarts. Without it, a password-protected file
downloadable — not even by the uploader. is never downloadable — not even by the uploader. Files uploaded without a
password stream immediately and have no auth gate.
### `DELETE /api/files/:id` ### `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 - **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 `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 encoded base64url; guessing one is infeasible at scale, and an optional
password is an added second factor. No timestamps, incrementing ids, password can be an added second factor. No timestamps, incrementing ids,
`Math.random()`, or filenames are ever used as secrets. `Math.random()`, 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.** 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 (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed
cookie, so access is granted by the server and cannot be forged. 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,
+8 -9
View File
@@ -18,6 +18,7 @@ const progressName = $("progressName");
const result = $("result"); const result = $("result");
const resultUrl = $("resultUrl"); const resultUrl = $("resultUrl");
const resultName = $("resultName"); const resultName = $("resultName");
const resultLock = $("resultLock");
const copyBtn = $("copyBtn"); const copyBtn = $("copyBtn");
const notice = $("notice"); const notice = $("notice");
const fileList = $("fileList"); const fileList = $("fileList");
@@ -171,7 +172,8 @@ function renderList() {
name.title = item.originalName; name.title = item.originalName;
const lock = document.createElement("span"); const lock = document.createElement("span");
lock.className = "lock"; lock.className = "lock";
lock.textContent = "Protected"; lock.textContent = item.protected ? "Protected" : "Public";
if (!item.protected) lock.classList.add("public");
nameWrap.appendChild(name); nameWrap.appendChild(name);
nameWrap.appendChild(lock); nameWrap.appendChild(lock);
nameTd.appendChild(nameWrap); nameTd.appendChild(nameWrap);
@@ -254,6 +256,8 @@ function showResult(data) {
progress.classList.add("hidden"); progress.classList.add("hidden");
result.classList.remove("hidden"); result.classList.remove("hidden");
resultName.textContent = data.originalName; resultName.textContent = data.originalName;
resultLock.textContent = data.protected ? "Protected" : "Public";
resultLock.classList.toggle("public", !data.protected);
resultUrl.value = downloadUrl(data); resultUrl.value = downloadUrl(data);
addUpload({ addUpload({
id: data.id, id: data.id,
@@ -262,6 +266,7 @@ function showResult(data) {
originalName: data.originalName, originalName: data.originalName,
size: data.size, size: data.size,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
protected: !!data.protected,
}); });
} }
@@ -277,7 +282,7 @@ function startUpload(file, password) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const form = new FormData(); const form = new FormData();
form.append("password", password); if (password) form.append("password", password);
form.append("file", file); form.append("file", file);
xhr.upload.addEventListener("progress", (e) => { xhr.upload.addEventListener("progress", (e) => {
@@ -344,13 +349,7 @@ function submitUpload() {
showNotice("Choose a file first.", "error"); showNotice("Choose a file first.", "error");
return; return;
} }
const password = pwInput.value; startUpload(currentFile, pwInput.value);
if (!password) {
showNotice("A password is required to protect this file.", "error");
pwInput.focus();
return;
}
startUpload(currentFile, password);
} }
dropzone.addEventListener("click", () => { dropzone.addEventListener("click", () => {
+5 -5
View File
@@ -15,7 +15,7 @@
<div class="logo" aria-hidden="true">FD</div> <div class="logo" aria-hidden="true">FD</div>
<div class="brand-text"> <div class="brand-text">
<h1>File Drop</h1> <h1>File Drop</h1>
<span class="sub">Private, password-protected file hosting</span> <span class="sub">Private file hosting</span>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
@@ -36,11 +36,11 @@
</div> </div>
<div class="field"> <div class="field">
<label for="pwInput">Password</label> <label for="pwInput">Password <span class="optional">(optional)</span></label>
<input <input
type="password" type="password"
id="pwInput" id="pwInput"
placeholder="Required — protects the download" placeholder="Optional — protects the download"
autocomplete="new-password" autocomplete="new-password"
/> />
</div> </div>
@@ -60,7 +60,7 @@
<div id="result" class="result hidden"> <div id="result" class="result hidden">
<div class="result-head"> <div class="result-head">
<span id="resultName"></span> <span id="resultName"></span>
<span class="lock">Protected</span> <span id="resultLock" class="lock">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" />
@@ -77,7 +77,7 @@
<span id="fileCount" class="count"></span> <span id="fileCount" class="count"></span>
</div> </div>
<p class="files-note"> <p class="files-note">
Stored locally in this browser only. Every file requires its password. Stored locally in this browser only.
</p> </p>
<div class="table-wrap"> <div class="table-wrap">
+12
View File
@@ -213,6 +213,13 @@ button {
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.field label .optional {
font-weight: 500;
text-transform: lowercase;
letter-spacing: 0;
opacity: 0.75;
}
.field input { .field input {
width: 100%; width: 100%;
background: var(--bg); background: var(--bg);
@@ -403,6 +410,11 @@ button {
padding: 2px 8px; padding: 2px 8px;
} }
.lock.public {
background: rgba(130, 190, 140, 0.1);
border-color: rgba(130, 190, 140, 0.3);
}
.file-sub { .file-sub {
display: block; display: block;
font-size: 12px; font-size: 12px;
+1 -1
View File
@@ -24,7 +24,7 @@ export async function handleDownload(
} }
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
if (!hasValidAuth(req, secret, rawKey)) { if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) {
const url = new URL(req.url); const url = new URL(req.url);
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e")); return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
} }
+3 -3
View File
@@ -74,23 +74,23 @@ export async function handleUpload(
} }
if (!hasFile) return jsonError(400, "no file part in the upload"); if (!hasFile) return jsonError(400, "no file part in the upload");
if (!password.trim()) return jsonError(400, "a password is required");
const originalName = sanitizeOriginalName(filename); const originalName = sanitizeOriginalName(filename);
const passwordHash = password.trim() ? hashPassword(password) : undefined;
await storage.writeMetadata({ await storage.writeMetadata({
id, id,
originalName, originalName,
size, size,
mimeType: mime, mimeType: mime,
downloadKeyHash: hashSecret(key), downloadKeyHash: hashSecret(key),
passwordHash: hashPassword(password), passwordHash,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttlMs).toISOString(), expiresAt: new Date(Date.now() + ttlMs).toISOString(),
}); });
const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`; const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`;
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
return json({ id, key, url, originalName, size, protected: true }); return json({ id, key, url, originalName, size, protected: !!passwordHash });
} catch (err) { } catch (err) {
await storage.delete(id); await storage.delete(id);
await storage.deletePartial(id); await storage.deletePartial(id);