Files
downloadwf/README.md
T
2026-08-15 00:54:04 +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 on the local filesystem and expire after one week.

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 one week 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 >= 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 10GB Max upload size. Units: B, KB, MB, GB, TB (or plain int).
FILE_TTL_MS 604800000 Milliseconds before an upload expires and is deleted (1 week).
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

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 one week, 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):

{
  "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 and a required password field (this is what the web UI uses). Uploads without a password are rejected with 400.

curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
{
  "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.

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

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

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 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 /: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.