Compare commits

...
3 Commits
Author SHA1 Message Date
Anaïs Saraiva e97eddbc1a reamping the mardkwon 2026-08-15 10:49:26 +02:00
Anaïs Saraiva 4867cf57b6 better 2026-08-15 10:46:10 +02:00
Anaïs Saraiva 6e76189e46 optional password 2026-08-15 10:42:20 +02:00
12 changed files with 206 additions and 67 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.
- 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
+95 -30
View File
@@ -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.
@@ -94,7 +93,7 @@ Example metadata record (`storage/metadata/<file-id>.json`):
```json
{
"id": "YOWJyhDXxhe3-L8MpEFBbA",
"id": "u8wcfcm7",
"originalName": "example.zip",
"size": 123456,
"mimeType": "application/zip",
@@ -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 "[email protected]" http://localhost:3000/api/upload
@@ -129,33 +128,98 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
```json
{
"id": "xmK5lLMRErlN",
"key": "xmK5lLMRErlN",
"url": "/xmK5lLMRErlN",
"id": "u8wcfcm7",
"key": "u8wcfcm7",
"url": "/u8wcfcm7",
"originalName": "example.zip",
"size": 123456,
"protected": true
}
```
`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.
`id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits
of entropy); the short length keeps share URLs tidy, and an optional password
can add a second factor of protection.
### Uploading with curl (tutorial)
Start an instance, then point curl at `/api/upload`:
```bash
BASE=https://download.wf
```
**1. Public upload (no password).** The only required field is `file`:
```bash
curl -sS -F "[email protected]" "$BASE/api/upload"
```
```json
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
"url": "/u8wcfcm7",
"originalName": "myfile.zip",
"size": 123456,
"protected": false
}
```
`url` is the share link — put it in a variable with `jq`:
```bash
url=$(curl -sS -F "[email protected]" "$BASE/api/upload" | jq -r .url)
echo "share: $BASE$url"
```
**2. Password-protected upload.** Add the `password` field; anyone with the link
must then enter it to download:
```bash
curl -sS -F "password=hunter2" -F "[email protected]" "$BASE/api/upload"
# => "protected": true
```
**3. Download.** Public files stream immediately:
```bash
curl -O "$BASE$url"
```
A password-protected file needs the auth cookie first:
```bash
curl -sS -c cookies.txt -X POST -d "password=hunter2" "$BASE/api/auth/$key"
curl -sS -b cookies.txt -O "$BASE/$key"
```
**4. Troubleshooting.**
- `400 expected a multipart/form-data upload` — missing or malformed
`Content-Type` (curl's `-F` already sets it correctly).
- `400 no file part in the upload` — no `file=` field was sent.
- `413 file too large` — the upload exceeds `MAX_FILE_SIZE`.
- `503 storage is full, try again later` — disk usage on the storage
filesystem is above `MAX_STORAGE_USAGE`; retry later.
Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id`
(see below).
### `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 download URL is just the key at the site root (`https://host/u8wcfcm7`).
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/u8wcfcm7 # 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 +232,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`
@@ -177,7 +242,7 @@ Permanently deletes the file and its metadata. The correct download key must be
sent in the `X-Delete-Key` header — proving you hold the secret.
```bash
curl -X DELETE -H "X-Delete-Key: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA
curl -X DELETE -H "X-Delete-Key: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7
```
### `GET /api/health`
@@ -194,18 +259,18 @@ paths or stack traces.
## Security model
- **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,
`Math.random()`, or filenames are ever used as secrets.
- **Secrets.** Each file gets a fresh 8-character lowercase alphanumeric key
(`crypto.randomBytes` mapped onto `a-z0-9`, ~41 bits of entropy), used as both
the share id and the download key. 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,
regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata
regex-validated ids (`^[a-z0-9]{12}$`). The original filename is metadata
only and is sanitized (basename, control/null bytes stripped) before being
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded
traversal, null bytes, and symlink injection via user input are not possible.
+39
View File
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="uploadGradient" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F46E5"/>
<stop offset="1" stop-color="#3B82F6"/>
</linearGradient>
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-opacity=".14"/>
</filter>
</defs>
<!-- Tuile blanche façon macOS, parfaitement centrée sur le canvas -->
<rect x="48" y="48" width="416" height="416" rx="94"
fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"
filter="url(#tileShadow)"/>
<!-- Document (bbox 168–344 horiz. / 144–368 vert. -> centré en 256,256) -->
<path
d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z"
fill="#fff"
stroke="#E7ECF5"
stroke-width="4"
/>
<!-- Coin plié -->
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
<!-- Flèche d'upload, centrée sur x=256 comme le document -->
<path
d="M256 300V196
M222 230l34-34 34 34
M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14"
stroke="url(#uploadGradient)"
stroke-width="30"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+7 -2
View File
@@ -50,8 +50,13 @@ dd if=/dev/urandom of="$TMP/random.bin" bs=1M count=16 2>/dev/null
check "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
check "upload without password is rejected (400)" test \
"$(curl -sS -o /dev/null -w '%{http_code}' -F "file=@$TMP/plain.txt" "$BASE/api/upload")" = "400"
curl -sS -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/pub.json"
PUB_ID="$(get "$TMP/pub.json" id)"
check "upload without password succeeds" test -n "$PUB_ID"
check "upload without password is public" test "$(get "$TMP/pub.json" protected)" = "false"
curl -sS -o "$TMP/pubdown.txt" "$BASE$(get "$TMP/pub.json" url)"
check "public file downloads directly" cmp "$TMP/plain.txt" "$TMP/pubdown.txt"
curl -sS -f -X DELETE -H "X-Delete-Key: $PUB_ID" "$BASE/api/files/$PUB_ID" >/dev/null
up "$TMP/plain.txt" > "$TMP/up.json"
KEY="$(get "$TMP/up.json" key)"
+14
View File
@@ -60,6 +60,7 @@ export function renderPasswordForm(
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);
}
.brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; }
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);
@@ -75,6 +76,19 @@ export function renderPasswordForm(
</head>
<body>
<main class="card">
<svg class="brand-logo" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="uG" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F46E5"/>
<stop offset="1" stop-color="#3B82F6"/>
</linearGradient>
</defs>
<rect x="48" y="48" width="416" height="416" rx="94" fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"/>
<path d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z" fill="#fff" stroke="#E7ECF5" stroke-width="4"/>
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
<path d="M256 300V196 M222 230l34-34 34 34 M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14" stroke="url(#uG)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>Protected file</h1>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message}
+8 -9
View File
@@ -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", () => {
+7 -7
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="data:," />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>File Drop</title>
<link rel="stylesheet" href="/style.css" />
</head>
@@ -12,10 +12,10 @@
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">FD</div>
<img class="logo" src="/logo.svg" width="40" height="40" alt="File Drop" />
<div class="brand-text">
<h1>File Drop</h1>
<span class="sub">Private, password-protected file hosting</span>
<span class="sub">Private file hosting</span>
</div>
</div>
<div class="topbar-actions">
@@ -36,11 +36,11 @@
</div>
<div class="field">
<label for="pwInput">Password</label>
<label for="pwInput">Password <span class="optional">(optional)</span></label>
<input
type="password"
id="pwInput"
placeholder="Required — protects the download"
placeholder="Optional — protects the download"
autocomplete="new-password"
/>
</div>
@@ -60,7 +60,7 @@
<div id="result" class="result hidden">
<div class="result-head">
<span id="resultName"></span>
<span class="lock">Protected</span>
<span id="resultLock" class="lock">Protected</span>
</div>
<div class="url-row">
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
@@ -77,7 +77,7 @@
<span id="fileCount" class="count"></span>
</div>
<p class="files-note">
Stored locally in this browser only. Every file requires its password.
Stored locally in this browser only.
</p>
<div class="table-wrap">
+13 -7
View File
@@ -59,13 +59,7 @@ button {
.logo {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
font-weight: 700;
display: grid;
place-items: center;
letter-spacing: 0.5px;
display: block;
}
.brand-text h1 {
@@ -213,6 +207,13 @@ button {
letter-spacing: 0.3px;
}
.field label .optional {
font-weight: 500;
text-transform: lowercase;
letter-spacing: 0;
opacity: 0.75;
}
.field input {
width: 100%;
background: var(--bg);
@@ -403,6 +404,11 @@ button {
padding: 2px 8px;
}
.lock.public {
background: rgba(130, 190, 140, 0.1);
border-color: rgba(130, 190, 140, 0.3);
}
.file-sub {
display: block;
font-size: 12px;
+1 -1
View File
@@ -24,7 +24,7 @@ export async function handleDownload(
}
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);
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 (!password.trim()) return jsonError(400, "a password is required");
const originalName = sanitizeOriginalName(filename);
const passwordHash = password.trim() ? hashPassword(password) : undefined;
await storage.writeMetadata({
id,
originalName,
size,
mimeType: mime,
downloadKeyHash: hashSecret(key),
passwordHash: hashPassword(password),
passwordHash,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
});
const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`;
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) {
await storage.delete(id);
await storage.deletePartial(id);
+16 -7
View File
@@ -1,18 +1,27 @@
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
export const KEY_BYTES = 9; // 9 bytes base64url -> 12 chars
export const KEY_LENGTH = 12;
export const KEY_LENGTH = 8;
export const KEY_PATTERN = /^[A-Za-z0-9_-]{12}$/;
// Keys are lowercase alphanumeric only (a-z0-9), 8 characters.
export const KEY_PATTERN = /^[a-z0-9]{8}$/;
// The id and the download key are the same 12-char token.
export const ID_PATTERN = KEY_PATTERN;
export function randomToken(byteCount: number): string {
return randomBytes(byteCount).toString("base64url");
}
const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789";
const CHARSET_SIZE = KEY_CHARSET.length;
const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE;
export function newFileId(): string {
return randomToken(KEY_BYTES);
let out = "";
const bytes = randomBytes(KEY_LENGTH * 2);
let i = 0;
while (out.length < KEY_LENGTH) {
const b = bytes[i++];
if (b === undefined) throw new Error("random source exhausted");
if (b >= MAX_BYTE) continue;
out += KEY_CHARSET[b % CHARSET_SIZE];
}
return out;
}
export function hashSecret(value: string): string {
+1
View File
@@ -42,6 +42,7 @@ const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
"/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" },
};
const staticContent = new Map<string, { body: string; type: string }>();