# 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 permanently on the local filesystem and never expire. Everything lives on disk — the filesystem is the source of truth. There is no database, no object storage, no queue, and no automatic cleanup. ## Features - 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. - 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). - 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` | `5GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). | | `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/d/` 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 permanently and returns a URL like `https://files.example.com/d/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 stays available indefinitely. It is removed only by explicit deletion. ## Filesystem layout ``` storage/ ├── .secret # server signing secret (0600), auto-generated ├── files/ │ ├── # the raw uploaded bytes │ └── └── metadata/ ├── .json # metadata for each file └── .json ``` Example metadata record (`storage/metadata/.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 `.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 "file=@secret.zip" http://localhost:3000/api/upload ``` ```json { "id": "xmK5lLMRErlN", "key": "xmK5lLMRErlN", "url": "/d/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 /d/:key` (also `HEAD`) 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/d/xmK5lLMRErlN # 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` 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 6g; # >= 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 /d/: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.