Compare commits

...
2 Commits
Author SHA1 Message Date
Anaïs Saraiva d8b9fbe993 UPDATED 2026-08-15 00:35:22 +02:00
Anaïs Saraiva 954384d1e4 security 2026-08-15 00:28:32 +02:00
14 changed files with 895 additions and 349 deletions
+2
View File
@@ -241,6 +241,8 @@ 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
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
- 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 never expire. Deletion requires possession of the key - Files never expire. Deletion requires possession of the key
+41 -11
View File
@@ -1,4 +1,4 @@
# File Drop # File Drop (VYBECODED)
A minimal, self-hosted private file hosting service built with **Bun** and A minimal, self-hosted private file hosting service built with **Bun** and
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are **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 ## 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.
- Permanent storage on the local filesystem; files survive restarts. - Permanent 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.
@@ -67,8 +68,9 @@ 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 permanently and returns a URL like 3. The server stores it permanently and returns a URL like
`https://files.example.com/d/8fJ...random-secret...`. `https://files.example.com/d/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file; nobody without the 4. Copy the URL. Anyone possessing it can download the file — **after entering
secret can. the password you set at upload**. Nobody without the secret URL and the
password can download it.
5. The file stays available indefinitely. It is removed only by explicit 5. The file stays available indefinitely. It is removed only by explicit
deletion. deletion.
@@ -76,6 +78,7 @@ Then open http://localhost:3000.
``` ```
storage/ storage/
├── .secret # server signing secret (0600), auto-generated
├── files/ ├── files/
│ ├── <file-id> # the raw uploaded bytes │ ├── <file-id> # the raw uploaded bytes
│ └── <file-id> │ └── <file-id>
@@ -98,7 +101,10 @@ Example metadata record (`storage/metadata/<file-id>.json`):
``` ```
Only a SHA-256 **hash** of the download key is stored — the plaintext key exists 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 `<file-id>.part` temp file and atomically renamed Uploads are first streamed to a `<file-id>.part` temp file and atomically renamed
into place on completion. At startup the server sweeps for orphans (a file into place on completion. At startup the server sweeps for orphans (a file
@@ -110,10 +116,12 @@ touched.
### `POST /api/upload` ### `POST /api/upload`
Multipart/form-data with a `file` field (this is what the web UI uses). 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`.
```bash ```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 ```json
@@ -122,19 +130,38 @@ curl -F "[email protected]" http://localhost:3000/api/upload
"key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456 "size": 123456,
"protected": true
} }
``` ```
### `GET /d/:key` (also `HEAD`) ### `GET /d/:key` (also `HEAD`)
Streams the file. Requires the full secret key; any wrong or non-existent key Every file is password-protected. Without a valid auth cookie the server shows a
returns a generic `404`. password prompt; a wrong or non-existent key returns a generic `404`.
```bash ```bash
curl -O http://localhost:3000/d/YOWJyh...7OQ curl -O http://localhost:3000/d/YOWJyh...7OQ # prompts for password first
``` ```
### Password protection
Every file requires its password:
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 file is never
downloadable — not even by the uploader.
### `DELETE /api/files/:id` ### `DELETE /api/files/:id`
Permanently deletes the file and its metadata. The correct download key must be 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. 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
(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, - **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-Za-z0-9_-]{22}$`). The original filename is metadata
only and is sanitized (basename, control/null bytes stripped) before being 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 + Intentionally small: no framework, no DI, no ORM, no repositories. Bun +
TypeScript + filesystem + a simple frontend + secret URLs. TypeScript + filesystem + a simple frontend + secret URLs.
+37 -14
View File
@@ -9,7 +9,9 @@
set -euo pipefail set -euo pipefail
BASE="${BASE_URL:-http://127.0.0.1:3000}" BASE="${BASE_URL:-http://127.0.0.1:3000}"
PW="hunter2"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
COOKIES="$TMP/cookies.txt"
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
pass=0 pass=0
@@ -30,43 +32,64 @@ get() { # get <jsonfile> <key>
bun -e 'const d = JSON.parse(await Bun.file(process.argv[3]).text()); console.log(d[process.argv[2]])' _ "$2" "$1" bun -e 'const d = JSON.parse(await Bun.file(process.argv[3]).text()); console.log(d[process.argv[2]])' _ "$2" "$1"
} }
up() { # up <file> [name] -> upload with the test password, writes json
curl -sS -f -F "password=$PW" -F "file=@$1;filename=${2:-$(basename "$1")}" "$BASE/api/upload"
}
auth() { # auth <key> -> POST password, stores cookie
rm -f "$COOKIES"
curl -sS -c "$COOKIES" -X POST -d "password=$PW" "$BASE/api/auth/$1" >/dev/null
}
fetch_auth() { # fetch_auth <key> <outfile>
curl -sS -b "$COOKIES" -o "$2" "$BASE/d/$1"
}
printf 'hello world\n' > "$TMP/plain.txt" printf 'hello world\n' > "$TMP/plain.txt"
dd if=/dev/urandom of="$TMP/random.bin" bs=1M count=16 2>/dev/null 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 "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
check "upload plain file" curl -sS -f -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/up.json" 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"
up "$TMP/plain.txt" > "$TMP/up.json"
KEY="$(get "$TMP/up.json" key)" KEY="$(get "$TMP/up.json" key)"
ID="$(get "$TMP/up.json" id)" ID="$(get "$TMP/up.json" id)"
URL="$(get "$TMP/up.json" url)" URL="$(get "$TMP/up.json" url)"
[ -n "$KEY" ] && check "upload returns a key" true [ -n "$KEY" ] && check "upload returns a key" true
check "download with key" curl -sS -f -o "$TMP/down.txt" "$BASE$URL" check "protected file serves a password form" test \
check "downloaded content matches" cmp "$TMP/plain.txt" "$TMP/down.txt" "$(curl -sS -o /dev/null -w '%{content_type}' "$BASE$URL")" = "text/html; charset=utf-8"
check "invalid key returns 404" test \ check "invalid key returns 404" test \
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")" = "404" "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")" = "404"
check "id alone cannot download" test \ check "id alone cannot download" test \
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/$ID")" = "404" "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/$ID")" = "404"
check "storage directory not served" test \ check "storage directory not served" test \
"$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404" "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404"
check "unicode filename upload" \ check "wrong password is rejected (redirect to error)" test \
curl -sS -f -F "file=@$TMP/plain.txt;filename=caf%C3%A9-%E6%96%87%E4%BB%B6.txt" "$BASE/api/upload" > "$TMP/upU.json" "$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d 'password=nope' "$BASE/api/auth/$KEY")" = "302"
auth "$KEY"
fetch_auth "$KEY" "$TMP/down.txt"
check "download with cookie matches" cmp "$TMP/plain.txt" "$TMP/down.txt"
up "$TMP/plain.txt" "caf%C3%A9-%E6%96%87%E4%BB%B6.txt" > "$TMP/upU.json"
KU="$(get "$TMP/upU.json" key)" KU="$(get "$TMP/upU.json" key)"
check "unicode filename download" curl -sS -f -o /dev/null "$BASE/d/$KU" auth "$KU"
fetch_auth "$KU" "$TMP/downU.txt"
check "unicode filename download" test -s "$TMP/downU.txt"
check "path traversal filename sanitized" \ up "$TMP/plain.txt" "../../../../etc/passwd" > "$TMP/upT.json"
curl -sS -f -F "file=@$TMP/plain.txt;filename=../../../../etc/passwd" "$BASE/api/upload" > "$TMP/upT.json" check "path traversal filename sanitized" true
check "upload 16MB binary" curl -sS -f -F "file=@$TMP/random.bin" "$BASE/api/upload" > "$TMP/upL.json" up "$TMP/random.bin" > "$TMP/upL.json"
KL="$(get "$TMP/upL.json" key)" KL="$(get "$TMP/upL.json" key)"
check "download 16MB binary" curl -sS -f -o "$TMP/random.out" "$BASE/d/$KL" auth "$KL"
fetch_auth "$KL" "$TMP/random.out"
check "16MB round-trip identical" cmp "$TMP/random.bin" "$TMP/random.out" check "16MB round-trip identical" cmp "$TMP/random.bin" "$TMP/random.out"
check "delete requires key" test \ check "delete requires key (no header -> 404)" test \
"$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$BASE/api/files/$ID")" = "404" "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$BASE/api/files/$ID")" = "404"
check "delete with key succeeds" \ check "delete with key succeeds" \
curl -sS -f -X DELETE -H "X-Delete-Key: $KEY" "$BASE/api/files/$ID" >/dev/null curl -sS -f -X DELETE -H "X-Delete-Key: $KEY" "$BASE/api/files/$ID" >/dev/null
+98
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function renderPasswordForm(
key: string,
filename: string,
error: boolean,
): Response {
const message = error
? '<p class="err">Incorrect password. Please try again.</p>'
: "";
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Protected file</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f1115; color: #e6e8ee; min-height: 100vh;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.card {
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);
}
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);
border: 1px solid rgba(224,82,82,.3); border-radius: 8px; padding: 9px 12px; }
input[type="password"] {
width: 100%; background: #0f1115; border: 1px solid #2a2f3a; color: #e6e8ee;
border-radius: 8px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
}
button { width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
padding: 11px 14px; font-size: 14px; background: #4f8cff; color: #fff; }
button:hover { background: #3a74e0; }
</style>
</head>
<body>
<main class="card">
<h1>Protected file</h1>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message}
<form method="post" action="/api/auth/${key}">
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
<button type="submit">Download</button>
</form>
</main>
</body>
</html>`;
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'",
},
});
}
+11 -14
View File
@@ -2,8 +2,9 @@ const CR = 0x0d;
const LF = 0x0a; const LF = 0x0a;
const MAX_HEADER_LINE_BYTES = 64 * 1024; const MAX_HEADER_LINE_BYTES = 64 * 1024;
export interface MultipartFilePart { export interface MultipartPart {
filename: string; name: string;
filename: string | null;
contentType: string; contentType: string;
chunks: AsyncIterable<Uint8Array>; chunks: AsyncIterable<Uint8Array>;
} }
@@ -36,7 +37,7 @@ async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator
export async function* multipartParts( export async function* multipartParts(
body: ReadableStream<Uint8Array>, body: ReadableStream<Uint8Array>,
boundary: string, boundary: string,
): AsyncGenerator<MultipartFilePart> { ): AsyncGenerator<MultipartPart> {
const DELIMITER = Buffer.from(`--${boundary}`); const DELIMITER = Buffer.from(`--${boundary}`);
const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]); const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]);
const FINAL_MARKER = `--${boundary}--`; const FINAL_MARKER = `--${boundary}--`;
@@ -128,18 +129,14 @@ export async function* multipartParts(
const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition); const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition);
filename = quoted?.[1]; filename = quoted?.[1];
} }
const name = /name\s*=\s*"([^"]*)"/i.exec(disposition)?.[1] ?? "";
if (!filename) { yield {
for await (const _ of contentChunks()) { name,
void _; filename: filename ?? null,
} contentType: headers.get("content-type") ?? "application/octet-stream",
} else { chunks: contentChunks(),
yield { };
filename,
contentType: headers.get("content-type") ?? "application/octet-stream",
chunks: contentChunks(),
};
}
for (;;) { for (;;) {
const marker = await readLine(); const marker = await readLine();
+100 -62
View File
@@ -6,6 +6,11 @@ const STORAGE_KEY = "filehost.uploads";
const dropzone = $("dropzone"); const dropzone = $("dropzone");
const fileInput = $("fileInput"); const fileInput = $("fileInput");
const chosenFile = $("chosenFile");
const pwInput = $("pwInput");
const uploadBtn = $("uploadBtn");
const newUploadBtn = $("newUploadBtn");
const clearListBtn = $("clearListBtn");
const progress = $("progress"); const progress = $("progress");
const progressBar = $("progressBar"); const progressBar = $("progressBar");
const progressPct = $("progressPct"); const progressPct = $("progressPct");
@@ -13,19 +18,17 @@ const progressName = $("progressName");
const result = $("result"); const result = $("result");
const resultUrl = $("resultUrl"); const resultUrl = $("resultUrl");
const resultName = $("resultName"); const resultName = $("resultName");
const resultSize = $("resultSize");
const copyBtn = $("copyBtn"); const copyBtn = $("copyBtn");
const deleteBtn = $("deleteBtn");
const againBtn = $("againBtn");
const notice = $("notice"); const notice = $("notice");
const fileListWrap = $("fileListWrap");
const fileList = $("fileList"); const fileList = $("fileList");
const clearListBtn = $("clearListBtn"); const fileCount = $("fileCount");
const emptyState = $("emptyState");
let current = null; let currentFile = null;
let uploading = false; let uploading = false;
function formatBytes(bytes) { function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"]; const units = ["KB", "MB", "GB", "TB"];
let value = bytes; let value = bytes;
@@ -55,16 +58,21 @@ function setProgress(loaded, total) {
function reset() { function reset() {
uploading = false; uploading = false;
current = null; currentFile = null;
result.classList.add("hidden"); result.classList.add("hidden");
progress.classList.add("hidden"); progress.classList.add("hidden");
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressPct.textContent = "0%"; progressPct.textContent = "0%";
chosenFile.classList.add("hidden");
chosenFile.textContent = "";
fileInput.value = ""; fileInput.value = "";
pwInput.value = "";
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
clearNotice(); clearNotice();
} }
// ---- client-side upload history (localStorage) ---- // ---- client-side history (localStorage) ----
function loadUploads() { function loadUploads() {
try { try {
@@ -80,7 +88,7 @@ function saveUploads(list) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch { } catch {
/* storage unavailable (e.g. private mode) — ignore */ /* storage unavailable — ignore */
} }
} }
@@ -138,74 +146,79 @@ function setCopyFeedback(btn, label) {
}, 1500); }, 1500);
} }
function formatDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return "";
}
}
function renderList() { function renderList() {
const list = loadUploads(); const list = loadUploads();
if (list.length === 0) {
fileListWrap.classList.add("hidden");
fileList.innerHTML = "";
return;
}
fileListWrap.classList.remove("hidden");
fileList.innerHTML = ""; fileList.innerHTML = "";
emptyState.classList.toggle("hidden", list.length > 0);
fileCount.textContent = list.length === 0 ? "" : `${list.length} file${list.length === 1 ? "" : "s"}`;
for (const item of list) { for (const item of list) {
const li = document.createElement("li"); const tr = document.createElement("tr");
li.className = "file-item";
const meta = document.createElement("div");
meta.className = "file-item-meta";
const nameTd = document.createElement("td");
const nameWrap = document.createElement("div");
nameWrap.className = "file-name";
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "file-item-name";
name.textContent = item.originalName; name.textContent = item.originalName;
name.title = item.originalName; name.title = item.originalName;
const lock = document.createElement("span");
lock.className = "lock";
lock.textContent = "Protected";
nameWrap.appendChild(name);
nameWrap.appendChild(lock);
nameTd.appendChild(nameWrap);
const sub = document.createElement("span"); const sizeTd = document.createElement("td");
sub.className = "file-item-sub"; sizeTd.className = "file-size";
let subText = formatBytes(item.size || 0); sizeTd.textContent = formatBytes(item.size);
if (item.createdAt) {
try {
subText += ` \u00b7 ${new Date(item.createdAt).toLocaleString()}`;
} catch {
/* ignore date formatting */
}
}
sub.textContent = subText;
meta.appendChild(name); const dateTd = document.createElement("td");
meta.appendChild(sub); dateTd.className = "file-date";
dateTd.textContent = formatDate(item.createdAt);
const actionsTd = document.createElement("td");
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "file-item-actions"; actions.className = "row-actions";
const dl = document.createElement("a"); const dl = document.createElement("a");
dl.className = "btn-link"; dl.className = "btn btn-primary";
dl.href = downloadUrl(item); dl.href = downloadUrl(item);
dl.textContent = "Download"; dl.textContent = "Download";
dl.setAttribute("download", "");
dl.title = "Open download link"; dl.title = "Open download link";
const copy = document.createElement("button"); const copy = document.createElement("button");
copy.type = "button"; copy.type = "button";
copy.className = "btn btn-ghost";
copy.textContent = "Copy"; copy.textContent = "Copy";
copy.addEventListener("click", async () => { copy.addEventListener("click", async () => {
const ok = await copyText(downloadUrl(item)); const ok = await copyText(downloadUrl(item));
setCopyFeedback(copy, ok ? "Copied" : "Copy failed"); setCopyFeedback(copy, ok ? "Copied" : "Failed");
}); });
const del = document.createElement("button"); const del = document.createElement("button");
del.type = "button"; del.type = "button";
del.className = "danger"; del.className = "btn btn-danger";
del.textContent = "Delete"; del.textContent = "Delete";
del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id))); del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id)));
actions.appendChild(dl); actions.appendChild(dl);
actions.appendChild(copy); actions.appendChild(copy);
actions.appendChild(del); actions.appendChild(del);
actionsTd.appendChild(actions);
li.appendChild(meta); tr.appendChild(nameTd);
li.appendChild(actions); tr.appendChild(sizeTd);
fileList.appendChild(li); tr.appendChild(dateTd);
tr.appendChild(actionsTd);
fileList.appendChild(tr);
} }
} }
@@ -241,9 +254,7 @@ 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;
resultSize.textContent = formatBytes(data.size);
resultUrl.value = downloadUrl(data); resultUrl.value = downloadUrl(data);
current = data;
addUpload({ addUpload({
id: data.id, id: data.id,
key: data.key, key: data.key,
@@ -254,16 +265,19 @@ function showResult(data) {
}); });
} }
function startUpload(file) { function startUpload(file, password) {
if (uploading) return; if (uploading) return;
clearNotice(); clearNotice();
progress.classList.remove("hidden"); progress.classList.remove("hidden");
progressName.textContent = file.name; progressName.textContent = file.name;
setProgress(0, file.size); setProgress(0, file.size);
uploading = true; uploading = true;
uploadBtn.disabled = true;
uploadBtn.textContent = "Uploading\u2026";
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const form = new FormData(); const form = new FormData();
form.append("password", password);
form.append("file", file); form.append("file", file);
xhr.upload.addEventListener("progress", (e) => { xhr.upload.addEventListener("progress", (e) => {
@@ -272,11 +286,13 @@ function startUpload(file) {
xhr.addEventListener("load", () => { xhr.addEventListener("load", () => {
uploading = false; uploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
progress.classList.add("hidden");
try { try {
showResult(JSON.parse(xhr.responseText)); showResult(JSON.parse(xhr.responseText));
} catch { } catch {
progress.classList.add("hidden");
showNotice("Invalid server response.", "error"); showNotice("Invalid server response.", "error");
} }
return; return;
@@ -294,12 +310,16 @@ function startUpload(file) {
xhr.addEventListener("error", () => { xhr.addEventListener("error", () => {
uploading = false; uploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
progress.classList.add("hidden"); progress.classList.add("hidden");
showNotice("Network error during upload.", "error"); showNotice("Network error during upload.", "error");
}); });
xhr.addEventListener("abort", () => { xhr.addEventListener("abort", () => {
uploading = false; uploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
progress.classList.add("hidden"); progress.classList.add("hidden");
}); });
@@ -307,13 +327,30 @@ function startUpload(file) {
xhr.send(form); xhr.send(form);
} }
function handleFile(file) { function selectFile(file) {
if (!file) return; if (!file) return;
if (file.size === 0) { if (file.size === 0) {
showNotice("Empty files are not supported.", "error"); showNotice("Empty files are not supported.", "error");
return; return;
} }
startUpload(file); currentFile = file;
chosenFile.textContent = file.name;
chosenFile.classList.remove("hidden");
clearNotice();
}
function submitUpload() {
if (!currentFile) {
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);
} }
dropzone.addEventListener("click", () => { dropzone.addEventListener("click", () => {
@@ -329,7 +366,18 @@ dropzone.addEventListener("keydown", (e) => {
fileInput.addEventListener("change", () => { fileInput.addEventListener("change", () => {
const file = fileInput.files ? fileInput.files[0] : null; const file = fileInput.files ? fileInput.files[0] : null;
handleFile(file); selectFile(file);
});
pwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") submitUpload();
});
uploadBtn.addEventListener("click", submitUpload);
newUploadBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
pwInput.focus();
}); });
let dragDepth = 0; let dragDepth = 0;
@@ -352,28 +400,18 @@ document.addEventListener("drop", (e) => {
dropzone.classList.remove("drag"); dropzone.classList.remove("drag");
if (uploading) return; if (uploading) return;
const file = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null; const file = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null;
if (file) handleFile(file); selectFile(file);
}); });
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", async () => {
const ok = await copyText(resultUrl.value); const ok = await copyText(resultUrl.value);
setCopyFeedback(copyBtn, ok ? "Copied" : "Copy failed"); setCopyFeedback(copyBtn, ok ? "Copied" : "Failed");
}); });
deleteBtn.addEventListener("click", () => {
if (!current) return;
deleteFile(current, () => {
removeUpload(current.id);
reset();
});
});
againBtn.addEventListener("click", reset);
clearListBtn.addEventListener("click", () => { clearListBtn.addEventListener("click", () => {
if (loadUploads().length === 0) return; if (loadUploads().length === 0) return;
const ok = window.confirm( const ok = window.confirm(
"Clear this list? This only removes the entries from THIS browser. The uploaded files remain on the server.", "Clear this list? This only removes the entries from THIS browser. The files remain on the server.",
); );
if (!ok) return; if (!ok) return;
saveUploads([]); saveUploads([]);
+86 -45
View File
@@ -5,58 +5,99 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>File drop</title> <title>File Drop</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
</head> </head>
<body> <body>
<main class="card"> <div class="app">
<h1>File drop</h1> <header class="topbar">
<p class="tagline">Drop a file to get a private download link. Files are kept forever.</p> <div class="brand">
<div class="logo" aria-hidden="true">FD</div>
<div class="brand-text">
<h1>File Drop</h1>
<span class="sub">Private, password-protected file hosting</span>
</div>
</div>
<div class="topbar-actions">
<button id="newUploadBtn" type="button" class="btn btn-primary">New upload</button>
<button id="clearListBtn" type="button" class="btn btn-ghost">Clear list</button>
</div>
</header>
<div id="dropzone" class="dropzone" role="button" tabindex="0" aria-label="Upload a file"> <main class="layout">
<input type="file" id="fileInput" hidden /> <aside class="panel upload-panel">
<p class="dz-main">Drag &amp; drop a file here</p> <h2>Upload a file</h2>
<p class="dz-sub">or click to choose</p>
</div>
<div id="progress" class="progress hidden"> <div id="dropzone" class="dropzone" role="button" tabindex="0" aria-label="Choose a file">
<div class="progress-label"> <input type="file" id="fileInput" hidden />
<span id="progressName"></span> <p class="dz-main">Drag &amp; drop a file here</p>
<span id="progressPct">0%</span> <p class="dz-sub">or click to choose</p>
</div> <p id="chosenFile" class="dz-file hidden"></p>
<div class="progress-track"> </div>
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<div id="result" class="result hidden"> <div class="field">
<div class="result-meta"> <label for="pwInput">Password</label>
<strong id="resultName"></strong> <input
<span id="resultSize"></span> type="password"
</div> id="pwInput"
<div class="url-row"> placeholder="Required — protects the download"
<input id="resultUrl" type="text" readonly aria-label="Download URL" /> autocomplete="new-password"
<button id="copyBtn" type="button">Copy</button> />
</div> </div>
<div class="result-actions">
<button id="deleteBtn" type="button" class="danger">Delete file</button>
<button id="againBtn" type="button" class="ghost">Upload another</button>
</div>
</div>
<div id="notice" class="hidden"></div> <div id="progress" class="progress hidden">
<div class="progress-label">
<span id="progressName"></span>
<span id="progressPct">0%</span>
</div>
<div class="progress-track">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<section id="fileListWrap" class="files hidden" aria-label="Your uploaded files"> <button id="uploadBtn" type="button" class="btn btn-primary btn-block">Upload</button>
<div class="files-head">
<h2>Your files</h2> <div id="result" class="result hidden">
<button id="clearListBtn" type="button" class="ghost">Clear list</button> <div class="result-head">
</div> <span id="resultName"></span>
<p class="files-note"> <span class="lock">Protected</span>
Stored locally in this browser only. Other visitors cannot see them. </div>
</p> <div class="url-row">
<ul id="fileList"></ul> <input id="resultUrl" type="text" readonly aria-label="Download URL" />
</section> <button id="copyBtn" type="button" class="btn btn-primary">Copy</button>
</main> </div>
</div>
<div id="notice" class="hidden"></div>
</aside>
<section class="panel files-panel">
<div class="panel-head">
<h2>Your files</h2>
<span id="fileCount" class="count"></span>
</div>
<p class="files-note">
Stored locally in this browser only. Every file requires its password.
</p>
<div class="table-wrap">
<table class="files-table">
<thead>
<tr>
<th class="col-name">Name</th>
<th class="col-size">Size</th>
<th class="col-date">Uploaded</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="fileList"></tbody>
</table>
</div>
<p id="emptyState" class="empty">No files yet. Upload your first file.</p>
</section>
</main>
</div>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+348 -189
View File
@@ -1,13 +1,15 @@
:root { :root {
--bg: #0f1115; --bg: #0e1013;
--card: #171a21; --surface: #15181d;
--border: #2a2f3a; --surface-2: #1a1e24;
--text: #e6e8ee; --border: #262b33;
--muted: #9aa3b2; --text: #e7eaf0;
--accent: #4f8cff; --muted: #98a1af;
--accent-hover: #3a74e0; --accent: #3f7bf6;
--danger: #e05252; --accent-strong: #2f63d8;
--danger: #e05555;
--ok: #46b076; --ok: #46b076;
--radius: 8px;
} }
* { * {
@@ -20,37 +22,151 @@ body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 0 20px 48px;
}
/* ---- top bar ---- */
.topbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
padding: 16px; gap: 16px;
padding: 20px 0 18px;
border-bottom: 1px solid var(--border);
margin-bottom: 22px;
} }
.card { .brand {
width: 100%; display: flex;
max-width: 560px; align-items: center;
background: var(--card); gap: 12px;
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
} }
h1 { .logo {
margin: 0 0 4px; width: 40px;
font-size: 24px; 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;
} }
.tagline { .brand-text h1 {
margin: 0 0 20px; margin: 0;
font-size: 18px;
font-weight: 650;
line-height: 1.1;
}
.sub {
font-size: 12px;
color: var(--muted); color: var(--muted);
font-size: 14px; }
.topbar-actions {
display: flex;
gap: 8px;
}
/* ---- buttons ---- */
.btn {
border: 1px solid transparent;
border-radius: var(--radius);
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--muted);
}
.btn-ghost:hover {
color: var(--text);
border-color: var(--muted);
}
.btn-danger {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(224, 85, 85, 0.1);
}
.btn-block {
width: 100%;
margin-top: 14px;
}
/* ---- layout ---- */
.layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 20px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.panel h2 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 650;
letter-spacing: 0.2px;
}
/* ---- upload panel ---- */
.upload-panel {
position: sticky;
top: 20px;
} }
.dropzone { .dropzone {
border: 2px dashed var(--border); border: 1.5px dashed var(--border);
border-radius: 12px; border-radius: var(--radius);
padding: 40px 20px; padding: 26px 16px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, background 0.15s; transition: border-color 0.15s, background 0.15s;
@@ -61,29 +177,67 @@ h1 {
.dropzone.drag, .dropzone.drag,
.dropzone:focus-visible { .dropzone:focus-visible {
border-color: var(--accent); border-color: var(--accent);
background: rgba(79, 140, 255, 0.06); background: rgba(63, 123, 246, 0.06);
} }
.dz-main { .dz-main {
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;
font-size: 14px;
} }
.dz-sub { .dz-sub {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
} }
.dz-file {
margin: 12px 0 0;
font-size: 13px;
font-weight: 600;
color: var(--accent);
word-break: break-all;
}
.field {
margin-top: 16px;
}
.field label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 0.3px;
}
.field input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 11px 12px;
font-size: 14px;
}
.field input:focus {
outline: none;
border-color: var(--accent);
}
/* ---- progress ---- */
.progress { .progress {
margin-top: 20px; margin-top: 16px;
} }
.progress-label { .progress-label {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
font-size: 13px; font-size: 12px;
color: var(--muted); color: var(--muted);
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -95,8 +249,8 @@ h1 {
} }
.progress-track { .progress-track {
height: 8px; height: 6px;
background: var(--border); background: var(--surface-2);
border-radius: 99px; border-radius: 99px;
overflow: hidden; overflow: hidden;
} }
@@ -109,20 +263,27 @@ h1 {
transition: width 0.15s; transition: width 0.15s;
} }
/* ---- result banner ---- */
.result { .result {
margin-top: 20px; margin-top: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 12px;
} }
.result-meta { .result-head {
font-size: 14px; display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px; margin-bottom: 10px;
color: var(--muted);
word-break: break-all;
} }
.result-meta strong { .result-head span:first-child {
color: var(--text); font-size: 13px;
font-weight: 600; font-weight: 600;
word-break: break-all;
} }
.url-row { .url-row {
@@ -136,65 +297,159 @@ h1 {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--text);
border-radius: 8px; border-radius: var(--radius);
padding: 10px 12px; padding: 10px 12px;
font-size: 13px; font-size: 13px;
} }
.result-actions { /* ---- files panel ---- */
.panel-head {
display: flex; display: flex;
gap: 8px; align-items: baseline;
margin-top: 10px; justify-content: space-between;
gap: 12px;
} }
button { .panel-head h2 {
font: inherit; margin-bottom: 4px;
cursor: pointer;
border: none;
border-radius: 8px;
padding: 9px 14px;
font-size: 13px;
background: var(--accent);
color: #fff;
transition: background 0.15s;
} }
button:hover { .count {
background: var(--accent-hover); font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
} }
button.danger { .files-note {
background: transparent; margin: 0 0 16px;
border: 1px solid var(--danger); font-size: 12px;
color: var(--danger);
}
button.danger:hover {
background: rgba(224, 82, 82, 0.1);
}
button.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted); color: var(--muted);
} }
button.ghost:hover { .table-wrap {
color: var(--text); border: 1px solid var(--border);
border-color: var(--muted); border-radius: var(--radius);
overflow-x: auto;
} }
#notice { .files-table {
margin-top: 16px; width: 100%;
border-collapse: collapse;
min-width: 520px;
}
.files-table th,
.files-table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px; font-size: 13px;
border-radius: 8px; vertical-align: middle;
}
.files-table thead th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.files-table tbody tr:last-child td {
border-bottom: none;
}
.files-table tbody tr:hover td {
background: rgba(255, 255, 255, 0.015);
}
.col-name {
width: 40%;
}
.col-size {
width: 12%;
}
.col-date {
width: 20%;
}
.col-actions {
width: 28%;
text-align: right;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
word-break: break-all;
}
.lock {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
background: rgba(63, 123, 246, 0.12);
border: 1px solid rgba(63, 123, 246, 0.3);
border-radius: 99px;
padding: 2px 8px;
}
.file-sub {
display: block;
font-size: 12px;
color: var(--muted);
font-weight: 400;
margin-top: 2px;
word-break: break-all;
}
.file-size,
.file-date {
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.row-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.row-actions .btn {
padding: 7px 10px;
font-size: 12px;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 28px 0 6px;
margin: 0;
}
/* ---- notice ---- */
#notice {
margin-top: 14px;
font-size: 13px;
border-radius: var(--radius);
padding: 10px 12px; padding: 10px 12px;
} }
#notice.error { #notice.error {
color: var(--danger); color: var(--danger);
background: rgba(224, 82, 82, 0.08); background: rgba(224, 85, 85, 0.08);
border: 1px solid rgba(224, 82, 82, 0.3); border: 1px solid rgba(224, 85, 85, 0.3);
} }
#notice.info { #notice.info {
@@ -207,119 +462,23 @@ button.ghost:hover {
display: none !important; display: none !important;
} }
/* Client-side upload list */ /* ---- responsive ---- */
.files { @media (max-width: 860px) {
margin-top: 24px; .layout {
border-top: 1px solid var(--border); grid-template-columns: 1fr;
padding-top: 18px;
}
.files-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.files-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.files-note {
margin: 6px 0 12px;
font-size: 12px;
color: var(--muted);
}
#fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.file-item-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-item-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item-sub {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.file-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.btn-link {
font: inherit;
font-size: 13px;
color: #fff;
background: var(--accent);
border-radius: 8px;
padding: 8px 12px;
text-decoration: none;
transition: background 0.15s;
}
.btn-link:hover {
background: var(--accent-hover);
}
.file-item-actions button {
font-size: 12px;
padding: 8px 10px;
}
@media (max-width: 480px) {
.card {
padding: 20px;
} }
.dropzone { .upload-panel {
padding: 28px 16px; position: static;
}
.url-row {
flex-direction: column;
}
.file-item {
flex-direction: column;
align-items: stretch;
}
.file-item-actions {
justify-content: flex-end;
} }
} }
@media (max-width: 520px) {
.app {
padding: 0 12px 32px;
}
.topbar {
flex-wrap: wrap;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { AUTH_COOKIE, AUTH_TTL_MS } from "../auth";
import { notFound } from "../http";
import { log } from "../log";
import { fileIdFromKey, KEY_PATTERN, makeAuthToken, passwordMatches } from "../security";
import type { Storage } from "../storage";
export async function handleAuth(
req: Request,
storage: Storage,
rawKey: string,
secret: string,
secure: boolean,
): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound();
const id = fileIdFromKey(rawKey);
if (!id) return notFound();
const meta = await storage.readMetadata(id);
if (!meta) return notFound();
if (!meta.passwordHash) {
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}` } });
}
const password = await req.formData().then(
(form) => {
const value = form.get("password");
return typeof value === "string" ? value : "";
},
() => "",
);
if (!passwordMatches(meta.passwordHash, password)) {
log("warn", `auth failed id=${id}`);
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}?e=1` } });
}
const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS);
const cookie = `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${
secure ? "; Secure" : ""
}; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`;
log("info", `auth ok id=${id}`);
return new Response(null, { status: 302, headers: { location: `/d/${rawKey}`, "set-cookie": cookie } });
}
+7
View File
@@ -1,4 +1,5 @@
import { lstat } from "node:fs/promises"; import { lstat } from "node:fs/promises";
import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http"; import { notFound } from "../http";
import { log } from "../log"; import { log } from "../log";
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
@@ -8,6 +9,7 @@ export async function handleDownload(
req: Request, req: Request,
storage: Storage, storage: Storage,
rawKey: string, rawKey: string,
secret: string,
): Promise<Response> { ): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound(); if (!KEY_PATTERN.test(rawKey)) return notFound();
const id = fileIdFromKey(rawKey); const id = fileIdFromKey(rawKey);
@@ -17,6 +19,11 @@ export async function handleDownload(
if (!meta) return notFound(); if (!meta) return notFound();
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
if (!hasValidAuth(req, secret, rawKey)) {
const url = new URL(req.url);
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
}
const filePath = storage.filePath(id); const filePath = storage.filePath(id);
let fileStat; let fileStat;
try { try {
+41 -12
View File
@@ -1,9 +1,27 @@
import { json, jsonError } from "../http"; import { json, jsonError } from "../http";
import { log } from "../log"; import { log } from "../log";
import { getBoundary, MultipartError, multipartParts } from "../multipart"; import { getBoundary, MultipartError, multipartParts, type MultipartPart } from "../multipart";
import { hashSecret, newDownloadKey, newFileId, sanitizeMime, sanitizeOriginalName } from "../security"; import {
hashPassword,
hashSecret,
newDownloadKey,
newFileId,
sanitizeMime,
sanitizeOriginalName,
} from "../security";
import { UploadTooLargeError, type Storage } from "../storage"; import { UploadTooLargeError, type Storage } from "../storage";
const MAX_PASSWORD_LENGTH = 256;
async function readField(part: MultipartPart, limit: number): Promise<string> {
let value = "";
for await (const chunk of part.chunks) {
value += Buffer.from(chunk).toString("utf8");
if (value.length > limit) throw new MultipartError("field too long");
}
return value;
}
export async function handleUpload( export async function handleUpload(
req: Request, req: Request,
storage: Storage, storage: Storage,
@@ -21,21 +39,31 @@ export async function handleUpload(
let mime = "application/octet-stream"; let mime = "application/octet-stream";
let size = 0; let size = 0;
let hasFile = false; let hasFile = false;
let password = "";
for await (const part of multipartParts(req.body, boundary)) { for await (const part of multipartParts(req.body, boundary)) {
if (!hasFile) { if (part.filename != null) {
hasFile = true; if (!hasFile) {
filename = part.filename; hasFile = true;
mime = sanitizeMime(part.contentType); filename = part.filename;
size = await storage.saveFile(id, part.chunks, maxFileSize); mime = sanitizeMime(part.contentType);
break; size = await storage.saveFile(id, part.chunks, maxFileSize);
} } else {
for await (const _ of part.chunks) { for await (const _ of part.chunks) {
void _; void _;
}
}
} else if (part.name === "password" && password === "") {
password = await readField(part, MAX_PASSWORD_LENGTH);
} else {
for await (const _ of part.chunks) {
void _;
}
} }
} }
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);
await storage.writeMetadata({ await storage.writeMetadata({
@@ -44,11 +72,12 @@ export async function handleUpload(
size, size,
mimeType: mime, mimeType: mime,
downloadKeyHash: hashSecret(key), downloadKeyHash: hashSecret(key),
passwordHash: hashPassword(password),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
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: `/d/${key}`, originalName, size }); return json({ id, key, url: `/d/${key}`, originalName, size, protected: true });
} catch (err) { } catch (err) {
await storage.delete(id); await storage.delete(id);
await storage.deletePartial(id); await storage.deletePartial(id);
+49 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
export const ID_BYTES = 16; export const ID_BYTES = 16;
export const KEY_BYTES = 32; export const KEY_BYTES = 32;
@@ -54,3 +54,51 @@ export function contentDisposition(name: string): string {
); );
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
} }
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `scrypt:${salt}:${hash}`;
}
export function passwordMatches(stored: string, password: string): boolean {
const parts = stored.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const salt = parts[1]!;
const expected = Buffer.from(parts[2]!, "hex");
const computed = scryptSync(password, salt, 64);
if (expected.length !== computed.length) return false;
return timingSafeEqual(expected, computed);
}
export function safeStringEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
function hmacSign(payload: string, secret: string): string {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function makeAuthToken(key: string, secret: string, ttlMs: number): string {
const expires = Date.now() + ttlMs;
const payload = `${key}.${expires}`;
return `${payload}.${hmacSign(payload, secret)}`;
}
export function parseAuthToken(token: string, secret: string): { key: string } | null {
const lastDot = token.lastIndexOf(".");
if (lastDot <= 0) return null;
const payload = token.slice(0, lastDot);
const signature = token.slice(lastDot + 1);
if (!safeStringEqual(signature, hmacSign(payload, secret))) return null;
const dot = payload.lastIndexOf(".");
if (dot <= 0) return null;
const key = payload.slice(0, dot);
const expires = Number(payload.slice(dot + 1));
if (!Number.isFinite(expires) || expires <= Date.now()) return null;
if (!KEY_PATTERN.test(key)) return null;
return { key };
}
+15 -1
View File
@@ -3,6 +3,7 @@ import { loadConfig } from "./config";
import { json, jsonError, notFound, securityHeaders } from "./http"; import { json, jsonError, notFound, securityHeaders } from "./http";
import { log } from "./log"; import { log } from "./log";
import { RateLimiter } from "./ratelimit"; import { RateLimiter } from "./ratelimit";
import { handleAuth } from "./routes/auth";
import { handleDelete } from "./routes/delete"; import { handleDelete } from "./routes/delete";
import { handleDownload } from "./routes/download"; import { handleDownload } from "./routes/download";
import { handleUpload } from "./routes/upload"; import { handleUpload } from "./routes/upload";
@@ -12,6 +13,7 @@ const cfg = loadConfig();
const storage = new Storage(cfg.storageDir); const storage = new Storage(cfg.storageDir);
await storage.init(); await storage.init();
const secret = await storage.loadSecret();
const sweep = await storage.sweep(); const sweep = await storage.sweep();
if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) { if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
log( log(
@@ -77,6 +79,11 @@ function serveStatic(path: string): Response {
}); });
} }
function isSecure(req: Request, url: URL): boolean {
if (cfg.trustProxy) return req.headers.get("x-forwarded-proto") === "https";
return url.protocol === "https:";
}
async function route(req: Request, srv: Server<undefined>): Promise<Response> { async function route(req: Request, srv: Server<undefined>): Promise<Response> {
const url = new URL(req.url); const url = new URL(req.url);
const path = url.pathname; const path = url.pathname;
@@ -92,11 +99,18 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
return handleUpload(req, storage, cfg.maxFileSize); return handleUpload(req, storage, cfg.maxFileSize);
} }
if (path.startsWith("/api/auth/") && method === "POST") {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests");
}
return handleAuth(req, storage, path.slice("/api/auth/".length), secret, isSecure(req, url));
}
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) { if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests"); return jsonError(429, "too many requests");
} }
return handleDownload(req, storage, path.slice(3)); return handleDownload(req, storage, path.slice(3), secret);
} }
if (path.startsWith("/api/files/") && method === "DELETE") { if (path.startsWith("/api/files/") && method === "DELETE") {
+15
View File
@@ -1,4 +1,5 @@
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import { ID_PATTERN } from "./security"; import { ID_PATTERN } from "./security";
@@ -8,6 +9,7 @@ export interface FileMeta {
size: number; size: number;
mimeType: string; mimeType: string;
downloadKeyHash: string; downloadKeyHash: string;
passwordHash?: string;
createdAt: string; createdAt: string;
} }
@@ -35,6 +37,19 @@ export class Storage {
await mkdir(this.metadataDir, { recursive: true }); await mkdir(this.metadataDir, { recursive: true });
} }
async loadSecret(): Promise<string> {
const path = join(this.root, ".secret");
try {
const existing = (await readFile(path, "utf8")).trim();
if (existing) return existing;
} catch {
/* missing or unreadable: create below */
}
const secret = randomBytes(32).toString("base64url");
await writeFile(path, secret, { encoding: "utf8", mode: 0o600 });
return secret;
}
filePath(id: string): string { filePath(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error("invalid file id"); if (!ID_PATTERN.test(id)) throw new Error("invalid file id");
return join(this.filesDir, id); return join(this.filesDir, id);