Files
downloadwf/README.md
T
2026-08-15 10:31:52 +02:00

358 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# File Drop (VYBECODED)
A minimal, self-hosted private file hosting service built with **Bun** and
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
stored on the local filesystem and expire after 48 hours.
Everything lives on disk — the filesystem is the source of truth. There is no
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.
- 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.
- Configurable maximum upload size (`413` when exceeded, default 10GB).
- Files **auto-expire after 48 hours** and are removed automatically.
- Authenticated deletion (possession of the file's secret key is required).
- Rate-limited download/delete endpoints to blunt brute force.
- Zero runtime dependencies (Bun is the only runtime).
## Requirements
- [Bun](https://bun.sh) >= 1.1 (developed and tested against 1.3).
- Any platform Bun runs on (Linux, macOS, Windows).
## Installation
```bash
bun install
```
There are no runtime dependencies; the only packages are dev-time
(`typescript`, `@types/bun`) used for type-checking.
## Configuration
All configuration is via environment variables. Bun auto-loads `.env`, so you
can copy the example and edit it:
```bash
cp .env.example .env
```
| Variable | Default | Description |
| ---------------------- | ------------- | ----------------------------------------------------------------- |
| `HOST` | `0.0.0.0` | Bind address. |
| `PORT` | `3000` | Bind port. |
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). |
| `MAX_FILE_SIZE` | `10GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). |
| `MAX_STORAGE_USAGE` | `0.5` | Max fraction (0.0–1.0) of the storage disk that may be used; uploads are rejected with `503 try again later` above this. |
| `FILE_TTL_MS` | `172800000` | Milliseconds before an upload expires and is deleted (48 hours, hard maximum). |
| `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/(key)` and `/api/files/`. |
| `RATE_LIMIT_MAX` | `300` | Max requests per IP per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. |
| `TRUST_PROXY` | `0` | Set `1` only behind a proxy that sets `X-Forwarded-For`. |
## Running locally
```bash
bun run start # production (env-driven)
bun run dev # watch mode, restarts on change
```
Then open http://localhost:3000.
## Usage
1. Open the site.
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.
5. The file is removed automatically after 48 hours, or earlier by explicit
deletion.
## Filesystem layout
```
storage/
├── .secret # server signing secret (0600), auto-generated
├── files/
│ ├── <file-id> # the raw uploaded bytes
│ └── <file-id>
└── metadata/
├── <file-id>.json # metadata for each file
└── <file-id>.json
```
Example metadata record (`storage/metadata/<file-id>.json`):
```json
{
"id": "YOWJyhDXxhe3-L8MpEFBbA",
"originalName": "example.zip",
"size": 123456,
"mimeType": "application/zip",
"downloadKeyHash": "669870d2e66b57edbd5128d85d803db0bed262efde61ef1f9e6dcef5c5ffa166",
"createdAt": "2026-08-14T22:08:36.730Z"
}
```
Only a SHA-256 **hash** of the download key is stored — the plaintext key exists
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
into place on completion. At startup the server sweeps for orphans (a file
without metadata or metadata without a file, plus leftover `.part` files) and
removes them. This is consistency repair, not expiration — real files are never
touched.
## API
### `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`.
```bash
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
```
```json
{
"id": "xmK5lLMRErlN",
"key": "xmK5lLMRErlN",
"url": "/xmK5lLMRErlN",
"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.
### `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`.
```bash
curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first
```
### Password protection
Every file requires its password:
1. `GET /: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 `/: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/: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`
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
```
### `GET /api/health`
```json
{ "status": "ok", "uptimeSeconds": 42 }
```
### Status codes
`400` invalid request · `404` unknown/invalid key · `413` file too large ·
`429` rate limited · `500` internal error. Errors are generic JSON and never leak
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.
- **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
(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
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.
Downloads additionally refuse symlinks (`lstat`) as defense in depth.
- **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers
download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed
`text/html` or `image/svg+xml` cannot execute scripts.
- **Secrecy of URLs.** All responses carry `Referrer-Policy: no-referrer`, and
downloads use `Cache-Control: private, no-store`, so the keyed URL is not leaked
to referrers or shared caches.
- **Isolation.** Only the frontend, health, upload, download, and delete routes
exist; everything else is a `404`. `storage/` is never served.
- **Brute force.** Download and delete endpoints are rate-limited per client IP.
Set `TRUST_PROXY=1` only when a proxy sets `X-Forwarded-For`, otherwise clients
can spoof it.
- **Log hygiene.** Logs record file ids, sizes, and sanitized names — never
download keys or full private URLs.
- **Overwrite.** Random ids make collisions effectively impossible; uploads write
to a unique `.part` file and atomically rename.
## Reverse proxy
The app does not handle TLS; put nginx, Caddy, or Cloudflare in front. It works
behind any proxy because it uses relative URLs and doesn't require HTTPS
termination.
Important: large uploads only work if your proxy does **not** buffer the request
body.
### nginx
```nginx
server {
listen 80;
server_name files.example.com;
client_max_body_size 11g; # >= MAX_FILE_SIZE
proxy_request_buffering off; # stream uploads, don't buffer
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s; # slow/large downloads
}
}
```
### Caddy
```
files.example.com {
reverse_proxy 127.0.0.1:3000
request_body {
max_size 6GB
}
}
```
> Cloudflare note: the free plan caps uploads at ~100MB (and downloads at
> ~100MB/500MB depending on plan). For larger files, bypass the proxy for
> `/api/upload` and `/d/` (e.g. a cache rule or origin rule) so traffic hits the
> origin directly.
## Production deployment
> Follow the step-by-step **[HOSTING.md](./HOSTING.md)** guide for a complete
> VPS setup (Bun install, systemd, nginx/Caddy + TLS, firewall, Cloudflare,
> backups, troubleshooting). Summary below.
```bash
# 1. install bun and copy the project
mkdir -p /opt/filehost && cp -r . /opt/filehost && cd /opt/filehost
bun install
# 2. create a dedicated user and writable storage
useradd --system --home /opt/filehost filehost
mkdir -p /opt/filehost/storage && chown -R filehost:filehost /opt/filehost
# 3. configure
cp .env.example /opt/filehost/.env
# edit .env (PORT, STORAGE_DIR, MAX_FILE_SIZE, TRUST_PROXY, ...)
# 4. install the systemd unit (see deploy/filehost.service)
cp deploy/filehost.service /etc/systemd/system/filehost.service
systemctl daemon-reload
systemctl enable --now filehost
systemctl status filehost
# 5. put a reverse proxy in front (above)
```
## Backup
Backup the whole `storage/` directory. Because files and metadata live in the
filesystem, a simple `tar` gives you a consistent, restorable snapshot:
```bash
tar -czf filehost-backup-$(date +%F).tar.gz /opt/filehost/storage
```
A nightly cron job:
```cron
0 2 * * * tar -czf /var/backups/filehost-$(date +\%F).tar.gz -C /opt/filehost/storage .
```
To restore: stop the service, replace the `storage/` directory, start the
service. Files that have their metadata survive; anything incomplete is repaired
by the startup sweep. For crash-consistency you can stop the service during
backup (or rely on `tar` on a quiet filesystem). Metadata writes are atomic
(tmp + rename), so partial records are rare.
## Running the smoke test
```bash
# start the server, then:
./scripts/smoke-test.sh
BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh # explicit URL
```
It exercises upload, download, invalid keys, id-only denial, storage isolation,
Unicode names, path traversal sanitization, a 16MB binary round-trip, and
authenticated deletion. The same checks performed manually during development
also included restart persistence, a 256MB streaming round-trip (peak RSS
~80MB), `413` handling, rate limiting, and log-hygiene (keys never logged).
## Project layout
```
src/
├── server.ts # entry point: config, routing, static files, startup
├── config.ts # env parsing + size parsing
├── storage.ts # filesystem source of truth (save, metadata, delete, sweep)
├── multipart.ts # streaming multipart parser (no full-body buffering)
├── security.ts # key generation, hashing, timing-safe compare, sanitization
├── ratelimit.ts # simple per-IP fixed-window limiter
├── http.ts # response/error helpers + security headers
├── log.ts # timestamped logging
├── routes/
│ ├── upload.ts # POST /api/upload
│ ├── download.ts # GET /:key
│ └── delete.ts # DELETE /api/files/:id
└── public/ # plain HTML/CSS/JS frontend (no framework)
```
Intentionally small: no framework, no DI, no ORM, no repositories. Bun +
TypeScript + filesystem + a simple frontend + secret URLs.