Compare commits
2
Commits
5f201cf725
...
d8b9fbe993
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b9fbe993 | ||
|
|
954384d1e4 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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
@@ -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 & 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 & 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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") {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user