diff --git a/README.md b/README.md index 86a1ca8..5cb0c5c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Example metadata record (`storage/metadata/.json`): ```json { - "id": "nkj2p0ulkijp", + "id": "u8wcfcm7", "originalName": "example.zip", "size": 123456, "mimeType": "application/zip", @@ -128,28 +128,93 @@ curl -F "password=hunter2" -F "file=@secret.zip" 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 lowercase alphanumeric token (~62 bits +`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 "file=@myfile.zip" "$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 "file=@myfile.zip" "$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 "file=@myfile.zip" "$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 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 # streams, or prompts if password set +curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set ``` ### Password protection @@ -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: nkj2p0ulkijp" http://localhost:3000/api/files/nkj2p0ulkijp +curl -X DELETE -H "X-Delete-Key: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7 ``` ### `GET /api/health` @@ -194,8 +259,8 @@ paths or stack traces. ## Security model -- **Secrets.** Each file gets a fresh 12-character lowercase alphanumeric key - (`crypto.randomBytes` mapped onto `a-z0-9`, ~62 bits of entropy), used as both +- **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. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1de96f6..2d59a4b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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)" diff --git a/src/security.ts b/src/security.ts index 58a0e01..dfe5e00 100644 --- a/src/security.ts +++ b/src/security.ts @@ -1,9 +1,9 @@ import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; -export const KEY_LENGTH = 12; +export const KEY_LENGTH = 8; -// Keys are lowercase alphanumeric only (a-z0-9), 12 characters. -export const KEY_PATTERN = /^[a-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;