reamping the mardkwon
This commit is contained in:
@@ -93,7 +93,7 @@ Example metadata record (`storage/metadata/<file-id>.json`):
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "nkj2p0ulkijp",
|
"id": "u8wcfcm7",
|
||||||
"originalName": "example.zip",
|
"originalName": "example.zip",
|
||||||
"size": 123456,
|
"size": 123456,
|
||||||
"mimeType": "application/zip",
|
"mimeType": "application/zip",
|
||||||
@@ -128,28 +128,93 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "xmK5lLMRErlN",
|
"id": "u8wcfcm7",
|
||||||
"key": "xmK5lLMRErlN",
|
"key": "u8wcfcm7",
|
||||||
"url": "/xmK5lLMRErlN",
|
"url": "/u8wcfcm7",
|
||||||
"originalName": "example.zip",
|
"originalName": "example.zip",
|
||||||
"size": 123456,
|
"size": 123456,
|
||||||
"protected": true
|
"protected": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`id` and `key` are the same 12-character 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
|
of entropy); the short length keeps share URLs tidy, and an optional password
|
||||||
can add a second factor of protection.
|
can add a second factor of protection.
|
||||||
|
|
||||||
|
### Uploading with curl (tutorial)
|
||||||
|
|
||||||
|
Start an instance, then point curl at `/api/upload`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=https://download.wf
|
||||||
|
```
|
||||||
|
|
||||||
|
**1. Public upload (no password).** The only required field is `file`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -F "[email protected]" "$BASE/api/upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "u8wcfcm7",
|
||||||
|
"key": "u8wcfcm7",
|
||||||
|
"url": "/u8wcfcm7",
|
||||||
|
"originalName": "myfile.zip",
|
||||||
|
"size": 123456,
|
||||||
|
"protected": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` is the share link — put it in a variable with `jq`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
url=$(curl -sS -F "[email protected]" "$BASE/api/upload" | jq -r .url)
|
||||||
|
echo "share: $BASE$url"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Password-protected upload.** Add the `password` field; anyone with the link
|
||||||
|
must then enter it to download:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -F "password=hunter2" -F "[email protected]" "$BASE/api/upload"
|
||||||
|
# => "protected": true
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Download.** Public files stream immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O "$BASE$url"
|
||||||
|
```
|
||||||
|
|
||||||
|
A password-protected file needs the auth cookie first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -c cookies.txt -X POST -d "password=hunter2" "$BASE/api/auth/$key"
|
||||||
|
curl -sS -b cookies.txt -O "$BASE/$key"
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Troubleshooting.**
|
||||||
|
|
||||||
|
- `400 expected a multipart/form-data upload` — missing or malformed
|
||||||
|
`Content-Type` (curl's `-F` already sets it correctly).
|
||||||
|
- `400 no file part in the upload` — no `file=` field was sent.
|
||||||
|
- `413 file too large` — the upload exceeds `MAX_FILE_SIZE`.
|
||||||
|
- `503 storage is full, try again later` — disk usage on the storage
|
||||||
|
filesystem is above `MAX_STORAGE_USAGE`; retry later.
|
||||||
|
|
||||||
|
Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id`
|
||||||
|
(see below).
|
||||||
|
|
||||||
### `GET /:key` (also `HEAD`)
|
### `GET /:key` (also `HEAD`)
|
||||||
|
|
||||||
The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`).
|
The download URL is just the key at the site root (`https://host/u8wcfcm7`).
|
||||||
The legacy `/d/:key` form still works as an alias. A file without a password
|
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
|
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`.
|
before streaming. A wrong or non-existent key returns a generic `404`.
|
||||||
|
|
||||||
```bash
|
```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
|
### 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.
|
sent in the `X-Delete-Key` header — proving you hold the secret.
|
||||||
|
|
||||||
```bash
|
```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`
|
### `GET /api/health`
|
||||||
@@ -194,8 +259,8 @@ paths or stack traces.
|
|||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
- **Secrets.** Each file gets a fresh 12-character lowercase alphanumeric key
|
- **Secrets.** Each file gets a fresh 8-character lowercase alphanumeric key
|
||||||
(`crypto.randomBytes` mapped onto `a-z0-9`, ~62 bits of entropy), used as both
|
(`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
|
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
|
optional password can be an added second factor. No timestamps, incrementing
|
||||||
ids, `Math.random()`, or filenames are ever used as secrets.
|
ids, `Math.random()`, or filenames are ever used as secrets.
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ dd if=/dev/urandom of="$TMP/random.bin" bs=1M count=16 2>/dev/null
|
|||||||
|
|
||||||
check "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
|
check "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null
|
||||||
|
|
||||||
check "upload without password is rejected (400)" test \
|
curl -sS -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/pub.json"
|
||||||
"$(curl -sS -o /dev/null -w '%{http_code}' -F "file=@$TMP/plain.txt" "$BASE/api/upload")" = "400"
|
PUB_ID="$(get "$TMP/pub.json" id)"
|
||||||
|
check "upload without password succeeds" test -n "$PUB_ID"
|
||||||
|
check "upload without password is public" test "$(get "$TMP/pub.json" protected)" = "false"
|
||||||
|
curl -sS -o "$TMP/pubdown.txt" "$BASE$(get "$TMP/pub.json" url)"
|
||||||
|
check "public file downloads directly" cmp "$TMP/plain.txt" "$TMP/pubdown.txt"
|
||||||
|
curl -sS -f -X DELETE -H "X-Delete-Key: $PUB_ID" "$BASE/api/files/$PUB_ID" >/dev/null
|
||||||
|
|
||||||
up "$TMP/plain.txt" > "$TMP/up.json"
|
up "$TMP/plain.txt" > "$TMP/up.json"
|
||||||
KEY="$(get "$TMP/up.json" key)"
|
KEY="$(get "$TMP/up.json" key)"
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
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.
|
// Keys are lowercase alphanumeric only (a-z0-9), 8 characters.
|
||||||
export const KEY_PATTERN = /^[a-z0-9]{12}$/;
|
export const KEY_PATTERN = /^[a-z0-9]{8}$/;
|
||||||
// The id and the download key are the same 12-char token.
|
// The id and the download key are the same 12-char token.
|
||||||
export const ID_PATTERN = KEY_PATTERN;
|
export const ID_PATTERN = KEY_PATTERN;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user