Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97eddbc1a | ||
|
|
4867cf57b6 | ||
|
|
6e76189e46 |
+2
-1
@@ -244,8 +244,9 @@ Files in `storage/` are untouched by updates and keep working.
|
||||
|
||||
- The app binds to loopback; only the proxy is exposed.
|
||||
- Download/deletion require the unguessable key; everything else is `404`.
|
||||
- Every file requires a password, stored only as an scrypt hash; entry mints a
|
||||
- Files uploaded with a password store only its scrypt hash; entry mints a
|
||||
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
|
||||
Password-less files stream immediately to anyone with the key.
|
||||
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
|
||||
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
|
||||
- Files auto-expire after 48 hours (configurable via `FILE_TTL_MS`, hard-capped
|
||||
|
||||
@@ -10,7 +10,7 @@ database, no object storage, and no queue. Expired files are swept automatically
|
||||
## Features
|
||||
|
||||
- Drag & drop / file-picker uploads with progress, straight from the browser.
|
||||
- **Required per-file password**: every download is protected by a password.
|
||||
- **Optional per-file password**: set a password to gate downloads behind a prompt.
|
||||
- Storage on the local filesystem; files survive restarts.
|
||||
- A cryptographically secure, unguessable download key for every file.
|
||||
- Streaming uploads and downloads — no full buffering in RAM, large files work.
|
||||
@@ -71,9 +71,8 @@ Then open http://localhost:3000.
|
||||
2. Drop `example.zip` (or click to pick a file).
|
||||
3. The server stores it and returns a URL like
|
||||
`https://files.example.com/8fJ...random-secret...`.
|
||||
4. Copy the URL. Anyone possessing it can download the file — **after entering
|
||||
the password you set at upload**. Nobody without the secret URL and the
|
||||
password can download it.
|
||||
4. Copy the URL. Anyone possessing it can download the file. If you set a
|
||||
password, they must enter it before the download starts.
|
||||
5. The file is removed automatically after 48 hours, or earlier by explicit
|
||||
deletion.
|
||||
|
||||
@@ -94,7 +93,7 @@ Example metadata record (`storage/metadata/<file-id>.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "YOWJyhDXxhe3-L8MpEFBbA",
|
||||
"id": "u8wcfcm7",
|
||||
"originalName": "example.zip",
|
||||
"size": 123456,
|
||||
"mimeType": "application/zip",
|
||||
@@ -119,9 +118,9 @@ touched.
|
||||
|
||||
### `POST /api/upload`
|
||||
|
||||
Multipart/form-data with a `file` field and a **required** `password` field
|
||||
(this is what the web UI uses). Uploads without a password are rejected with
|
||||
`400`.
|
||||
Multipart/form-data with a `file` field and an **optional** `password` field
|
||||
(this is what the web UI uses). When no password is sent, the file is
|
||||
downloadable by anyone with the URL.
|
||||
|
||||
```bash
|
||||
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
|
||||
@@ -129,33 +128,98 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "xmK5lLMRErlN",
|
||||
"key": "xmK5lLMRErlN",
|
||||
"url": "/xmK5lLMRErlN",
|
||||
"id": "u8wcfcm7",
|
||||
"key": "u8wcfcm7",
|
||||
"url": "/u8wcfcm7",
|
||||
"originalName": "example.zip",
|
||||
"size": 123456,
|
||||
"protected": true
|
||||
}
|
||||
```
|
||||
|
||||
`id` and `key` are the same 12-character base64url token (~72 bits of entropy);
|
||||
the short length keeps share URLs tidy, and the required password adds a second
|
||||
factor of protection.
|
||||
`id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits
|
||||
of entropy); the short length keeps share URLs tidy, and an optional password
|
||||
can add a second factor of protection.
|
||||
|
||||
### Uploading with curl (tutorial)
|
||||
|
||||
Start an instance, then point curl at `/api/upload`:
|
||||
|
||||
```bash
|
||||
BASE=https://download.wf
|
||||
```
|
||||
|
||||
**1. Public upload (no password).** The only required field is `file`:
|
||||
|
||||
```bash
|
||||
curl -sS -F "[email protected]" "$BASE/api/upload"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "u8wcfcm7",
|
||||
"key": "u8wcfcm7",
|
||||
"url": "/u8wcfcm7",
|
||||
"originalName": "myfile.zip",
|
||||
"size": 123456,
|
||||
"protected": false
|
||||
}
|
||||
```
|
||||
|
||||
`url` is the share link — put it in a variable with `jq`:
|
||||
|
||||
```bash
|
||||
url=$(curl -sS -F "[email protected]" "$BASE/api/upload" | jq -r .url)
|
||||
echo "share: $BASE$url"
|
||||
```
|
||||
|
||||
**2. Password-protected upload.** Add the `password` field; anyone with the link
|
||||
must then enter it to download:
|
||||
|
||||
```bash
|
||||
curl -sS -F "password=hunter2" -F "[email protected]" "$BASE/api/upload"
|
||||
# => "protected": true
|
||||
```
|
||||
|
||||
**3. Download.** Public files stream immediately:
|
||||
|
||||
```bash
|
||||
curl -O "$BASE$url"
|
||||
```
|
||||
|
||||
A password-protected file needs the auth cookie first:
|
||||
|
||||
```bash
|
||||
curl -sS -c cookies.txt -X POST -d "password=hunter2" "$BASE/api/auth/$key"
|
||||
curl -sS -b cookies.txt -O "$BASE/$key"
|
||||
```
|
||||
|
||||
**4. Troubleshooting.**
|
||||
|
||||
- `400 expected a multipart/form-data upload` — missing or malformed
|
||||
`Content-Type` (curl's `-F` already sets it correctly).
|
||||
- `400 no file part in the upload` — no `file=` field was sent.
|
||||
- `413 file too large` — the upload exceeds `MAX_FILE_SIZE`.
|
||||
- `503 storage is full, try again later` — disk usage on the storage
|
||||
filesystem is above `MAX_STORAGE_USAGE`; retry later.
|
||||
|
||||
Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id`
|
||||
(see below).
|
||||
|
||||
### `GET /:key` (also `HEAD`)
|
||||
|
||||
The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`).
|
||||
The legacy `/d/:key` form still works as an alias. Every file is
|
||||
password-protected: without a valid auth cookie the server shows a password
|
||||
prompt; a wrong or non-existent key returns a generic `404`.
|
||||
The download URL is just the key at the site root (`https://host/u8wcfcm7`).
|
||||
The legacy `/d/:key` form still works as an alias. A file without a password
|
||||
streams immediately; if a password is set, the server shows a password prompt
|
||||
before streaming. A wrong or non-existent key returns a generic `404`.
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first
|
||||
curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set
|
||||
```
|
||||
|
||||
### Password protection
|
||||
|
||||
Every file requires its password:
|
||||
Files uploaded with a password are gated like this:
|
||||
|
||||
1. `GET /:key` returns a small HTML password form.
|
||||
2. The browser submits it to `POST /api/auth/:key` with `password=...`.
|
||||
@@ -168,8 +232,9 @@ curl -b cookies.txt -O http://localhost:3000/:key
|
||||
```
|
||||
|
||||
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
|
||||
forged, and it survives server restarts. Without it, a file is never
|
||||
downloadable — not even by the uploader.
|
||||
forged, and it survives server restarts. Without it, a password-protected file
|
||||
is never downloadable — not even by the uploader. Files uploaded without a
|
||||
password stream immediately and have no auth gate.
|
||||
|
||||
### `DELETE /api/files/:id`
|
||||
|
||||
@@ -177,7 +242,7 @@ Permanently deletes the file and its metadata. The correct download key must be
|
||||
sent in the `X-Delete-Key` header — proving you hold the secret.
|
||||
|
||||
```bash
|
||||
curl -X DELETE -H "X-Delete-Key: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA
|
||||
curl -X DELETE -H "X-Delete-Key: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7
|
||||
```
|
||||
|
||||
### `GET /api/health`
|
||||
@@ -194,18 +259,18 @@ paths or stack traces.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via
|
||||
`crypto.randomBytes`, used as both the share id and the download key. Keys are
|
||||
encoded base64url; guessing one is infeasible at scale, and the required
|
||||
password is an added second factor. No timestamps, incrementing ids,
|
||||
`Math.random()`, or filenames are ever used as secrets.
|
||||
- **Secrets.** Each file gets a fresh 8-character lowercase alphanumeric key
|
||||
(`crypto.randomBytes` mapped onto `a-z0-9`, ~41 bits of entropy), used as both
|
||||
the share id and the download key. Guessing one is infeasible at scale, and an
|
||||
optional password can be an added second factor. No timestamps, incrementing
|
||||
ids, `Math.random()`, or filenames are ever used as secrets.
|
||||
- **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use
|
||||
constant-time `timingSafeEqual`.
|
||||
- **Passwords.** Every file has a password stored only as an **scrypt** hash
|
||||
- **Passwords.** A file's password (if any) is stored only as an **scrypt** hash
|
||||
(salted), never in plaintext. Successful entry mints a short-lived HMAC-signed
|
||||
cookie, so access is granted by the server and cannot be forged.
|
||||
- **Path safety.** Filesystem paths are built only from server-generated,
|
||||
regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata
|
||||
regex-validated ids (`^[a-z0-9]{12}$`). The original filename is metadata
|
||||
only and is sanitized (basename, control/null bytes stripped) before being
|
||||
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded
|
||||
traversal, null bytes, and symlink injection via user input are not possible.
|
||||
|
||||
@@ -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 |
@@ -50,8 +50,13 @@ dd if=/dev/urandom of="$TMP/random.bin" bs=1M count=16 2>/dev/null
|
||||
|
||||
check "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
|
||||
|
||||
check "upload without password is rejected (400)" test \
|
||||
"$(curl -sS -o /dev/null -w '%{http_code}' -F "file=@$TMP/plain.txt" "$BASE/api/upload")" = "400"
|
||||
curl -sS -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/pub.json"
|
||||
PUB_ID="$(get "$TMP/pub.json" id)"
|
||||
check "upload without password succeeds" test -n "$PUB_ID"
|
||||
check "upload without password is public" test "$(get "$TMP/pub.json" protected)" = "false"
|
||||
curl -sS -o "$TMP/pubdown.txt" "$BASE$(get "$TMP/pub.json" url)"
|
||||
check "public file downloads directly" cmp "$TMP/plain.txt" "$TMP/pubdown.txt"
|
||||
curl -sS -f -X DELETE -H "X-Delete-Key: $PUB_ID" "$BASE/api/files/$PUB_ID" >/dev/null
|
||||
|
||||
up "$TMP/plain.txt" > "$TMP/up.json"
|
||||
KEY="$(get "$TMP/up.json" key)"
|
||||
|
||||
+14
@@ -60,6 +60,7 @@ export function renderPasswordForm(
|
||||
width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a;
|
||||
border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
||||
}
|
||||
.brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; }
|
||||
h1 { margin: 0 0 4px; font-size: 19px; }
|
||||
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; }
|
||||
.err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
|
||||
@@ -75,6 +76,19 @@ export function renderPasswordForm(
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<svg class="brand-logo" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="uG" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F46E5"/>
|
||||
<stop offset="1" stop-color="#3B82F6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="48" y="48" width="416" height="416" rx="94" fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"/>
|
||||
<path d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z" fill="#fff" stroke="#E7ECF5" stroke-width="4"/>
|
||||
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
|
||||
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
|
||||
<path d="M256 300V196 M222 230l34-34 34 34 M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14" stroke="url(#uG)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<h1>Protected file</h1>
|
||||
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
|
||||
${message}
|
||||
|
||||
+8
-9
@@ -18,6 +18,7 @@ const progressName = $("progressName");
|
||||
const result = $("result");
|
||||
const resultUrl = $("resultUrl");
|
||||
const resultName = $("resultName");
|
||||
const resultLock = $("resultLock");
|
||||
const copyBtn = $("copyBtn");
|
||||
const notice = $("notice");
|
||||
const fileList = $("fileList");
|
||||
@@ -171,7 +172,8 @@ function renderList() {
|
||||
name.title = item.originalName;
|
||||
const lock = document.createElement("span");
|
||||
lock.className = "lock";
|
||||
lock.textContent = "Protected";
|
||||
lock.textContent = item.protected ? "Protected" : "Public";
|
||||
if (!item.protected) lock.classList.add("public");
|
||||
nameWrap.appendChild(name);
|
||||
nameWrap.appendChild(lock);
|
||||
nameTd.appendChild(nameWrap);
|
||||
@@ -254,6 +256,8 @@ function showResult(data) {
|
||||
progress.classList.add("hidden");
|
||||
result.classList.remove("hidden");
|
||||
resultName.textContent = data.originalName;
|
||||
resultLock.textContent = data.protected ? "Protected" : "Public";
|
||||
resultLock.classList.toggle("public", !data.protected);
|
||||
resultUrl.value = downloadUrl(data);
|
||||
addUpload({
|
||||
id: data.id,
|
||||
@@ -262,6 +266,7 @@ function showResult(data) {
|
||||
originalName: data.originalName,
|
||||
size: data.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
protected: !!data.protected,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,7 +282,7 @@ function startUpload(file, password) {
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const form = new FormData();
|
||||
form.append("password", password);
|
||||
if (password) form.append("password", password);
|
||||
form.append("file", file);
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
@@ -344,13 +349,7 @@ function submitUpload() {
|
||||
showNotice("Choose a file first.", "error");
|
||||
return;
|
||||
}
|
||||
const password = pwInput.value;
|
||||
if (!password) {
|
||||
showNotice("A password is required to protect this file.", "error");
|
||||
pwInput.focus();
|
||||
return;
|
||||
}
|
||||
startUpload(currentFile, password);
|
||||
startUpload(currentFile, pwInput.value);
|
||||
}
|
||||
|
||||
dropzone.addEventListener("click", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<title>File Drop</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
@@ -12,10 +12,10 @@
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="logo" aria-hidden="true">FD</div>
|
||||
<img class="logo" src="/logo.svg" width="40" height="40" alt="File Drop" />
|
||||
<div class="brand-text">
|
||||
<h1>File Drop</h1>
|
||||
<span class="sub">Private, password-protected file hosting</span>
|
||||
<span class="sub">Private file hosting</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -36,11 +36,11 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pwInput">Password</label>
|
||||
<label for="pwInput">Password <span class="optional">(optional)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
id="pwInput"
|
||||
placeholder="Required — protects the download"
|
||||
placeholder="Optional — protects the download"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@
|
||||
<div id="result" class="result hidden">
|
||||
<div class="result-head">
|
||||
<span id="resultName"></span>
|
||||
<span class="lock">Protected</span>
|
||||
<span id="resultLock" class="lock">Protected</span>
|
||||
</div>
|
||||
<div class="url-row">
|
||||
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
|
||||
@@ -77,7 +77,7 @@
|
||||
<span id="fileCount" class="count"></span>
|
||||
</div>
|
||||
<p class="files-note">
|
||||
Stored locally in this browser only. Every file requires its password.
|
||||
Stored locally in this browser only.
|
||||
</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
|
||||
+13
-7
@@ -59,13 +59,7 @@ button {
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
@@ -213,6 +207,13 @@ button {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.field label .optional {
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
@@ -403,6 +404,11 @@ button {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.lock.public {
|
||||
background: rgba(130, 190, 140, 0.1);
|
||||
border-color: rgba(130, 190, 140, 0.3);
|
||||
}
|
||||
|
||||
.file-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function handleDownload(
|
||||
}
|
||||
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
|
||||
|
||||
if (!hasValidAuth(req, secret, rawKey)) {
|
||||
if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) {
|
||||
const url = new URL(req.url);
|
||||
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
|
||||
}
|
||||
|
||||
@@ -74,23 +74,23 @@ export async function handleUpload(
|
||||
}
|
||||
|
||||
if (!hasFile) return jsonError(400, "no file part in the upload");
|
||||
if (!password.trim()) return jsonError(400, "a password is required");
|
||||
|
||||
const originalName = sanitizeOriginalName(filename);
|
||||
const passwordHash = password.trim() ? hashPassword(password) : undefined;
|
||||
await storage.writeMetadata({
|
||||
id,
|
||||
originalName,
|
||||
size,
|
||||
mimeType: mime,
|
||||
downloadKeyHash: hashSecret(key),
|
||||
passwordHash: hashPassword(password),
|
||||
passwordHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
});
|
||||
|
||||
const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`;
|
||||
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
||||
return json({ id, key, url, originalName, size, protected: true });
|
||||
return json({ id, key, url, originalName, size, protected: !!passwordHash });
|
||||
} catch (err) {
|
||||
await storage.delete(id);
|
||||
await storage.deletePartial(id);
|
||||
|
||||
+16
-7
@@ -1,18 +1,27 @@
|
||||
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const KEY_BYTES = 9; // 9 bytes base64url -> 12 chars
|
||||
export const KEY_LENGTH = 12;
|
||||
export const KEY_LENGTH = 8;
|
||||
|
||||
export const KEY_PATTERN = /^[A-Za-z0-9_-]{12}$/;
|
||||
// Keys are lowercase alphanumeric only (a-z0-9), 8 characters.
|
||||
export const KEY_PATTERN = /^[a-z0-9]{8}$/;
|
||||
// The id and the download key are the same 12-char token.
|
||||
export const ID_PATTERN = KEY_PATTERN;
|
||||
|
||||
export function randomToken(byteCount: number): string {
|
||||
return randomBytes(byteCount).toString("base64url");
|
||||
}
|
||||
const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const CHARSET_SIZE = KEY_CHARSET.length;
|
||||
const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE;
|
||||
|
||||
export function newFileId(): string {
|
||||
return randomToken(KEY_BYTES);
|
||||
let out = "";
|
||||
const bytes = randomBytes(KEY_LENGTH * 2);
|
||||
let i = 0;
|
||||
while (out.length < KEY_LENGTH) {
|
||||
const b = bytes[i++];
|
||||
if (b === undefined) throw new Error("random source exhausted");
|
||||
if (b >= MAX_BYTE) continue;
|
||||
out += KEY_CHARSET[b % CHARSET_SIZE];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hashSecret(value: string): string {
|
||||
|
||||
@@ -42,6 +42,7 @@ const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
|
||||
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
||||
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
|
||||
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
|
||||
"/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" },
|
||||
};
|
||||
|
||||
const staticContent = new Map<string, { body: string; type: string }>();
|
||||
|
||||
Reference in New Issue
Block a user