This commit is contained in:
Anaïs Saraiva
2026-08-15 00:54:04 +02:00
parent ad469460a4
commit f735c64e62
9 changed files with 97 additions and 29 deletions
+23 -19
View File
@@ -2,19 +2,20 @@
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.
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, no queue, and no automatic cleanup.
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.
- Permanent storage on the local filesystem; files survive restarts.
- 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).
- 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).
@@ -47,8 +48,9 @@ cp .env.example .env
| `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/`. |
| `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`. |
@@ -66,12 +68,12 @@ Then open http://localhost:3000.
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...`.
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 stays available indefinitely. It is removed only by explicit
5. The file is removed automatically after one week, or earlier by explicit
deletion.
## Filesystem layout
@@ -128,7 +130,7 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
{
"id": "xmK5lLMRErlN",
"key": "xmK5lLMRErlN",
"url": "/d/xmK5lLMRErlN",
"url": "/xmK5lLMRErlN",
"originalName": "example.zip",
"size": 123456,
"protected": true
@@ -139,27 +141,29 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
the short length keeps share URLs tidy, and the required password adds a second
factor of protection.
### `GET /d/:key` (also `HEAD`)
### `GET /: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`.
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`.
```bash
curl -O http://localhost:3000/d/xmK5lLMRErlN # prompts for password first
curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first
```
### Password protection
Every file requires its password:
1. `GET /d/:key` returns a small HTML password form.
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 `/d/:key`, which then streams the file.
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/d/:key
curl -b cookies.txt -O http://localhost:3000/:key
```
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
@@ -237,7 +241,7 @@ server {
listen 80;
server_name files.example.com;
client_max_body_size 6g; # >= MAX_FILE_SIZE
client_max_body_size 11g; # >= MAX_FILE_SIZE
proxy_request_buffering off; # stream uploads, don't buffer
location / {
@@ -343,7 +347,7 @@ src/
├── log.ts # timestamped logging
├── routes/
│ ├── upload.ts # POST /api/upload
│ ├── download.ts # GET /d/:key
│ ├── download.ts # GET /:key
│ └── delete.ts # DELETE /api/files/:id
└── public/ # plain HTML/CSS/JS frontend (no framework)
```