Files
downloadwf/README.md
T
2026-08-15 00:28:32 +02:00

13 KiB

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.
  • Optional per-file password: protect a download link with 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 >= 1.1 (developed and tested against 1.3).
  • Any platform Bun runs on (Linux, macOS, Windows).

Installation

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:

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

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; nobody without the secret can. If a password was set, the downloader must also enter 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/
│   ├── <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):

{
  "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). 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 (this is what the web UI uses). An optional password field protects the download with a password.

curl -F "[email protected]" http://localhost:3000/api/upload
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
{
  "id": "YOWJyhDXxhe3-L8MpEFBbA",
  "key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
  "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
  "originalName": "example.zip",
  "size": 123456,
  "protected": false
}

GET /d/:key (also HEAD)

Streams the file. Requires the full secret key; any wrong or non-existent key returns a generic 404. If the file has a password, the server first shows a password prompt (see below).

curl -O http://localhost:3000/d/YOWJyh...7OQ

Password protection

For files uploaded with a password field:

  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.
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 protected 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.

curl -X DELETE -H "X-Delete-Key: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA

GET /api/health

{ "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 128-bit id + 256-bit secret via crypto.randomBytes. Keys are encoded base64url; brute forcing them is computationally infeasible. 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. An optional per-file password is 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

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 guide for a complete VPS setup (Bun install, systemd, nginx/Caddy + TLS, firewall, Cloudflare, backups, troubleshooting). Summary below.

# 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:

tar -czf filehost-backup-$(date +%F).tar.gz /opt/filehost/storage

A nightly cron job:

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

# 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.