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
+6 -2
View File
@@ -3,12 +3,16 @@ HOST=0.0.0.0
PORT=3000 PORT=3000
# Storage: persistent filesystem directory where uploaded files and # Storage: persistent filesystem directory where uploaded files and
# metadata live. Files survive restarts; no automatic expiration. # metadata live. Files survive restarts.
STORAGE_DIR=./storage STORAGE_DIR=./storage
# Maximum upload size. Accepts plain bytes or units (B, KB, MB, GB, TB, # Maximum upload size. Accepts plain bytes or units (B, KB, MB, GB, TB,
# case-insensitive). A request larger than this is rejected with 413. # case-insensitive). A request larger than this is rejected with 413.
MAX_FILE_SIZE=5GB MAX_FILE_SIZE=10GB
# How long an uploaded file lives before it expires and is deleted.
# In milliseconds. Default is 1 week (604800000).
FILE_TTL_MS=604800000
# Brute-force protection: rate limit per client IP on download and # Brute-force protection: rate limit per client IP on download and
# deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable. # deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable.
+7 -4
View File
@@ -62,7 +62,8 @@ Relevant variables:
HOST=127.0.0.1 # bind loopback only; the proxy talks to it HOST=127.0.0.1 # bind loopback only; the proxy talks to it
PORT=3000 PORT=3000
STORAGE_DIR=/opt/filehost/storage STORAGE_DIR=/opt/filehost/storage
MAX_FILE_SIZE=5GB # must be <= proxy upload limit MAX_FILE_SIZE=10GB # must be <= proxy upload limit
FILE_TTL_MS=604800000 # expire files after 1 week
RATE_LIMIT_ENABLED=1 RATE_LIMIT_ENABLED=1
TRUST_PROXY=1 # proxy sets X-Forwarded-For TRUST_PROXY=1 # proxy sets X-Forwarded-For
``` ```
@@ -116,7 +117,7 @@ server {
} }
location / { location / {
client_max_body_size 6g; # >= MAX_FILE_SIZE client_max_body_size 11g; # >= MAX_FILE_SIZE
proxy_request_buffering off; # stream uploads (critical for large files) proxy_request_buffering off; # stream uploads (critical for large files)
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -170,7 +171,8 @@ If you proxy through Cloudflare:
- Set the DNS record to **Proxied**. - Set the DNS record to **Proxied**.
- Uploads/downloads are subject to Cloudflare's size caps (free plan ~100MB). - Uploads/downloads are subject to Cloudflare's size caps (free plan ~100MB).
For larger files, add a **Cache rule** or **Origin rule** that bypasses For larger files, add a **Cache rule** or **Origin rule** that bypasses
Cloudflare for `/api/upload` and `/d/*` so traffic hits your origin directly. Cloudflare for `/api/upload` and `/*` (or at least the share links) so traffic
hits your origin directly.
- Enable `TRUST_PROXY=1`; the app reads `CF-Connecting-IP` as the client IP. - Enable `TRUST_PROXY=1`; the app reads `CF-Connecting-IP` as the client IP.
## 9. Verification ## 9. Verification
@@ -245,5 +247,6 @@ Files in `storage/` are untouched by updates and keep working.
short-lived HMAC-signed cookie (server secret in `storage/.secret`). short-lived HMAC-signed cookie (server secret in `storage/.secret`).
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set - Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`. when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
- Files never expire. Deletion requires possession of the key - Files auto-expire after one week (configurable via `FILE_TTL_MS`) and are
swept hourly. Deletion also requires possession of the key
(`DELETE /api/files/:id` with `X-Delete-Key`). (`DELETE /api/files/:id` with `X-Delete-Key`).
+23 -19
View File
@@ -2,19 +2,20 @@
A minimal, self-hosted private file hosting service built with **Bun** and A minimal, self-hosted private file hosting service built with **Bun** and
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are **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 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 ## Features
- Drag & drop / file-picker uploads with progress, straight from the browser. - Drag & drop / file-picker uploads with progress, straight from the browser.
- **Required per-file password**: every download is protected by a password. - **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. - A cryptographically secure, unguessable download key for every file.
- Streaming uploads and downloads — no full buffering in RAM, large files work. - 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). - Authenticated deletion (possession of the file's secret key is required).
- Rate-limited download/delete endpoints to blunt brute force. - Rate-limited download/delete endpoints to blunt brute force.
- Zero runtime dependencies (Bun is the only runtime). - Zero runtime dependencies (Bun is the only runtime).
@@ -47,8 +48,9 @@ cp .env.example .env
| `HOST` | `0.0.0.0` | Bind address. | | `HOST` | `0.0.0.0` | Bind address. |
| `PORT` | `3000` | Bind port. | | `PORT` | `3000` | Bind port. |
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). | | `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). | | `MAX_FILE_SIZE` | `10GB` | 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/`. | | `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_MAX` | `300` | Max requests per IP per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. | | `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`. | | `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. 1. Open the site.
2. Drop `example.zip` (or click to pick a file). 2. Drop `example.zip` (or click to pick a file).
3. The server stores it permanently and returns a URL like 3. The server stores it and returns a URL like
`https://files.example.com/d/8fJ...random-secret...`. `https://files.example.com/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file — **after entering 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 the password you set at upload**. Nobody without the secret URL and the
password can download it. 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. deletion.
## Filesystem layout ## Filesystem layout
@@ -128,7 +130,7 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
{ {
"id": "xmK5lLMRErlN", "id": "xmK5lLMRErlN",
"key": "xmK5lLMRErlN", "key": "xmK5lLMRErlN",
"url": "/d/xmK5lLMRErlN", "url": "/xmK5lLMRErlN",
"originalName": "example.zip", "originalName": "example.zip",
"size": 123456, "size": 123456,
"protected": true "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 the short length keeps share URLs tidy, and the required password adds a second
factor of protection. 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 The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`).
password prompt; a wrong or non-existent key returns a generic `404`. 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 ```bash
curl -O http://localhost:3000/d/xmK5lLMRErlN # prompts for password first curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first
``` ```
### Password protection ### Password protection
Every file requires its password: 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=...`. 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, 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 ```bash
curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key 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 The cookie is signed with a server secret (`storage/.secret`) so it cannot be
@@ -237,7 +241,7 @@ server {
listen 80; listen 80;
server_name files.example.com; 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 proxy_request_buffering off; # stream uploads, don't buffer
location / { location / {
@@ -343,7 +347,7 @@ src/
├── log.ts # timestamped logging ├── log.ts # timestamped logging
├── routes/ ├── routes/
│ ├── upload.ts # POST /api/upload │ ├── upload.ts # POST /api/upload
│ ├── download.ts # GET /d/:key │ ├── download.ts # GET /:key
│ └── delete.ts # DELETE /api/files/:id │ └── delete.ts # DELETE /api/files/:id
└── public/ # plain HTML/CSS/JS frontend (no framework) └── public/ # plain HTML/CSS/JS frontend (no framework)
``` ```
+8 -1
View File
@@ -42,10 +42,16 @@ in
maxFileSize = lib.mkOption { maxFileSize = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "5GB"; default = "10GB";
description = "Maximum upload size (B/KB/MB/GB/TB)."; description = "Maximum upload size (B/KB/MB/GB/TB).";
}; };
fileTtlMs = lib.mkOption {
type = lib.types.ints.positive;
default = 7 * 24 * 60 * 60 * 1000;
description = "Milliseconds before an uploaded file expires and is deleted.";
};
trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)"; trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)";
}; };
@@ -73,6 +79,7 @@ in
"HOST=${cfg.host}" "HOST=${cfg.host}"
"STORAGE_DIR=${cfg.storageDir}" "STORAGE_DIR=${cfg.storageDir}"
"MAX_FILE_SIZE=${cfg.maxFileSize}" "MAX_FILE_SIZE=${cfg.maxFileSize}"
"FILE_TTL_MS=${toString cfg.fileTtlMs}"
] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; ] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ];
# no runtime deps, so no `bun install` needed at startup # no runtime deps, so no `bun install` needed at startup
+10 -1
View File
@@ -5,12 +5,15 @@ export interface Config {
port: number; port: number;
storageDir: string; storageDir: string;
maxFileSize: number; maxFileSize: number;
fileTtlMs: number;
rateLimitEnabled: boolean; rateLimitEnabled: boolean;
rateLimitMax: number; rateLimitMax: number;
rateLimitWindowMs: number; rateLimitWindowMs: number;
trustProxy: boolean; trustProxy: boolean;
} }
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
const SIZE_UNITS: Record<string, number> = { const SIZE_UNITS: Record<string, number> = {
B: 1, B: 1,
K: 1 << 10, K: 1 << 10,
@@ -49,9 +52,14 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
} }
const host = env.HOST ?? "0.0.0.0"; const host = env.HOST ?? "0.0.0.0";
const storageDir = resolve(env.STORAGE_DIR ?? "./storage"); const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "5GB"); const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "10GB");
if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB"); if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB");
const fileTtlMs = Number(env.FILE_TTL_MS ?? String(DEFAULT_TTL_MS));
if (!Number.isInteger(fileTtlMs) || fileTtlMs < 1) {
throw new Error(`Invalid FILE_TTL_MS: ${env.FILE_TTL_MS}`);
}
const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300"); const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300");
if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) { if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) {
throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`); throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`);
@@ -66,6 +74,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
port, port,
storageDir, storageDir,
maxFileSize, maxFileSize,
fileTtlMs,
rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true), rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true),
rateLimitMax, rateLimitMax,
rateLimitWindowMs, rateLimitWindowMs,
+6 -1
View File
@@ -3,7 +3,7 @@ import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http"; import { notFound } from "../http";
import { log } from "../log"; import { log } from "../log";
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
import type { Storage } from "../storage"; import { isExpired, type Storage } from "../storage";
export async function handleDownload( export async function handleDownload(
req: Request, req: Request,
@@ -17,6 +17,11 @@ export async function handleDownload(
const meta = await storage.readMetadata(id); const meta = await storage.readMetadata(id);
if (!meta) return notFound(); if (!meta) return notFound();
if (isExpired(meta)) {
await storage.delete(id);
log("info", `download rejected id=${id} expired`);
return notFound();
}
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
if (!hasValidAuth(req, secret, rawKey)) { if (!hasValidAuth(req, secret, rawKey)) {
+2
View File
@@ -25,6 +25,7 @@ export async function handleUpload(
req: Request, req: Request,
storage: Storage, storage: Storage,
maxFileSize: number, maxFileSize: number,
ttlMs: number,
): Promise<Response> { ): Promise<Response> {
const contentType = req.headers.get("content-type"); const contentType = req.headers.get("content-type");
const boundary = contentType ? getBoundary(contentType) : null; const boundary = contentType ? getBoundary(contentType) : null;
@@ -73,6 +74,7 @@ export async function handleUpload(
downloadKeyHash: hashSecret(key), downloadKeyHash: hashSecret(key),
passwordHash: hashPassword(password), passwordHash: hashPassword(password),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
}); });
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
+13 -1
View File
@@ -26,6 +26,17 @@ if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs); const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs);
const startedAt = Date.now(); const startedAt = Date.now();
const expiryTimer = setInterval(() => {
storage
.expireExpired()
.then((removed) => {
if (removed > 0) log("info", `expiry sweep removed ${removed} file(s)`);
})
.catch((err) => {
log("error", `expiry sweep failed: ${err instanceof Error ? err.message : String(err)}`);
});
}, 60 * 60 * 1000);
const STATIC_ROUTES: Record<string, { file: string; type: string }> = { const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" }, "/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" }, "/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
@@ -97,7 +108,7 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
} }
if (path === "/api/upload" && method === "POST") { if (path === "/api/upload" && method === "POST") {
return handleUpload(req, storage, cfg.maxFileSize); return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs);
} }
if (path.startsWith("/api/auth/") && method === "POST") { if (path.startsWith("/api/auth/") && method === "POST") {
@@ -145,6 +156,7 @@ log(
for (const signal of ["SIGINT", "SIGTERM"] as const) { for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => { process.once(signal, () => {
log("info", `received ${signal}, shutting down`); log("info", `received ${signal}, shutting down`);
clearInterval(expiryTimer);
server.stop(); server.stop();
process.exit(0); process.exit(0);
}); });
+22
View File
@@ -11,6 +11,13 @@ export interface FileMeta {
downloadKeyHash: string; downloadKeyHash: string;
passwordHash?: string; passwordHash?: string;
createdAt: string; createdAt: string;
expiresAt?: string;
}
export function isExpired(meta: FileMeta, now: number = Date.now()): boolean {
if (!meta.expiresAt) return false;
const expires = Date.parse(meta.expiresAt);
return Number.isFinite(expires) && expires <= now;
} }
export class UploadTooLargeError extends Error { export class UploadTooLargeError extends Error {
@@ -113,6 +120,21 @@ export class Storage {
await rm(join(this.filesDir, `${id}.part`), { force: true }); await rm(join(this.filesDir, `${id}.part`), { force: true });
} }
async expireExpired(now: number = Date.now()): Promise<number> {
let removed = 0;
for (const entry of await readdir(this.metadataDir)) {
if (!entry.endsWith(".json")) continue;
const id = entry.slice(0, -".json".length);
if (!ID_PATTERN.test(id)) continue;
const meta = await this.readMetadata(id);
if (meta && isExpired(meta, now)) {
await this.delete(id);
removed++;
}
}
return removed;
}
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> { async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
let removedFiles = 0; let removedFiles = 0;
let removedMetadata = 0; let removedMetadata = 0;