From d8b9fbe993803f53fc01a7b88dcea08f538d3f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:35:22 +0200 Subject: [PATCH] UPDATED --- HOSTING.md | 2 +- README.md | 32 +-- scripts/smoke-test.sh | 63 +++-- src/public/app.js | 171 +++++++------ src/public/index.html | 142 ++++++----- src/public/style.css | 543 +++++++++++++++++++++++++---------------- src/routes/download.ts | 2 +- src/routes/upload.ts | 11 +- 8 files changed, 578 insertions(+), 388 deletions(-) diff --git a/HOSTING.md b/HOSTING.md index 0af4706..fdb5841 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -241,7 +241,7 @@ 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`. -- Optional per-file passwords are stored only as scrypt hashes; entry mints a +- 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 when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`. diff --git a/README.md b/README.md index 46bd687..b20e027 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ database, no object storage, no queue, and no automatic cleanup. ## Features - Drag & drop / file-picker uploads with progress, straight from the browser. -- **Optional per-file password**: protect a download link with a password. +- **Required per-file password**: every download is protected by a password. - Permanent 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. @@ -68,8 +68,9 @@ Then open http://localhost:3000. 2. Drop `example.zip` (or click to pick a file). 3. The server stores it permanently and returns a URL like `https://files.example.com/d/8fJ...random-secret...`. -4. Copy the URL. Anyone possessing it can download the file; nobody without the - secret can. If a password was set, the downloader must also enter it. +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. 5. The file stays available indefinitely. It is removed only by explicit deletion. @@ -115,11 +116,11 @@ touched. ### `POST /api/upload` -Multipart/form-data with a `file` field (this is what the web UI uses). An -optional `password` field protects the download with a password. +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 -curl -F "file=@example.zip" http://localhost:3000/api/upload curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/upload ``` @@ -130,23 +131,22 @@ curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/uploa "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", "originalName": "example.zip", "size": 123456, - "protected": false + "protected": true } ``` ### `GET /d/:key` (also `HEAD`) -Streams the file. Requires the full secret key; any wrong or non-existent key -returns a generic `404`. If the file has a password, the server first shows a -password prompt (see below). +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`. ```bash -curl -O http://localhost:3000/d/YOWJyh...7OQ +curl -O http://localhost:3000/d/YOWJyh...7OQ # prompts for password first ``` ### Password protection -For files uploaded with a `password` field: +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=...`. @@ -159,7 +159,7 @@ 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 protected file is never +forged, and it survives server restarts. Without it, a file is never downloadable — not even by the uploader. ### `DELETE /api/files/:id` @@ -191,9 +191,9 @@ paths or stack traces. 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.** An optional per-file password 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. +- **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, regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata only and is sanitized (basename, control/null bytes stripped) before being diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ecdded7..048ce07 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -9,7 +9,9 @@ set -euo pipefail BASE="${BASE_URL:-http://127.0.0.1:3000}" +PW="hunter2" TMP="$(mktemp -d)" +COOKIES="$TMP/cookies.txt" trap 'rm -rf "$TMP"' EXIT pass=0 @@ -30,61 +32,70 @@ get() { # get bun -e 'const d = JSON.parse(await Bun.file(process.argv[3]).text()); console.log(d[process.argv[2]])' _ "$2" "$1" } +up() { # up [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 -> 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 + curl -sS -b "$COOKIES" -o "$2" "$BASE/d/$1" +} + printf 'hello world\n' > "$TMP/plain.txt" 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 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)" ID="$(get "$TMP/up.json" id)" URL="$(get "$TMP/up.json" url)" [ -n "$KEY" ] && check "upload returns a key" true -check "download with key" curl -sS -f -o "$TMP/down.txt" "$BASE$URL" -check "downloaded content matches" cmp "$TMP/plain.txt" "$TMP/down.txt" - +check "protected file serves a password form" test \ + "$(curl -sS -o /dev/null -w '%{content_type}' "$BASE$URL")" = "text/html; charset=utf-8" check "invalid key returns 404" test \ "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")" = "404" - check "id alone cannot download" test \ "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/$ID")" = "404" - check "storage directory not served" test \ "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404" -check "unicode filename upload" \ - 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" +check "wrong password is rejected (redirect to error)" test \ + "$(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)" -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" \ - curl -sS -f -F "file=@$TMP/plain.txt;filename=../../../../etc/passwd" "$BASE/api/upload" > "$TMP/upT.json" +up "$TMP/plain.txt" "../../../../etc/passwd" > "$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)" -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 "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" check "delete with key succeeds" \ curl -sS -f -X DELETE -H "X-Delete-Key: $KEY" "$BASE/api/files/$ID" >/dev/null check "file gone after delete" test \ "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE$URL")" = "404" -echo -echo "-- password protection --" -check "upload with password" curl -sS -f -F "password=hunter2" -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/upP.json" -KP="$(get "$TMP/upP.json" key)" -check "protected file serves a password form" test \ - "$(curl -sS -o /dev/null -w '%{content_type}' "$BASE/d/$KP")" = "text/html; charset=utf-8" -check "wrong password is rejected" test \ - "$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d 'password=nope' "$BASE/api/auth/$KP")" = "302" -check "correct password returns redirect" curl -sS -c "$TMP/cj.txt" -X POST -d "password=hunter2" "$BASE/api/auth/$KP" >/dev/null -curl -sS -b "$TMP/cj.txt" -o "$TMP/downP.txt" "$BASE/d/$KP" -check "download with cookie matches" cmp "$TMP/plain.txt" "$TMP/downP.txt" - echo echo "passed: $pass, failed: $fail" [ "$fail" -eq 0 ] || exit 1 \ No newline at end of file diff --git a/src/public/app.js b/src/public/app.js index 92a0320..177a01b 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -6,7 +6,11 @@ const STORAGE_KEY = "filehost.uploads"; const dropzone = $("dropzone"); const fileInput = $("fileInput"); +const chosenFile = $("chosenFile"); const pwInput = $("pwInput"); +const uploadBtn = $("uploadBtn"); +const newUploadBtn = $("newUploadBtn"); +const clearListBtn = $("clearListBtn"); const progress = $("progress"); const progressBar = $("progressBar"); const progressPct = $("progressPct"); @@ -14,20 +18,17 @@ const progressName = $("progressName"); const result = $("result"); const resultUrl = $("resultUrl"); const resultName = $("resultName"); -const resultSize = $("resultSize"); -const resultLock = $("resultLock"); const copyBtn = $("copyBtn"); -const deleteBtn = $("deleteBtn"); -const againBtn = $("againBtn"); const notice = $("notice"); -const fileListWrap = $("fileListWrap"); const fileList = $("fileList"); -const clearListBtn = $("clearListBtn"); +const fileCount = $("fileCount"); +const emptyState = $("emptyState"); -let current = null; +let currentFile = null; let uploading = false; function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return "-"; if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; let value = bytes; @@ -57,18 +58,21 @@ function setProgress(loaded, total) { function reset() { uploading = false; - current = null; + currentFile = null; result.classList.add("hidden"); - resultLock.classList.add("hidden"); progress.classList.add("hidden"); progressBar.style.width = "0%"; progressPct.textContent = "0%"; + chosenFile.classList.add("hidden"); + chosenFile.textContent = ""; fileInput.value = ""; pwInput.value = ""; + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload"; clearNotice(); } -// ---- client-side upload history (localStorage) ---- +// ---- client-side history (localStorage) ---- function loadUploads() { try { @@ -84,7 +88,7 @@ function saveUploads(list) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); } catch { - /* storage unavailable (e.g. private mode) — ignore */ + /* storage unavailable — ignore */ } } @@ -142,80 +146,79 @@ function setCopyFeedback(btn, label) { }, 1500); } +function formatDate(iso) { + try { + return new Date(iso).toLocaleString(); + } catch { + return ""; + } +} + function renderList() { const list = loadUploads(); - if (list.length === 0) { - fileListWrap.classList.add("hidden"); - fileList.innerHTML = ""; - return; - } - fileListWrap.classList.remove("hidden"); 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) { - const li = document.createElement("li"); - li.className = "file-item"; - - const meta = document.createElement("div"); - meta.className = "file-item-meta"; + const tr = document.createElement("tr"); + const nameTd = document.createElement("td"); + const nameWrap = document.createElement("div"); + nameWrap.className = "file-name"; const name = document.createElement("span"); - name.className = "file-item-name"; name.textContent = 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); - if (item.protected) { - const lock = document.createElement("span"); - lock.className = "lock"; - lock.textContent = "Password protected"; - name.appendChild(lock); - } + const sizeTd = document.createElement("td"); + sizeTd.className = "file-size"; + sizeTd.textContent = formatBytes(item.size); - const sub = document.createElement("span"); - sub.className = "file-item-sub"; - let subText = formatBytes(item.size || 0); - if (item.createdAt) { - try { - subText += ` \u00b7 ${new Date(item.createdAt).toLocaleString()}`; - } catch { - /* ignore date formatting */ - } - } - sub.textContent = subText; - - meta.appendChild(name); - meta.appendChild(sub); + const dateTd = document.createElement("td"); + dateTd.className = "file-date"; + dateTd.textContent = formatDate(item.createdAt); + const actionsTd = document.createElement("td"); const actions = document.createElement("div"); - actions.className = "file-item-actions"; + actions.className = "row-actions"; const dl = document.createElement("a"); - dl.className = "btn-link"; + dl.className = "btn btn-primary"; dl.href = downloadUrl(item); dl.textContent = "Download"; dl.title = "Open download link"; const copy = document.createElement("button"); copy.type = "button"; + copy.className = "btn btn-ghost"; copy.textContent = "Copy"; copy.addEventListener("click", async () => { const ok = await copyText(downloadUrl(item)); - setCopyFeedback(copy, ok ? "Copied" : "Copy failed"); + setCopyFeedback(copy, ok ? "Copied" : "Failed"); }); const del = document.createElement("button"); del.type = "button"; - del.className = "danger"; + del.className = "btn btn-danger"; del.textContent = "Delete"; del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id))); actions.appendChild(dl); actions.appendChild(copy); actions.appendChild(del); + actionsTd.appendChild(actions); - li.appendChild(meta); - li.appendChild(actions); - fileList.appendChild(li); + tr.appendChild(nameTd); + tr.appendChild(sizeTd); + tr.appendChild(dateTd); + tr.appendChild(actionsTd); + fileList.appendChild(tr); } } @@ -251,32 +254,30 @@ function showResult(data) { progress.classList.add("hidden"); result.classList.remove("hidden"); resultName.textContent = data.originalName; - resultSize.textContent = formatBytes(data.size); resultUrl.value = downloadUrl(data); - resultLock.classList.toggle("hidden", !data.protected); - current = data; addUpload({ id: data.id, key: data.key, url: data.url, originalName: data.originalName, size: data.size, - protected: !!data.protected, createdAt: new Date().toISOString(), }); } -function startUpload(file) { +function startUpload(file, password) { if (uploading) return; clearNotice(); progress.classList.remove("hidden"); progressName.textContent = file.name; setProgress(0, file.size); uploading = true; + uploadBtn.disabled = true; + uploadBtn.textContent = "Uploading\u2026"; const xhr = new XMLHttpRequest(); const form = new FormData(); - if (pwInput.value) form.append("password", pwInput.value); + form.append("password", password); form.append("file", file); xhr.upload.addEventListener("progress", (e) => { @@ -285,11 +286,13 @@ function startUpload(file) { xhr.addEventListener("load", () => { uploading = false; + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload"; if (xhr.status >= 200 && xhr.status < 300) { + progress.classList.add("hidden"); try { showResult(JSON.parse(xhr.responseText)); } catch { - progress.classList.add("hidden"); showNotice("Invalid server response.", "error"); } return; @@ -307,12 +310,16 @@ function startUpload(file) { xhr.addEventListener("error", () => { uploading = false; + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload"; progress.classList.add("hidden"); showNotice("Network error during upload.", "error"); }); xhr.addEventListener("abort", () => { uploading = false; + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload"; progress.classList.add("hidden"); }); @@ -320,13 +327,30 @@ function startUpload(file) { xhr.send(form); } -function handleFile(file) { +function selectFile(file) { if (!file) return; if (file.size === 0) { showNotice("Empty files are not supported.", "error"); 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", () => { @@ -342,7 +366,18 @@ dropzone.addEventListener("keydown", (e) => { fileInput.addEventListener("change", () => { 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; @@ -365,28 +400,18 @@ document.addEventListener("drop", (e) => { dropzone.classList.remove("drag"); if (uploading) return; const file = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null; - if (file) handleFile(file); + selectFile(file); }); copyBtn.addEventListener("click", async () => { 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", () => { if (loadUploads().length === 0) return; 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; saveUploads([]); diff --git a/src/public/index.html b/src/public/index.html index db3b93d..2f85d76 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -5,69 +5,99 @@ - File drop + File Drop -
-

File drop

-

Drop a file to get a private download link. Files are kept forever.

- -
- -

Drag & drop a file here

-

or click to choose

-
- -
- -
- -
+ - + \ No newline at end of file diff --git a/src/public/style.css b/src/public/style.css index 1235350..a70fb46 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -1,13 +1,15 @@ :root { - --bg: #0f1115; - --card: #171a21; - --border: #2a2f3a; - --text: #e6e8ee; - --muted: #9aa3b2; - --accent: #4f8cff; - --accent-hover: #3a74e0; - --danger: #e05252; + --bg: #0e1013; + --surface: #15181d; + --surface-2: #1a1e24; + --border: #262b33; + --text: #e7eaf0; + --muted: #98a1af; + --accent: #3f7bf6; + --accent-strong: #2f63d8; + --danger: #e05555; --ok: #46b076; + --radius: 8px; } * { @@ -20,37 +22,151 @@ body { background: var(--bg); color: var(--text); 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; align-items: center; - justify-content: center; - padding: 16px; + justify-content: space-between; + gap: 16px; + padding: 20px 0 18px; + border-bottom: 1px solid var(--border); + margin-bottom: 22px; } -.card { - width: 100%; - max-width: 560px; - background: var(--card); - border: 1px solid var(--border); - border-radius: 14px; - padding: 28px; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); +.brand { + display: flex; + align-items: center; + gap: 12px; } -h1 { - margin: 0 0 4px; - font-size: 24px; +.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; } -.tagline { - margin: 0 0 20px; +.brand-text h1 { + margin: 0; + font-size: 18px; + font-weight: 650; + line-height: 1.1; +} + +.sub { + font-size: 12px; 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 { - border: 2px dashed var(--border); - border-radius: 12px; - padding: 40px 20px; + border: 1.5px dashed var(--border); + border-radius: var(--radius); + padding: 26px 16px; text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; @@ -61,61 +177,67 @@ h1 { .dropzone.drag, .dropzone:focus-visible { border-color: var(--accent); - background: rgba(79, 140, 255, 0.06); + background: rgba(63, 123, 246, 0.06); } .dz-main { margin: 0; font-weight: 600; + font-size: 14px; } .dz-sub { margin: 6px 0 0; color: var(--muted); + font-size: 12px; +} + +.dz-file { + margin: 12px 0 0; font-size: 13px; + font-weight: 600; + color: var(--accent); + word-break: break-all; } -.pw-row { - margin-top: 12px; +.field { + margin-top: 16px; } -.pw-row input { +.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: 8px; - padding: 10px 12px; - font-size: 13px; + border-radius: var(--radius); + padding: 11px 12px; + font-size: 14px; } -.pw-row input:focus { +.field input:focus { outline: none; border-color: var(--accent); } -.lock { - display: inline-block; - margin-left: 8px; - font-size: 11px; - font-weight: 600; - color: var(--muted); - background: rgba(79, 140, 255, 0.12); - border: 1px solid rgba(79, 140, 255, 0.3); - border-radius: 99px; - padding: 2px 8px; - vertical-align: middle; -} - +/* ---- progress ---- */ .progress { - margin-top: 20px; + margin-top: 16px; } .progress-label { display: flex; justify-content: space-between; gap: 12px; - font-size: 13px; + font-size: 12px; color: var(--muted); margin-bottom: 8px; } @@ -127,8 +249,8 @@ h1 { } .progress-track { - height: 8px; - background: var(--border); + height: 6px; + background: var(--surface-2); border-radius: 99px; overflow: hidden; } @@ -141,20 +263,27 @@ h1 { transition: width 0.15s; } +/* ---- result banner ---- */ .result { - margin-top: 20px; + margin-top: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: 12px; } -.result-meta { - font-size: 14px; +.result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; margin-bottom: 10px; - color: var(--muted); - word-break: break-all; } -.result-meta strong { - color: var(--text); +.result-head span:first-child { + font-size: 13px; font-weight: 600; + word-break: break-all; } .url-row { @@ -168,65 +297,159 @@ h1 { background: var(--bg); border: 1px solid var(--border); color: var(--text); - border-radius: 8px; + border-radius: var(--radius); padding: 10px 12px; font-size: 13px; } -.result-actions { +/* ---- files panel ---- */ +.panel-head { display: flex; - gap: 8px; - margin-top: 10px; + align-items: baseline; + justify-content: space-between; + gap: 12px; } -button { - font: inherit; - cursor: pointer; - border: none; - border-radius: 8px; - padding: 9px 14px; - font-size: 13px; - background: var(--accent); - color: #fff; - transition: background 0.15s; +.panel-head h2 { + margin-bottom: 4px; } -button:hover { - background: var(--accent-hover); +.count { + font-size: 12px; + color: var(--muted); + font-variant-numeric: tabular-nums; } -button.danger { - background: transparent; - border: 1px solid var(--danger); - color: var(--danger); -} - -button.danger:hover { - background: rgba(224, 82, 82, 0.1); -} - -button.ghost { - background: transparent; - border: 1px solid var(--border); +.files-note { + margin: 0 0 16px; + font-size: 12px; color: var(--muted); } -button.ghost:hover { - color: var(--text); - border-color: var(--muted); +.table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow-x: auto; } -#notice { - margin-top: 16px; +.files-table { + 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; - 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; } #notice.error { color: var(--danger); - background: rgba(224, 82, 82, 0.08); - border: 1px solid rgba(224, 82, 82, 0.3); + background: rgba(224, 85, 85, 0.08); + border: 1px solid rgba(224, 85, 85, 0.3); } #notice.info { @@ -239,119 +462,23 @@ button.ghost:hover { display: none !important; } -/* Client-side upload list */ -.files { - margin-top: 24px; - border-top: 1px solid var(--border); - 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; +/* ---- responsive ---- */ +@media (max-width: 860px) { + .layout { + grid-template-columns: 1fr; } - .dropzone { - padding: 28px 16px; - } - - .url-row { - flex-direction: column; - } - - .file-item { - flex-direction: column; - align-items: stretch; - } - - .file-item-actions { - justify-content: flex-end; + .upload-panel { + position: static; } } + +@media (max-width: 520px) { + .app { + padding: 0 12px 32px; + } + + .topbar { + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/src/routes/download.ts b/src/routes/download.ts index 10c75d1..52383bd 100644 --- a/src/routes/download.ts +++ b/src/routes/download.ts @@ -19,7 +19,7 @@ export async function handleDownload( if (!meta) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); - if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) { + if (!hasValidAuth(req, secret, rawKey)) { const url = new URL(req.url); return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e")); } diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 438820b..1ec110e 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -63,24 +63,21 @@ 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 protected_ = password.trim().length > 0; await storage.writeMetadata({ id, originalName, size, mimeType: mime, downloadKeyHash: hashSecret(key), - passwordHash: protected_ ? hashPassword(password) : undefined, + passwordHash: hashPassword(password), createdAt: new Date().toISOString(), }); - log( - "info", - `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)} protected=${protected_}`, - ); - return json({ id, key, url: `/d/${key}`, originalName, size, protected: protected_ }); + log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); + return json({ id, key, url: `/d/${key}`, originalName, size, protected: true }); } catch (err) { await storage.delete(id); await storage.deletePartial(id);