From f735c64e62b1a3492c7ea8cf7f0f75a9956f5282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:54:04 +0200 Subject: [PATCH] fix --- .env.example | 8 +++++-- HOSTING.md | 11 ++++++---- README.md | 42 ++++++++++++++++++++----------------- deploy/nixos/downloadwf.nix | 9 +++++++- src/config.ts | 11 +++++++++- src/routes/download.ts | 7 ++++++- src/routes/upload.ts | 2 ++ src/server.ts | 14 ++++++++++++- src/storage.ts | 22 +++++++++++++++++++ 9 files changed, 97 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index f71b84a..9ffec1d 100644 --- a/.env.example +++ b/.env.example @@ -3,12 +3,16 @@ HOST=0.0.0.0 PORT=3000 # Storage: persistent filesystem directory where uploaded files and -# metadata live. Files survive restarts; no automatic expiration. +# metadata live. Files survive restarts. STORAGE_DIR=./storage # Maximum upload size. Accepts plain bytes or units (B, KB, MB, GB, TB, # 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 # deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable. diff --git a/HOSTING.md b/HOSTING.md index fdb5841..6c0ecd2 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -62,7 +62,8 @@ Relevant variables: HOST=127.0.0.1 # bind loopback only; the proxy talks to it PORT=3000 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 TRUST_PROXY=1 # proxy sets X-Forwarded-For ``` @@ -116,7 +117,7 @@ server { } 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_http_version 1.1; @@ -170,7 +171,8 @@ If you proxy through Cloudflare: - Set the DNS record to **Proxied**. - Uploads/downloads are subject to Cloudflare's size caps (free plan ~100MB). 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. ## 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`). - Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set 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`). \ No newline at end of file diff --git a/README.md b/README.md index 144a984..0cc7ad3 100644 --- a/README.md +++ b/README.md @@ -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 "file=@secret.zip" 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 "file=@secret.zip" 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) ``` diff --git a/deploy/nixos/downloadwf.nix b/deploy/nixos/downloadwf.nix index 932682d..5643f29 100644 --- a/deploy/nixos/downloadwf.nix +++ b/deploy/nixos/downloadwf.nix @@ -42,10 +42,16 @@ in maxFileSize = lib.mkOption { type = lib.types.str; - default = "5GB"; + default = "10GB"; 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)"; }; @@ -73,6 +79,7 @@ in "HOST=${cfg.host}" "STORAGE_DIR=${cfg.storageDir}" "MAX_FILE_SIZE=${cfg.maxFileSize}" + "FILE_TTL_MS=${toString cfg.fileTtlMs}" ] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; # no runtime deps, so no `bun install` needed at startup diff --git a/src/config.ts b/src/config.ts index 578a421..cb564af 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,12 +5,15 @@ export interface Config { port: number; storageDir: string; maxFileSize: number; + fileTtlMs: number; rateLimitEnabled: boolean; rateLimitMax: number; rateLimitWindowMs: number; trustProxy: boolean; } +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week + const SIZE_UNITS: Record = { B: 1, K: 1 << 10, @@ -49,9 +52,14 @@ export function loadConfig(env: Record = process.env } const host = env.HOST ?? "0.0.0.0"; 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"); + 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"); if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) { throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`); @@ -66,6 +74,7 @@ export function loadConfig(env: Record = process.env port, storageDir, maxFileSize, + fileTtlMs, rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true), rateLimitMax, rateLimitWindowMs, diff --git a/src/routes/download.ts b/src/routes/download.ts index 52383bd..ec2d42d 100644 --- a/src/routes/download.ts +++ b/src/routes/download.ts @@ -3,7 +3,7 @@ import { hasValidAuth, renderPasswordForm } from "../auth"; import { notFound } from "../http"; import { log } from "../log"; import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; -import type { Storage } from "../storage"; +import { isExpired, type Storage } from "../storage"; export async function handleDownload( req: Request, @@ -17,6 +17,11 @@ export async function handleDownload( const meta = await storage.readMetadata(id); 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 (!hasValidAuth(req, secret, rawKey)) { diff --git a/src/routes/upload.ts b/src/routes/upload.ts index c156bad..ae86e54 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -25,6 +25,7 @@ export async function handleUpload( req: Request, storage: Storage, maxFileSize: number, + ttlMs: number, ): Promise { const contentType = req.headers.get("content-type"); const boundary = contentType ? getBoundary(contentType) : null; @@ -73,6 +74,7 @@ export async function handleUpload( downloadKeyHash: hashSecret(key), passwordHash: hashPassword(password), createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttlMs).toISOString(), }); log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); diff --git a/src/server.ts b/src/server.ts index 0a1e88c..a0868d6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,17 @@ if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) { const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs); 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 = { "/": { 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): Promise { } 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") { @@ -145,6 +156,7 @@ log( for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, () => { log("info", `received ${signal}, shutting down`); + clearInterval(expiryTimer); server.stop(); process.exit(0); }); diff --git a/src/storage.ts b/src/storage.ts index 751c24a..03133d9 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -11,6 +11,13 @@ export interface FileMeta { downloadKeyHash: string; passwordHash?: 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 { @@ -113,6 +120,21 @@ export class Storage { await rm(join(this.filesDir, `${id}.part`), { force: true }); } + async expireExpired(now: number = Date.now()): Promise { + 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 }> { let removedFiles = 0; let removedMetadata = 0;