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. - The app binds to loopback; only the proxy is exposed.
- Download/deletion require the unguessable key; everything else is `404`. - Download/deletion require the unguessable key; everything else is `404`.
- Every file requires a password, stored only as an scrypt hash; entry mints a - Files uploaded with a password store only its scrypt hash; entry mints a
short-lived HMAC-signed cookie (server secret in `storage/.secret`). short-lived HMAC-signed cookie (server secret in `storage/.secret`).
Password-less files stream immediately to anyone with the key.
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set - Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`. when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
- Files auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped - Files auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped
+95 -30
View File
@@ -10,7 +10,7 @@ database, no object storage, and no queue. Expired files are swept automatically
## Features ## Features
- Drag & drop / file-picker uploads with progress, straight from the browser. - Drag & drop / file-picker uploads with progress, straight from the browser.
- **Required per-file password**: every download is protected by a password. - **Optional per-file password**: set a password to gate downloads behind a prompt.
- Storage on the local filesystem; files survive restarts. - Storage on the local filesystem; files survive restarts.
- A cryptographically secure, unguessable download key for every file. - A cryptographically secure, unguessable download key for every file.
- Streaming uploads and downloads — no full buffering in RAM, large files work. - Streaming uploads and downloads — no full buffering in RAM, large files work.
@@ -71,9 +71,8 @@ Then open http://localhost:3000.
2. Drop `example.zip` (or click to pick a file). 2. Drop `example.zip` (or click to pick a file).
3. The server stores it and returns a URL like 3. The server stores it and returns a URL like
`https://files.example.com/8fJ...random-secret...`. `https://files.example.com/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file — **after entering 4. Copy the URL. Anyone possessing it can download the file. If you set a
the password you set at upload**. Nobody without the secret URL and the password, they must enter it before the download starts.
password can download it.
5. The file is removed automatically after 48 hours, or earlier by explicit 5. The file is removed automatically after 48 hours, or earlier by explicit
deletion. deletion.
@@ -94,7 +93,7 @@ Example metadata record (`storage/metadata/<file-id>.json`):
```json ```json
{ {
"id": "YOWJyhDXxhe3-L8MpEFBbA", "id": "u8wcfcm7",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456, "size": 123456,
"mimeType": "application/zip", "mimeType": "application/zip",
@@ -119,9 +118,9 @@ touched.
### `POST /api/upload` ### `POST /api/upload`
Multipart/form-data with a `file` field and a **required** `password` field Multipart/form-data with a `file` field and an **optional** `password` field
(this is what the web UI uses). Uploads without a password are rejected with (this is what the web UI uses). When no password is sent, the file is
`400`. downloadable by anyone with the URL.
```bash ```bash
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
@@ -129,33 +128,98 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
```json ```json
{ {
"id": "xmK5lLMRErlN", "id": "u8wcfcm7",
"key": "xmK5lLMRErlN", "key": "u8wcfcm7",
"url": "/xmK5lLMRErlN", "url": "/u8wcfcm7",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456, "size": 123456,
"protected": true "protected": true
} }
``` ```
`id` and `key` are the same 12-character base64url token (~72 bits of entropy); `id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits
the short length keeps share URLs tidy, and the required password adds a second of entropy); the short length keeps share URLs tidy, and an optional password
factor of protection. 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`) ### `GET /:key` (also `HEAD`)
The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`). The download URL is just the key at the site root (`https://host/u8wcfcm7`).
The legacy `/d/:key` form still works as an alias. Every file is The legacy `/d/:key` form still works as an alias. A file without a password
password-protected: without a valid auth cookie the server shows a password streams immediately; if a password is set, the server shows a password prompt
prompt; a wrong or non-existent key returns a generic `404`. before streaming. A wrong or non-existent key returns a generic `404`.
```bash ```bash
curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set
``` ```
### Password protection ### Password protection
Every file requires its password: Files uploaded with a password are gated like this:
1. `GET /:key` returns a small HTML password form. 1. `GET /:key` returns a small HTML password form.
2. The browser submits it to `POST /api/auth/:key` with `password=...`. 2. The browser submits it to `POST /api/auth/:key` with `password=...`.
@@ -168,8 +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 The cookie is signed with a server secret (`storage/.secret`) so it cannot be
forged, and it survives server restarts. Without it, a file is never forged, and it survives server restarts. Without it, a password-protected file
downloadable — not even by the uploader. is never downloadable — not even by the uploader. Files uploaded without a
password stream immediately and have no auth gate.
### `DELETE /api/files/:id` ### `DELETE /api/files/:id`
@@ -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. sent in the `X-Delete-Key` header — proving you hold the secret.
```bash ```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` ### `GET /api/health`
@@ -194,18 +259,18 @@ paths or stack traces.
## Security model ## Security model
- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via - **Secrets.** Each file gets a fresh 8-character lowercase alphanumeric key
`crypto.randomBytes`, used as both the share id and the download key. Keys are (`crypto.randomBytes` mapped onto `a-z0-9`, ~41 bits of entropy), used as both
encoded base64url; guessing one is infeasible at scale, and the required the share id and the download key. Guessing one is infeasible at scale, and an
password is an added second factor. No timestamps, incrementing ids, optional password can be an added second factor. No timestamps, incrementing
`Math.random()`, or filenames are ever used as secrets. ids, `Math.random()`, or filenames are ever used as secrets.
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use - **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
constant-time `timingSafeEqual`. constant-time `timingSafeEqual`.
- **Passwords.** Every file has a password stored only as an **scrypt** hash - **Passwords.** A file's password (if any) is stored only as an **scrypt** hash
(salted), never in plaintext. Successful entry mints a short-lived HMAC-signed (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed
cookie, so access is granted by the server and cannot be forged. cookie, so access is granted by the server and cannot be forged.
- **Path safety.** Filesystem paths are built only from server-generated, - **Path safety.** Filesystem paths are built only from server-generated,
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 only and is sanitized (basename, control/null bytes stripped) before being
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded
traversal, null bytes, and symlink injection via user input are not possible. 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 "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
check "upload without password is rejected (400)" test \ curl -sS -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/pub.json"
"$(curl -sS -o /dev/null -w '%{http_code}' -F "file=@$TMP/plain.txt" "$BASE/api/upload")" = "400" 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" up "$TMP/plain.txt" > "$TMP/up.json"
KEY="$(get "$TMP/up.json" key)" 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; 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); 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; } h1 { margin: 0 0 4px; font-size: 19px; }
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; } 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); .err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
@@ -75,6 +76,19 @@ export function renderPasswordForm(
</head> </head>
<body> <body>
<main class="card"> <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> <h1>Protected file</h1>
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p> <p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
${message} ${message}
+8 -9
View File
@@ -18,6 +18,7 @@ const progressName = $("progressName");
const result = $("result"); const result = $("result");
const resultUrl = $("resultUrl"); const resultUrl = $("resultUrl");
const resultName = $("resultName"); const resultName = $("resultName");
const resultLock = $("resultLock");
const copyBtn = $("copyBtn"); const copyBtn = $("copyBtn");
const notice = $("notice"); const notice = $("notice");
const fileList = $("fileList"); const fileList = $("fileList");
@@ -171,7 +172,8 @@ function renderList() {
name.title = item.originalName; name.title = item.originalName;
const lock = document.createElement("span"); const lock = document.createElement("span");
lock.className = "lock"; lock.className = "lock";
lock.textContent = "Protected"; lock.textContent = item.protected ? "Protected" : "Public";
if (!item.protected) lock.classList.add("public");
nameWrap.appendChild(name); nameWrap.appendChild(name);
nameWrap.appendChild(lock); nameWrap.appendChild(lock);
nameTd.appendChild(nameWrap); nameTd.appendChild(nameWrap);
@@ -254,6 +256,8 @@ function showResult(data) {
progress.classList.add("hidden"); progress.classList.add("hidden");
result.classList.remove("hidden"); result.classList.remove("hidden");
resultName.textContent = data.originalName; resultName.textContent = data.originalName;
resultLock.textContent = data.protected ? "Protected" : "Public";
resultLock.classList.toggle("public", !data.protected);
resultUrl.value = downloadUrl(data); resultUrl.value = downloadUrl(data);
addUpload({ addUpload({
id: data.id, id: data.id,
@@ -262,6 +266,7 @@ function showResult(data) {
originalName: data.originalName, originalName: data.originalName,
size: data.size, size: data.size,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
protected: !!data.protected,
}); });
} }
@@ -277,7 +282,7 @@ function startUpload(file, password) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const form = new FormData(); const form = new FormData();
form.append("password", password); if (password) form.append("password", password);
form.append("file", file); form.append("file", file);
xhr.upload.addEventListener("progress", (e) => { xhr.upload.addEventListener("progress", (e) => {
@@ -344,13 +349,7 @@ function submitUpload() {
showNotice("Choose a file first.", "error"); showNotice("Choose a file first.", "error");
return; return;
} }
const password = pwInput.value; startUpload(currentFile, pwInput.value);
if (!password) {
showNotice("A password is required to protect this file.", "error");
pwInput.focus();
return;
}
startUpload(currentFile, password);
} }
dropzone.addEventListener("click", () => { dropzone.addEventListener("click", () => {
+7 -7
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<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" type="image/svg+xml" href="/logo.svg" />
<title>File Drop</title> <title>File Drop</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
</head> </head>
@@ -12,10 +12,10 @@
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<div class="brand"> <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"> <div class="brand-text">
<h1>File Drop</h1> <h1>File Drop</h1>
<span class="sub">Private, password-protected file hosting</span> <span class="sub">Private file hosting</span>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
@@ -36,11 +36,11 @@
</div> </div>
<div class="field"> <div class="field">
<label for="pwInput">Password</label> <label for="pwInput">Password <span class="optional">(optional)</span></label>
<input <input
type="password" type="password"
id="pwInput" id="pwInput"
placeholder="Required — protects the download" placeholder="Optional — protects the download"
autocomplete="new-password" autocomplete="new-password"
/> />
</div> </div>
@@ -60,7 +60,7 @@
<div id="result" class="result hidden"> <div id="result" class="result hidden">
<div class="result-head"> <div class="result-head">
<span id="resultName"></span> <span id="resultName"></span>
<span class="lock">Protected</span> <span id="resultLock" class="lock">Protected</span>
</div> </div>
<div class="url-row"> <div class="url-row">
<input id="resultUrl" type="text" readonly aria-label="Download URL" /> <input id="resultUrl" type="text" readonly aria-label="Download URL" />
@@ -77,7 +77,7 @@
<span id="fileCount" class="count"></span> <span id="fileCount" class="count"></span>
</div> </div>
<p class="files-note"> <p class="files-note">
Stored locally in this browser only. Every file requires its password. Stored locally in this browser only.
</p> </p>
<div class="table-wrap"> <div class="table-wrap">
+13 -7
View File
@@ -59,13 +59,7 @@ button {
.logo { .logo {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: var(--radius); display: block;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
font-weight: 700;
display: grid;
place-items: center;
letter-spacing: 0.5px;
} }
.brand-text h1 { .brand-text h1 {
@@ -213,6 +207,13 @@ button {
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.field label .optional {
font-weight: 500;
text-transform: lowercase;
letter-spacing: 0;
opacity: 0.75;
}
.field input { .field input {
width: 100%; width: 100%;
background: var(--bg); background: var(--bg);
@@ -403,6 +404,11 @@ button {
padding: 2px 8px; padding: 2px 8px;
} }
.lock.public {
background: rgba(130, 190, 140, 0.1);
border-color: rgba(130, 190, 140, 0.3);
}
.file-sub { .file-sub {
display: block; display: block;
font-size: 12px; font-size: 12px;
+1 -1
View File
@@ -24,7 +24,7 @@ export async function handleDownload(
} }
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
if (!hasValidAuth(req, secret, rawKey)) { if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) {
const url = new URL(req.url); const url = new URL(req.url);
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e")); return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
} }
+3 -3
View File
@@ -74,23 +74,23 @@ export async function handleUpload(
} }
if (!hasFile) return jsonError(400, "no file part in the upload"); if (!hasFile) return jsonError(400, "no file part in the upload");
if (!password.trim()) return jsonError(400, "a password is required");
const originalName = sanitizeOriginalName(filename); const originalName = sanitizeOriginalName(filename);
const passwordHash = password.trim() ? hashPassword(password) : undefined;
await storage.writeMetadata({ await storage.writeMetadata({
id, id,
originalName, originalName,
size, size,
mimeType: mime, mimeType: mime,
downloadKeyHash: hashSecret(key), downloadKeyHash: hashSecret(key),
passwordHash: hashPassword(password), passwordHash,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttlMs).toISOString(), expiresAt: new Date(Date.now() + ttlMs).toISOString(),
}); });
const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`; const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`;
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
return json({ id, key, url, originalName, size, protected: true }); return json({ id, key, url, originalName, size, protected: !!passwordHash });
} catch (err) { } catch (err) {
await storage.delete(id); await storage.delete(id);
await storage.deletePartial(id); await storage.deletePartial(id);
+16 -7
View File
@@ -1,18 +1,27 @@
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
export const KEY_BYTES = 9; // 9 bytes base64url -> 12 chars export const KEY_LENGTH = 8;
export const KEY_LENGTH = 12;
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. // The id and the download key are the same 12-char token.
export const ID_PATTERN = KEY_PATTERN; export const ID_PATTERN = KEY_PATTERN;
export function randomToken(byteCount: number): string { const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789";
return randomBytes(byteCount).toString("base64url"); const CHARSET_SIZE = KEY_CHARSET.length;
} const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE;
export function newFileId(): string { 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 { 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" }, "/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/style.css": { file: "./public/style.css", type: "text/css; 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" }, "/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 }>(); const staticContent = new Map<string, { body: string; type: string }>();