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 (
413when 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
- Open the site.
- Drop
example.zip(or click to pick a file). - The server stores it permanently and returns a URL like
https://files.example.com/d/8fJ...random-secret.... - 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.
- 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 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": "YOWJyhDXxhe3-L8MpEFBbA",
"key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ",
"originalName": "example.zip",
"size": 123456,
"protected": true
}
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.
curl -O http://localhost:3000/d/YOWJyh...7OQ # prompts for password first
Password protection
Every file requires its password:
GET /d/:keyreturns a small HTML password form.- The browser submits it to
POST /api/auth/:keywithpassword=.... - 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 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. 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 inContent-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) withX-Content-Type-Options: nosniff, so a spoofedtext/htmlorimage/svg+xmlcannot execute scripts. - Secrecy of URLs. All responses carry
Referrer-Policy: no-referrer, and downloads useCache-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=1only when a proxy setsX-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
.partfile 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/uploadand/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.