optional password
This commit is contained in:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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", () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user