Part O
This commit is contained in:
@@ -1,439 +1,84 @@
|
||||
# File Drop (VYBECODED)
|
||||
# File Drop
|
||||
|
||||
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.
|
||||
Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier,
|
||||
on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent
|
||||
directement dans le navigateur ; les autres fichiers se téléchargent.
|
||||
|
||||
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.
|
||||
- **Backend** — Rust (Axum + Tokio)
|
||||
- **Frontend** — Svelte (Vite + Tailwind)
|
||||
- **Stockage** — système de fichiers local, sans base de données
|
||||
|
||||
## Features
|
||||
|
||||
- Drag & drop / file-picker uploads with progress, straight from the browser.
|
||||
- **Media files render inline** (image/video/audio/PDF/text) as direct, embeddable
|
||||
links, with one-click **Direct / HTML / BBCode / Markdown** link formats.
|
||||
- **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 (`413` when 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](https://bun.sh) >= 1.1 (developed and tested against 1.3).
|
||||
- Any platform Bun runs on (Linux, macOS, Windows).
|
||||
|
||||
## Installation
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
bun install
|
||||
# 1. Construire le frontend (produit frontend/dist)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 2. Lancer le serveur (depuis la racine du projet)
|
||||
cargo run --release --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
There are no runtime dependencies; the only packages are dev-time
|
||||
(`typescript`, `@types/bun`) used for type-checking.
|
||||
Ouvrir ensuite http://localhost:3000.
|
||||
|
||||
> Les chemins `STORAGE_DIR` et `STATIC_DIR` sont relatifs au dossier de
|
||||
> lancement. Lance donc le serveur depuis la racine du projet (ou règle ces
|
||||
> variables en absolu dans `.env`).
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables. Bun auto-loads `.env`, so you
|
||||
can copy the example and edit it:
|
||||
Copier `backend/.env.example` vers `.env` puis l'adapter.
|
||||
|
||||
```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` | `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
|
||||
|
||||
```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 and returns a URL like
|
||||
`https://files.example.com/8fJ...random-secret...`.
|
||||
4. Copy the URL. Anyone possessing it can download the file. If you set a
|
||||
password, they must enter it before the download starts.
|
||||
5. 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`):
|
||||
|
||||
```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](#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.
|
||||
| Variable | Défaut | Rôle |
|
||||
| ---------------------- | ----------------- | ----------------------------------------- |
|
||||
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. |
|
||||
| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. |
|
||||
| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. |
|
||||
| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. |
|
||||
| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). |
|
||||
| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). |
|
||||
| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. |
|
||||
| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. |
|
||||
| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. |
|
||||
| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). |
|
||||
|
||||
## API
|
||||
|
||||
### `POST /api/upload`
|
||||
| Méthode | Route | Rôle |
|
||||
| --------- | -------------------- | ------------------------------------------- |
|
||||
| `POST` | `/api/upload` | Envoi d'un fichier (`multipart/form-data`). |
|
||||
| `GET` | `/:key` | Téléchargement (inline pour les médias). |
|
||||
| `POST` | `/api/auth/:key` | Mot de passe d'un fichier protégé. |
|
||||
| `DELETE` | `/api/files/:id` | Suppression (en-tête `X-Delete-Key`). |
|
||||
| `GET` | `/api/health` | État du service. |
|
||||
|
||||
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.
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
|
||||
curl -F "[email protected]" http://localhost:3000/api/upload
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "u8wcfcm7",
|
||||
"key": "u8wcfcm7",
|
||||
"url": "/u8wcfcm7.zip",
|
||||
"originalName": "example.zip",
|
||||
"downloadName": "u8wcfcm7.zip",
|
||||
"size": 123456,
|
||||
"mimeType": "application/zip",
|
||||
"protected": true
|
||||
}
|
||||
Le lien renvoyé est de la forme `http://localhost:3000/<clé>.png`. Ajouter
|
||||
`?dl=1` force le téléchargement au lieu de l'affichage.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/ serveur Rust (Axum)
|
||||
frontend/ interface Svelte (Vite + Tailwind)
|
||||
scripts/ tests
|
||||
storage/ données (généré au premier lancement)
|
||||
```
|
||||
|
||||
`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`:
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
BASE=https://download.wf
|
||||
cargo build # dans backend/
|
||||
bash scripts/e2e-test.sh
|
||||
```
|
||||
|
||||
**1. Public upload (no password).** The only required field is `file`:
|
||||
|
||||
```bash
|
||||
curl -sS -F "[email protected]" "$BASE/api/upload"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "u8wcfcm7",
|
||||
"key": "u8wcfcm7",
|
||||
"url": "/u8wcfcm7.zip",
|
||||
"originalName": "myfile.zip",
|
||||
"size": 123456,
|
||||
"mimeType": "application/zip",
|
||||
"protected": false
|
||||
}
|
||||
```
|
||||
|
||||
`url` is the share link — put it in a variable with `jq`:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
curl -sS -F "password=hunter2" -F "[email protected]" "$BASE/api/upload"
|
||||
# => "protected": true
|
||||
```
|
||||
|
||||
**3. Download.** Public files stream immediately:
|
||||
|
||||
```bash
|
||||
curl -O "$BASE$url"
|
||||
```
|
||||
|
||||
A password-protected file needs the auth cookie first:
|
||||
|
||||
```bash
|
||||
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 malformed
|
||||
`Content-Type` (curl's `-F` already sets it correctly).
|
||||
- `400 no file part in the upload` — no `file=` field was sent.
|
||||
- `413 file too large` — the upload exceeds `MAX_FILE_SIZE`.
|
||||
- `503 storage is full, try again later` — disk usage on the storage
|
||||
filesystem is above `MAX_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 the key at the site root, optionally with the file extension
|
||||
appended for a Catbox-style link (`https://host/u8wcfcm7` or
|
||||
`https://host/u8wcfcm7.zip`). 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`.
|
||||
|
||||
Media files (images, video, audio, PDF, plain text) are served `inline` so they
|
||||
render directly in the browser and can be embedded (`<img>`, Discord, etc.).
|
||||
Everything else — and any URL with `?dl=1` — is sent as an `attachment` download.
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set
|
||||
curl -O "http://localhost:3000/u8wcfcm7.zip?dl=1" # always force a download
|
||||
```
|
||||
|
||||
### Password protection
|
||||
|
||||
Files uploaded with a password are gated like this:
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
curl -X DELETE -H "X-Delete-Key: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7
|
||||
```
|
||||
|
||||
### `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 8-character lowercase alphanumeric key
|
||||
(`crypto.randomBytes` mapped onto `a-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 in
|
||||
`Content-Disposition` is 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.** Non-media 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. Only a whitelist of
|
||||
inert media types (image, video, audio, PDF, plain text — never HTML or SVG) is
|
||||
served `inline` for direct/embed links.
|
||||
- **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 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](./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 /: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.
|
||||
Le test couvre l'envoi, le téléchargement, l'authentification par mot de passe,
|
||||
les clés invalides, la sanitisation des noms et la suppression.
|
||||
|
||||
Reference in New Issue
Block a user