15 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 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.
- Optional per-file password: set a password to gate downloads behind a prompt.
- 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, 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 >= 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). |
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). |
KEY_LENGTH |
8 |
Length in characters of the upload/download key token (a-z0-9). |
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
- Open the site.
- Drop
example.zip(or click to pick a file). - The server stores it and returns a URL like
https://files.example.com/8fJ...random-secret.... - Copy the URL. Anyone possessing it can download the file. If you set a password, they must enter it before the download starts.
- 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):
{
"id": "u8wcfcm7",
"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 an optional password field
(this is what the web UI uses). When no password is sent, the file is
downloadable by anyone with the URL.
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
"url": "/u8wcfcm7",
"originalName": "example.zip",
"downloadName": "u8wcfcm7.zip",
"size": 123456,
"protected": true
}
id and key are the same 8-character lowercase alphanumeric token (~41 bits
of entropy); the short length keeps share URLs tidy, and an optional password
can add a second factor of protection.
Uploading with curl (tutorial)
Start an instance, then point curl at /api/upload:
BASE=https://download.wf
1. Public upload (no password). The only required field is file:
curl -sS -F "[email protected]" "$BASE/api/upload"
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
"url": "/u8wcfcm7",
"originalName": "myfile.zip",
"size": 123456,
"protected": false
}
url is the share link — put it in a variable with jq:
url=$(curl -sS -F "[email protected]" "$BASE/api/upload" | jq -r .url)
echo "share: $BASE$url"
2. Password-protected upload. Add the password field; anyone with the link
must then enter it to download:
curl -sS -F "password=hunter2" -F "[email protected]" "$BASE/api/upload"
# => "protected": true
3. Download. Public files stream immediately:
curl -O "$BASE$url"
A password-protected file needs the auth cookie first:
curl -sS -c cookies.txt -X POST -d "password=hunter2" "$BASE/api/auth/$key"
curl -sS -b cookies.txt -O "$BASE/$key"
4. Troubleshooting.
400 expected a multipart/form-data upload— missing or malformedContent-Type(curl's-Falready sets it correctly).400 no file part in the upload— nofile=field was sent.413 file too large— the upload exceedsMAX_FILE_SIZE.503 storage is full, try again later— disk usage on the storage filesystem is aboveMAX_STORAGE_USAGE; retry later.
Keep id/key around to delete the file afterwards via DELETE /api/files/:id
(see below).
GET /:key (also HEAD)
The download URL is just the key at the site root (https://host/u8wcfcm7).
The legacy /d/:key form still works as an alias. A file without a password
streams immediately; if a password is set, the server shows a password prompt
before streaming. A wrong or non-existent key returns a generic 404.
curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set
Password protection
Files uploaded with a password are gated like this:
GET /: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
/: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 password-protected file
is never downloadable — not even by the uploader. Files uploaded without a
password stream immediately and have no auth gate.
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: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7
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 8-character lowercase alphanumeric key
(
crypto.randomBytesmapped ontoa-z0-9, ~41 bits of entropy), used as both the share id and the download key. Guessing one is infeasible at scale, and an optional password can be 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. A file's password (if any) 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-z0-9]{8}$). The original filename is metadata only and is sanitized (basename, control/null bytes stripped) before being stored; downloads are sent as<upload-key>.<extension>, so the basename inContent-Dispositionis always the server-generated key. 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 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/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 /: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.