From 5f201cf7258b981a3898f56545ab4b0007bcf043 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:20:45 +0200 Subject: [PATCH] VYBECODEDDDDDDDDD --- .env.example | 23 +++ .gitignore | 3 + HOSTING.md | 247 ++++++++++++++++++++++++++ README.md | 318 ++++++++++++++++++++++++++++++++- bun.lock | 24 +++ deploy/filehost.service | 25 +++ package.json | 16 ++ scripts/smoke-test.sh | 78 ++++++++ src/config.ts | 74 ++++++++ src/http.ts | 21 +++ src/log.ts | 10 ++ src/multipart.ts | 153 ++++++++++++++++ src/public/app.js | 384 ++++++++++++++++++++++++++++++++++++++++ src/public/index.html | 62 +++++++ src/public/style.css | 325 ++++++++++++++++++++++++++++++++++ src/ratelimit.ts | 29 +++ src/routes/delete.ts | 23 +++ src/routes/download.ts | 42 +++++ src/routes/upload.ts | 66 +++++++ src/security.ts | 56 ++++++ src/server.ts | 127 +++++++++++++ src/storage.ts | 142 +++++++++++++++ tsconfig.json | 17 ++ 23 files changed, 2263 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 HOSTING.md create mode 100644 bun.lock create mode 100644 deploy/filehost.service create mode 100644 package.json create mode 100755 scripts/smoke-test.sh create mode 100644 src/config.ts create mode 100644 src/http.ts create mode 100644 src/log.ts create mode 100644 src/multipart.ts create mode 100644 src/public/app.js create mode 100644 src/public/index.html create mode 100644 src/public/style.css create mode 100644 src/ratelimit.ts create mode 100644 src/routes/delete.ts create mode 100644 src/routes/download.ts create mode 100644 src/routes/upload.ts create mode 100644 src/security.ts create mode 100644 src/server.ts create mode 100644 src/storage.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f71b84a --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Network +HOST=0.0.0.0 +PORT=3000 + +# Storage: persistent filesystem directory where uploaded files and +# metadata live. Files survive restarts; no automatic expiration. +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 + +# Brute-force protection: rate limit per client IP on download and +# deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable. +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_MAX=300 +RATE_LIMIT_WINDOW_MS=60000 + +# Set to 1 ONLY when running behind a reverse proxy that sets or +# overwrites X-Forwarded-For (e.g. nginx: proxy_set_header +# X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when +# clients can reach the server directly - they could spoof the header. +TRUST_PROXY=0 diff --git a/.gitignore b/.gitignore index d1a1697..e2db7b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# App +storage/ + # ---> Node # Logs logs diff --git a/HOSTING.md b/HOSTING.md new file mode 100644 index 0000000..26c5582 --- /dev/null +++ b/HOSTING.md @@ -0,0 +1,247 @@ +# Hosting Guide + +Step-by-step guide to run **File Drop** on a VPS behind a reverse proxy with +TLS. The app itself does not terminate TLS — nginx or Caddy does. + +Assumes a fresh Ubuntu/Debian VPS with a domain pointing at it. Adjust paths and +commands for your distro. + +--- + +## 1. Prerequisites + +- A VPS (any distro Bun supports). +- A domain (or subdomain) you control, e.g. `files.example.com`. +- DNS A record: `files.example.com` → your server's public IP. + +## 2. Install Bun + +```bash +curl -fsSL https://bun.sh/install | bash +export PATH="$HOME/.bun/bin:$PATH" # add to ~/.bashrc +bun --version +``` + +## 3. Create a dedicated user and directory + +Running as a non-root user is strongly recommended. + +```bash +sudo useradd --system --shell /usr/sbin/nologin --create-home filehost +sudo mkdir -p /opt/filehost +sudo chown filehost:filehost /opt/filehost +``` + +## 4. Put the code in place + +```bash +# from your local machine (the project root): +rsync -avz --exclude node_modules --exclude storage --exclude .env \ + ./ filehost@your-server:/opt/filehost/ +``` + +On the server: + +```bash +cd /opt/filehost +sudo -u filehost bun install # dev deps (typecheck only), no runtime deps +sudo -u filehost mkdir -p storage +sudo chown -R filehost:filehost /opt/filehost +``` + +## 5. Configure + +```bash +sudo -u filehost cp .env.example /opt/filehost/.env +sudo -u filehost nano /opt/filehost/.env +``` + +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 +RATE_LIMIT_ENABLED=1 +TRUST_PROXY=1 # proxy sets X-Forwarded-For +``` + +Binding `HOST=127.0.0.1` (instead of `0.0.0.0`) keeps the app private on the +loopback interface so only the reverse proxy can reach it. + +## 6. Install the systemd service + +```bash +sudo cp /opt/filehost/deploy/filehost.service /etc/systemd/system/filehost.service +sudo systemctl daemon-reload +sudo systemctl enable --now filehost +sudo systemctl status filehost # expect "active (running)" +journalctl -u filehost -f # follow logs +``` + +The unit runs as `filehost`, reads `/opt/filehost/.env`, and is hardened with +`ProtectSystem=full`, `ProtectHome=true`, `NoNewPrivileges=true`, and read-write +access only to the storage directory. + +## 7. Firewall + +Open only the ports the proxy needs: + +```bash +sudo ufw allow 80,443/tcp +sudo ufw allow OpenSSH +sudo ufw enable +``` + +Do not open port 3000 to the public (it's bound to loopback). + +## 8. Reverse proxy + +### nginx (with TLS via certbot) + +```bash +sudo apt install nginx certbot python3-certbot-nginx +``` + +`/etc/nginx/sites-available/files.example.com`: + +```nginx +server { + listen 80; + server_name files.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + client_max_body_size 6g; # >= MAX_FILE_SIZE + proxy_request_buffering off; # stream uploads (critical for large files) + proxy_http_version 1.1; + + 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_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/files.example.com /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d files.example.com # obtains + wires TLS, auto-renews +``` + +> `proxy_request_buffering off` matters: with it on, nginx buffers the whole +> upload to a temp file before forwarding, defeating streaming and doubling disk +> usage for large files. + +### Caddy (auto TLS, minimal config) + +```bash +sudo apt install caddy +``` + +`/etc/caddy/Caddyfile`: + +``` +files.example.com { + reverse_proxy 127.0.0.1:3000 + + request_body { + max_size 6GB + } +} +``` + +```bash +sudo systemctl reload caddy +``` + +Caddy obtains and renews TLS automatically. + +### Cloudflare + +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. +- Enable `TRUST_PROXY=1`; the app reads `CF-Connecting-IP` as the client IP. + +## 9. Verification + +```bash +curl https://files.example.com/api/health +./scripts/smoke-test.sh # BASE_URL=https://files.example.com +``` + +Then in a browser: + +1. Upload a file. +2. Copy the private link; open it in a new private window → downloads. +3. Open the link with a modified character → 404. +4. Restart the service, confirm the link still works: + `sudo systemctl restart filehost`. + +## 10. Backups + +Stop-free snapshot of the storage directory (files + metadata): + +```bash +tar -czf /var/backups/filehost-$(date +%F).tar.gz -C /opt/filehost/storage . +``` + +Nightly cron (`crontab -e`): + +```cron +0 2 * * * tar -czf /var/backups/filehost-$(date +\%F).tar.gz -C /opt/filehost/storage . +``` + +Restore: + +```bash +sudo systemctl stop filehost +sudo rm -rf /opt/filehost/storage +sudo tar -xzf /var/backups/filehost-.tar.gz -C /opt/filehost/storage +sudo chown -R filehost:filehost /opt/filehost/storage +sudo systemctl start filehost +``` + +The startup sweep repairs any half-written records automatically. + +## 11. Updating + +```bash +# pull/push the new code to /opt/filehost, then: +sudo -u filehost bun install +sudo systemctl restart filehost +``` + +Files in `storage/` are untouched by updates and keep working. + +## 12. Troubleshooting + +| Symptom | Likely cause / fix | +| --- | --- | +| `413 Request Entity Too Large` | `MAX_FILE_SIZE` bigger than the proxy's `client_max_body_size` / `request_body.max_size`. Raise the proxy limit. | +| `502 Bad Gateway` on large uploads | Proxy buffering on. Set `proxy_request_buffering off` (nginx). | +| Slow uploads / high memory | Proxy buffering uploads; disable it so the app streams to disk. | +| Download link works locally but not through the proxy | Wrong `Host`/`proxy_pass`; check the proxy config and that `HOST` in `.env` is `127.0.0.1`. | +| Rate-limited (`429`) through a proxy | Real IP not seen; enable `TRUST_PROXY=1`. | +| Disk fills up | Uploads are permanent by design. Monitor disk; see Backups and the delete API. | +| Keys appear in logs | They shouldn't. Only ids/sizes/names are logged; if you see one, you added logging. | +| After restart files 404 | Metadata or file missing/renamed; check `storage/metadata` and the startup sweep log line. | + +## Security recap + +- The app binds to loopback; only the proxy is exposed. +- Download/deletion require the unguessable key; everything else is `404`. +- 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 + (`DELETE /api/files/:id` with `X-Delete-Key`). \ No newline at end of file diff --git a/README.md b/README.md index 3cf6302..ce5b908 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,317 @@ -# downloadwf +# File Drop -download.wf \ No newline at end of file +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. + +Everything lives on disk — the filesystem is the source of truth. There is no +database, no object storage, no queue, and no automatic cleanup. + +## Features + +- Drag & drop / file-picker uploads with progress, straight from the browser. +- Permanent 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). +- 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 + +```bash +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: + +```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` | `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/`. | +| `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 permanently and returns a URL like + `https://files.example.com/d/8fJ...random-secret...`. +4. Copy the URL. Anyone possessing it can download the file; nobody without the + secret can. +5. The file stays available indefinitely. It is removed only by explicit + deletion. + +## Filesystem layout + +``` +storage/ +├── files/ +│ ├── # the raw uploaded bytes +│ └── +└── metadata/ + ├── .json # metadata for each file + └── .json +``` + +Example metadata record (`storage/metadata/.json`): + +```json +{ + "id": "YOWJyhDXxhe3-L8MpEFBbA", + "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. + +Uploads are first streamed to a `.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 (this is what the web UI uses). + +```bash +curl -F "file=@example.zip" http://localhost:3000/api/upload +``` + +```json +{ + "id": "YOWJyhDXxhe3-L8MpEFBbA", + "key": "YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", + "url": "/d/YOWJyhDXxhe3-L8MpEFBbAN_e93AqTrEMYutkmStScq5VfbQlrqUt9yHkqmeRp7OQ", + "originalName": "example.zip", + "size": 123456 +} +``` + +### `GET /d/:key` (also `HEAD`) + +Streams the file. Requires the full secret key; any wrong or non-existent key +returns a generic `404`. + +```bash +curl -O http://localhost:3000/d/YOWJyh...7OQ +``` + +### `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: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA +``` + +### `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 128-bit id + 256-bit secret via + `crypto.randomBytes`. Keys are encoded base64url; brute forcing them is + computationally infeasible. 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`. +- **Path safety.** Filesystem paths are built only from server-generated, + regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata + only and is sanitized (basename, control/null bytes stripped) before being + stored or emitted in `Content-Disposition`. 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) with `X-Content-Type-Options: nosniff`, so a spoofed + `text/html` or `image/svg+xml` cannot execute scripts. +- **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 6g; # >= 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 /d/: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. \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..811b1a2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "downloadwf", + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.5.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/deploy/filehost.service b/deploy/filehost.service new file mode 100644 index 0000000..62020d2 --- /dev/null +++ b/deploy/filehost.service @@ -0,0 +1,25 @@ +[Unit] +Description=Filehost private file hosting service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=filehost +Group=filehost +WorkingDirectory=/opt/filehost +EnvironmentFile=/opt/filehost/.env +ExecStart=/usr/local/bin/bun run src/server.ts +Restart=on-failure +RestartSec=3 +TimeoutStopSec=30 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/opt/filehost/storage + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7022b25 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "downloadwf", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Minimal self-hosted private file hosting service built with Bun and TypeScript.", + "scripts": { + "start": "bun run src/server.ts", + "dev": "bun run --watch src/server.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.5.0" + } +} diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..a305cc3 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Smoke test for the file host. +# +# Usage: +# ./scripts/smoke-test.sh # assumes http://127.0.0.1:3000 +# BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh +# +# Requires a running instance and `bun` on PATH. +set -euo pipefail + +BASE="${BASE_URL:-http://127.0.0.1:3000}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +pass=0 +fail=0 +check() { # check + local name="$1" + shift + if "$@"; then + pass=$((pass + 1)) + echo "ok: $name" >&2 + else + fail=$((fail + 1)) + echo "FAIL: $name" >&2 + fi +} + +get() { # get + bun -e 'const d = JSON.parse(await Bun.file(process.argv[3]).text()); console.log(d[process.argv[2]])' _ "$2" "$1" +} + +printf 'hello world\n' > "$TMP/plain.txt" +dd if=/dev/urandom of="$TMP/random.bin" bs=1M count=16 2>/dev/null + +check "health endpoint" curl -sS -f "$BASE/api/health" >/dev/null + +check "upload plain file" curl -sS -f -F "file=@$TMP/plain.txt" "$BASE/api/upload" > "$TMP/up.json" +KEY="$(get "$TMP/up.json" key)" +ID="$(get "$TMP/up.json" id)" +URL="$(get "$TMP/up.json" url)" +[ -n "$KEY" ] && check "upload returns a key" true + +check "download with key" curl -sS -f -o "$TMP/down.txt" "$BASE$URL" +check "downloaded content matches" cmp "$TMP/plain.txt" "$TMP/down.txt" + +check "invalid key returns 404" test \ + "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")" = "404" + +check "id alone cannot download" test \ + "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/d/$ID")" = "404" + +check "storage directory not served" test \ + "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/storage/")" = "404" + +check "unicode filename upload" \ + curl -sS -f -F "file=@$TMP/plain.txt;filename=caf%C3%A9-%E6%96%87%E4%BB%B6.txt" "$BASE/api/upload" > "$TMP/upU.json" +KU="$(get "$TMP/upU.json" key)" +check "unicode filename download" curl -sS -f -o /dev/null "$BASE/d/$KU" + +check "path traversal filename sanitized" \ + curl -sS -f -F "file=@$TMP/plain.txt;filename=../../../../etc/passwd" "$BASE/api/upload" > "$TMP/upT.json" + +check "upload 16MB binary" curl -sS -f -F "file=@$TMP/random.bin" "$BASE/api/upload" > "$TMP/upL.json" +KL="$(get "$TMP/upL.json" key)" +check "download 16MB binary" curl -sS -f -o "$TMP/random.out" "$BASE/d/$KL" +check "16MB round-trip identical" cmp "$TMP/random.bin" "$TMP/random.out" + +check "delete requires key" test \ + "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$BASE/api/files/$ID")" = "404" +check "delete with key succeeds" \ + curl -sS -f -X DELETE -H "X-Delete-Key: $KEY" "$BASE/api/files/$ID" >/dev/null +check "file gone after delete" test \ + "$(curl -sS -o /dev/null -w '%{http_code}' "$BASE$URL")" = "404" + +echo +echo "passed: $pass, failed: $fail" +[ "$fail" -eq 0 ] || exit 1 \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..578a421 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,74 @@ +import { resolve } from "node:path"; + +export interface Config { + host: string; + port: number; + storageDir: string; + maxFileSize: number; + rateLimitEnabled: boolean; + rateLimitMax: number; + rateLimitWindowMs: number; + trustProxy: boolean; +} + +const SIZE_UNITS: Record = { + B: 1, + K: 1 << 10, + KB: 1 << 10, + KIB: 1 << 10, + M: 1 << 20, + MB: 1 << 20, + MIB: 1 << 20, + G: 1 << 30, + GB: 1 << 30, + GIB: 1 << 30, + T: 1 << 40, + TB: 1 << 40, + TIB: 1 << 40, +}; + +export function parseSize(value: string): number { + const match = /^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$/.exec(value); + if (!match) throw new Error(`Invalid size value: "${value}"`); + const num = parseFloat(match[1]!); + const unit = (match[2] || "B").toUpperCase(); + const factor = SIZE_UNITS[unit]; + if (factor === undefined) throw new Error(`Invalid size unit in: "${value}"`); + return Math.floor(num * factor); +} + +function boolFromEnv(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + return !["0", "false", "no", "off"].includes(value.trim().toLowerCase()); +} + +export function loadConfig(env: Record = process.env): Config { + const port = Number(env.PORT ?? "3000"); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid PORT: ${env.PORT}`); + } + const host = env.HOST ?? "0.0.0.0"; + const storageDir = resolve(env.STORAGE_DIR ?? "./storage"); + const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "5GB"); + if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB"); + + 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}`); + } + const rateLimitWindowMs = Number(env.RATE_LIMIT_WINDOW_MS ?? "60000"); + if (!Number.isInteger(rateLimitWindowMs) || rateLimitWindowMs < 1000) { + throw new Error(`Invalid RATE_LIMIT_WINDOW_MS: ${env.RATE_LIMIT_WINDOW_MS}`); + } + + return { + host, + port, + storageDir, + maxFileSize, + rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true), + rateLimitMax, + rateLimitWindowMs, + trustProxy: boolFromEnv(env.TRUST_PROXY, false), + }; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..419c16a --- /dev/null +++ b/src/http.ts @@ -0,0 +1,21 @@ +export function securityHeaders(): Record { + return { + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }; +} + +export function json(data: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8", ...securityHeaders(), ...headers }, + }); +} + +export function jsonError(status: number, message: string): Response { + return json({ error: message }, status); +} + +export function notFound(): Response { + return jsonError(404, "not found"); +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..d7befb0 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,10 @@ +export type LogLevel = "info" | "warn" | "error"; + +export function log(level: LogLevel, message: string): void { + const line = `[${new Date().toISOString()}] [${level}] ${message}`; + if (level === "error") { + console.error(line); + } else { + console.log(line); + } +} diff --git a/src/multipart.ts b/src/multipart.ts new file mode 100644 index 0000000..e030a63 --- /dev/null +++ b/src/multipart.ts @@ -0,0 +1,153 @@ +const CR = 0x0d; +const LF = 0x0a; +const MAX_HEADER_LINE_BYTES = 64 * 1024; + +export interface MultipartFilePart { + filename: string; + contentType: string; + chunks: AsyncIterable; +} + +export class MultipartError extends Error { + constructor(message: string) { + super(message); + this.name = "MultipartError"; + } +} + +export function getBoundary(contentType: string): string | null { + const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType); + return match?.[1] ?? match?.[2] ?? null; +} + +async function* streamChunks(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + if (value && value.byteLength > 0) yield value; + } + } finally { + reader.releaseLock(); + } +} + +export async function* multipartParts( + body: ReadableStream, + boundary: string, +): AsyncGenerator { + const DELIMITER = Buffer.from(`--${boundary}`); + const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]); + const FINAL_MARKER = `--${boundary}--`; + + let buffer = Buffer.alloc(0); + let eof = false; + const source = streamChunks(body); + + const readMore = async (): Promise => { + if (eof) return; + const { done, value } = await source.next(); + if (done) { + eof = true; + return; + } + if (value) buffer = Buffer.concat([buffer, Buffer.from(value)]); + }; + + const readLine = async (): Promise => { + for (;;) { + const lf = buffer.indexOf(LF); + if (lf !== -1) { + if (lf > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long"); + let line = buffer.subarray(0, lf); + buffer = buffer.subarray(lf + 1); + if (line.byteLength > 0 && line[line.byteLength - 1] === CR) { + line = line.subarray(0, line.byteLength - 1); + } + return line; + } + if (buffer.byteLength > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long"); + if (eof) return buffer.byteLength > 0 ? buffer : null; + await readMore(); + } + }; + + const contentChunks = async function* (): AsyncGenerator { + for (;;) { + const idx = buffer.indexOf(CRLF_DELIMITER); + if (idx !== -1) { + if (idx > 0) yield buffer.subarray(0, idx); + buffer = buffer.subarray(idx + 2); + return; + } + const keep = Math.min(CRLF_DELIMITER.byteLength - 1, buffer.byteLength); + const emit = buffer.byteLength - keep; + if (emit > 0) { + yield buffer.subarray(0, emit); + buffer = buffer.subarray(emit); + } + if (eof) { + if (buffer.byteLength > 0) yield buffer; + buffer = Buffer.alloc(0); + return; + } + await readMore(); + } + }; + + for (;;) { + const line = await readLine(); + if (line === null) throw new MultipartError("no multipart boundary found"); + if (line.equals(DELIMITER)) break; + } + + for (;;) { + const headers = new Map(); + for (;;) { + const line = await readLine(); + if (line === null) return; + if (line.byteLength === 0) break; + const text = line.toString("utf8"); + const colon = text.indexOf(":"); + if (colon > 0) { + headers.set(text.slice(0, colon).trim().toLowerCase(), text.slice(colon + 1).trim()); + } + } + + const disposition = headers.get("content-disposition") ?? ""; + let filename: string | undefined; + const star = /filename\*\s*=\s*UTF-8''([^\s;]+)/i.exec(disposition); + if (star?.[1]) { + try { + filename = decodeURIComponent(star[1]); + } catch { + filename = star[1]; + } + } else { + const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition); + filename = quoted?.[1]; + } + + if (!filename) { + for await (const _ of contentChunks()) { + void _; + } + } else { + yield { + filename, + contentType: headers.get("content-type") ?? "application/octet-stream", + chunks: contentChunks(), + }; + } + + for (;;) { + const marker = await readLine(); + if (marker === null) return; + const markerText = marker.toString("utf8"); + if (markerText === FINAL_MARKER) return; + if (markerText !== `--${boundary}`) throw new MultipartError("malformed multipart boundary"); + break; + } + } +} diff --git a/src/public/app.js b/src/public/app.js new file mode 100644 index 0000000..98c8766 --- /dev/null +++ b/src/public/app.js @@ -0,0 +1,384 @@ +"use strict"; + +const $ = (id) => document.getElementById(id); + +const STORAGE_KEY = "filehost.uploads"; + +const dropzone = $("dropzone"); +const fileInput = $("fileInput"); +const progress = $("progress"); +const progressBar = $("progressBar"); +const progressPct = $("progressPct"); +const progressName = $("progressName"); +const result = $("result"); +const resultUrl = $("resultUrl"); +const resultName = $("resultName"); +const resultSize = $("resultSize"); +const copyBtn = $("copyBtn"); +const deleteBtn = $("deleteBtn"); +const againBtn = $("againBtn"); +const notice = $("notice"); +const fileListWrap = $("fileListWrap"); +const fileList = $("fileList"); +const clearListBtn = $("clearListBtn"); + +let current = null; +let uploading = false; + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes; + let i = -1; + do { + value /= 1024; + i += 1; + } while (value >= 1024 && i < units.length - 1); + return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`; +} + +function showNotice(message, kind) { + notice.textContent = message; + notice.className = kind; +} + +function clearNotice() { + notice.className = "hidden"; + notice.textContent = ""; +} + +function setProgress(loaded, total) { + const pct = total > 0 ? Math.round((loaded / total) * 100) : 0; + progressBar.style.width = `${pct}%`; + progressPct.textContent = `${pct}%`; +} + +function reset() { + uploading = false; + current = null; + result.classList.add("hidden"); + progress.classList.add("hidden"); + progressBar.style.width = "0%"; + progressPct.textContent = "0%"; + fileInput.value = ""; + clearNotice(); +} + +// ---- client-side upload history (localStorage) ---- + +function loadUploads() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveUploads(list) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } catch { + /* storage unavailable (e.g. private mode) — ignore */ + } +} + +function addUpload(record) { + const list = loadUploads(); + const idx = list.findIndex((item) => item.id === record.id); + if (idx !== -1) list[idx] = record; + else list.unshift(record); + saveUploads(list); + renderList(); +} + +function removeUpload(id) { + saveUploads(loadUploads().filter((item) => item.id !== id)); + renderList(); +} + +function downloadUrl(item) { + return new URL(item.url, window.location.origin).href; +} + +async function copyText(text) { + const fallback = () => { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + ta.remove(); + return ok; + }; + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return fallback(); + } + } + return fallback(); +} + +function setCopyFeedback(btn, label) { + const original = btn.textContent; + btn.textContent = label; + setTimeout(() => { + btn.textContent = original; + }, 1500); +} + +function renderList() { + const list = loadUploads(); + if (list.length === 0) { + fileListWrap.classList.add("hidden"); + fileList.innerHTML = ""; + return; + } + fileListWrap.classList.remove("hidden"); + fileList.innerHTML = ""; + + for (const item of list) { + const li = document.createElement("li"); + li.className = "file-item"; + + const meta = document.createElement("div"); + meta.className = "file-item-meta"; + + const name = document.createElement("span"); + name.className = "file-item-name"; + name.textContent = item.originalName; + name.title = item.originalName; + + const sub = document.createElement("span"); + sub.className = "file-item-sub"; + let subText = formatBytes(item.size || 0); + if (item.createdAt) { + try { + subText += ` \u00b7 ${new Date(item.createdAt).toLocaleString()}`; + } catch { + /* ignore date formatting */ + } + } + sub.textContent = subText; + + meta.appendChild(name); + meta.appendChild(sub); + + const actions = document.createElement("div"); + actions.className = "file-item-actions"; + + const dl = document.createElement("a"); + dl.className = "btn-link"; + dl.href = downloadUrl(item); + dl.textContent = "Download"; + dl.setAttribute("download", ""); + dl.title = "Open download link"; + + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = "Copy"; + copy.addEventListener("click", async () => { + const ok = await copyText(downloadUrl(item)); + setCopyFeedback(copy, ok ? "Copied" : "Copy failed"); + }); + + const del = document.createElement("button"); + del.type = "button"; + del.className = "danger"; + del.textContent = "Delete"; + del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id))); + + actions.appendChild(dl); + actions.appendChild(copy); + actions.appendChild(del); + + li.appendChild(meta); + li.appendChild(actions); + fileList.appendChild(li); + } +} + +async function deleteFile(item, onSuccess) { + const ok = window.confirm(`Delete "${item.originalName}" permanently? This cannot be undone.`); + if (!ok) return; + try { + const res = await fetch(`/api/files/${item.id}`, { + method: "DELETE", + headers: { "X-Delete-Key": item.key }, + }); + if (res.ok) { + onSuccess(); + showNotice(`"${item.originalName}" was deleted.`, "info"); + } else { + let message = "Deletion failed."; + try { + const data = await res.json(); + if (data && data.error) message = data.error; + } catch { + /* keep fallback message */ + } + showNotice(message, "error"); + } + } catch { + showNotice("Deletion failed: network error.", "error"); + } +} + +// ---- upload flow ---- + +function showResult(data) { + progress.classList.add("hidden"); + result.classList.remove("hidden"); + resultName.textContent = data.originalName; + resultSize.textContent = formatBytes(data.size); + resultUrl.value = downloadUrl(data); + current = data; + addUpload({ + id: data.id, + key: data.key, + url: data.url, + originalName: data.originalName, + size: data.size, + createdAt: new Date().toISOString(), + }); +} + +function startUpload(file) { + if (uploading) return; + clearNotice(); + progress.classList.remove("hidden"); + progressName.textContent = file.name; + setProgress(0, file.size); + uploading = true; + + const xhr = new XMLHttpRequest(); + const form = new FormData(); + form.append("file", file); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) setProgress(e.loaded, e.total); + }); + + xhr.addEventListener("load", () => { + uploading = false; + if (xhr.status >= 200 && xhr.status < 300) { + try { + showResult(JSON.parse(xhr.responseText)); + } catch { + progress.classList.add("hidden"); + showNotice("Invalid server response.", "error"); + } + return; + } + progress.classList.add("hidden"); + let message = `Upload failed (HTTP ${xhr.status})`; + try { + const data = JSON.parse(xhr.responseText); + if (data && data.error) message = data.error; + } catch { + /* keep fallback message */ + } + showNotice(message, "error"); + }); + + xhr.addEventListener("error", () => { + uploading = false; + progress.classList.add("hidden"); + showNotice("Network error during upload.", "error"); + }); + + xhr.addEventListener("abort", () => { + uploading = false; + progress.classList.add("hidden"); + }); + + xhr.open("POST", "/api/upload"); + xhr.send(form); +} + +function handleFile(file) { + if (!file) return; + if (file.size === 0) { + showNotice("Empty files are not supported.", "error"); + return; + } + startUpload(file); +} + +dropzone.addEventListener("click", () => { + if (!uploading) fileInput.click(); +}); + +dropzone.addEventListener("keydown", (e) => { + if ((e.key === "Enter" || e.key === " ") && !uploading) { + e.preventDefault(); + fileInput.click(); + } +}); + +fileInput.addEventListener("change", () => { + const file = fileInput.files ? fileInput.files[0] : null; + handleFile(file); +}); + +let dragDepth = 0; +document.addEventListener("dragenter", (e) => { + e.preventDefault(); + if (!uploading) { + dragDepth += 1; + dropzone.classList.add("drag"); + } +}); +document.addEventListener("dragover", (e) => e.preventDefault()); +document.addEventListener("dragleave", (e) => { + e.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) dropzone.classList.remove("drag"); +}); +document.addEventListener("drop", (e) => { + e.preventDefault(); + dragDepth = 0; + dropzone.classList.remove("drag"); + if (uploading) return; + const file = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null; + if (file) handleFile(file); +}); + +copyBtn.addEventListener("click", async () => { + const ok = await copyText(resultUrl.value); + setCopyFeedback(copyBtn, ok ? "Copied" : "Copy failed"); +}); + +deleteBtn.addEventListener("click", () => { + if (!current) return; + deleteFile(current, () => { + removeUpload(current.id); + reset(); + }); +}); + +againBtn.addEventListener("click", reset); + +clearListBtn.addEventListener("click", () => { + if (loadUploads().length === 0) return; + const ok = window.confirm( + "Clear this list? This only removes the entries from THIS browser. The uploaded files remain on the server.", + ); + if (!ok) return; + saveUploads([]); + renderList(); + showNotice("List cleared. Files on the server were not touched.", "info"); +}); + +renderList(); \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 0000000..1e4065e --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,62 @@ + + + + + + + + File drop + + + +
+

File drop

+

Drop a file to get a private download link. Files are kept forever.

+ +
+ +

Drag & drop a file here

+

or click to choose

+
+ + + + + + + + +
+ + + diff --git a/src/public/style.css b/src/public/style.css new file mode 100644 index 0000000..739bbab --- /dev/null +++ b/src/public/style.css @@ -0,0 +1,325 @@ +:root { + --bg: #0f1115; + --card: #171a21; + --border: #2a2f3a; + --text: #e6e8ee; + --muted: #9aa3b2; + --accent: #4f8cff; + --accent-hover: #3a74e0; + --danger: #e05252; + --ok: #46b076; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.card { + width: 100%; + max-width: 560px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; + padding: 28px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); +} + +h1 { + margin: 0 0 4px; + font-size: 24px; +} + +.tagline { + margin: 0 0 20px; + color: var(--muted); + font-size: 14px; +} + +.dropzone { + border: 2px dashed var(--border); + border-radius: 12px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + outline: none; +} + +.dropzone:hover, +.dropzone.drag, +.dropzone:focus-visible { + border-color: var(--accent); + background: rgba(79, 140, 255, 0.06); +} + +.dz-main { + margin: 0; + font-weight: 600; +} + +.dz-sub { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.progress { + margin-top: 20px; +} + +.progress-label { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: var(--muted); + margin-bottom: 8px; +} + +.progress-label span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-track { + height: 8px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: 99px; + transition: width 0.15s; +} + +.result { + margin-top: 20px; +} + +.result-meta { + font-size: 14px; + margin-bottom: 10px; + color: var(--muted); + word-break: break-all; +} + +.result-meta strong { + color: var(--text); + font-weight: 600; +} + +.url-row { + display: flex; + gap: 8px; +} + +.url-row input { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 10px 12px; + font-size: 13px; +} + +.result-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +button { + font: inherit; + cursor: pointer; + border: none; + border-radius: 8px; + padding: 9px 14px; + font-size: 13px; + background: var(--accent); + color: #fff; + transition: background 0.15s; +} + +button:hover { + background: var(--accent-hover); +} + +button.danger { + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); +} + +button.danger:hover { + background: rgba(224, 82, 82, 0.1); +} + +button.ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); +} + +button.ghost:hover { + color: var(--text); + border-color: var(--muted); +} + +#notice { + margin-top: 16px; + font-size: 13px; + border-radius: 8px; + padding: 10px 12px; +} + +#notice.error { + color: var(--danger); + background: rgba(224, 82, 82, 0.08); + border: 1px solid rgba(224, 82, 82, 0.3); +} + +#notice.info { + color: var(--ok); + background: rgba(70, 176, 118, 0.08); + border: 1px solid rgba(70, 176, 118, 0.3); +} + +.hidden { + display: none !important; +} + +/* Client-side upload list */ +.files { + margin-top: 24px; + border-top: 1px solid var(--border); + padding-top: 18px; +} + +.files-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.files-head h2 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.files-note { + margin: 6px 0 12px; + font-size: 12px; + color: var(--muted); +} + +#fileList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; +} + +.file-item-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.file-item-name { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-item-sub { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.file-item-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.btn-link { + font: inherit; + font-size: 13px; + color: #fff; + background: var(--accent); + border-radius: 8px; + padding: 8px 12px; + text-decoration: none; + transition: background 0.15s; +} + +.btn-link:hover { + background: var(--accent-hover); +} + +.file-item-actions button { + font-size: 12px; + padding: 8px 10px; +} + +@media (max-width: 480px) { + .card { + padding: 20px; + } + + .dropzone { + padding: 28px 16px; + } + + .url-row { + flex-direction: column; + } + + .file-item { + flex-direction: column; + align-items: stretch; + } + + .file-item-actions { + justify-content: flex-end; + } +} diff --git a/src/ratelimit.ts b/src/ratelimit.ts new file mode 100644 index 0000000..e0d66b9 --- /dev/null +++ b/src/ratelimit.ts @@ -0,0 +1,29 @@ +interface Bucket { + count: number; + resetAt: number; +} + +export class RateLimiter { + private buckets = new Map(); + + constructor( + private readonly max: number, + private readonly windowMs: number, + ) {} + + check(ip: string): boolean { + const now = Date.now(); + if (this.buckets.size > 10_000) this.prune(now); + const existing = this.buckets.get(ip); + const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + this.windowMs }; + if (!existing || bucket !== existing) this.buckets.set(ip, bucket); + bucket.count++; + return bucket.count <= this.max; + } + + private prune(now: number): void { + for (const [ip, bucket] of this.buckets) { + if (bucket.resetAt <= now) this.buckets.delete(ip); + } + } +} diff --git a/src/routes/delete.ts b/src/routes/delete.ts new file mode 100644 index 0000000..d1aec25 --- /dev/null +++ b/src/routes/delete.ts @@ -0,0 +1,23 @@ +import { json, notFound } from "../http"; +import { log } from "../log"; +import { ID_PATTERN, KEY_PATTERN, secretMatches } from "../security"; +import type { Storage } from "../storage"; + +export async function handleDelete( + req: Request, + storage: Storage, + rawId: string, +): Promise { + if (!ID_PATTERN.test(rawId)) return notFound(); + + const meta = await storage.readMetadata(rawId); + if (!meta) return notFound(); + + const key = req.headers.get("x-delete-key"); + if (!key || !KEY_PATTERN.test(key)) return notFound(); + if (!secretMatches(meta.downloadKeyHash, key)) return notFound(); + + await storage.delete(rawId); + log("info", `deleted id=${rawId} name=${JSON.stringify(meta.originalName)}`); + return json({ ok: true }); +} diff --git a/src/routes/download.ts b/src/routes/download.ts new file mode 100644 index 0000000..16567db --- /dev/null +++ b/src/routes/download.ts @@ -0,0 +1,42 @@ +import { lstat } from "node:fs/promises"; +import { notFound } from "../http"; +import { log } from "../log"; +import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; +import type { Storage } from "../storage"; + +export async function handleDownload( + req: Request, + storage: Storage, + rawKey: string, +): Promise { + if (!KEY_PATTERN.test(rawKey)) return notFound(); + const id = fileIdFromKey(rawKey); + if (!id) return notFound(); + + const meta = await storage.readMetadata(id); + if (!meta) return notFound(); + if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); + + const filePath = storage.filePath(id); + let fileStat; + try { + fileStat = await lstat(filePath); + } catch { + return notFound(); + } + if (!fileStat.isFile()) return notFound(); + + const headers = new Headers({ + "content-type": meta.mimeType || "application/octet-stream", + "content-length": String(fileStat.size), + "content-disposition": contentDisposition(meta.originalName), + "cache-control": "private, no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }); + + if (req.method === "HEAD") return new Response(null, { status: 200, headers }); + + log("info", `download id=${id} size=${fileStat.size}`); + return new Response(Bun.file(filePath).stream(), { status: 200, headers }); +} diff --git a/src/routes/upload.ts b/src/routes/upload.ts new file mode 100644 index 0000000..be14dea --- /dev/null +++ b/src/routes/upload.ts @@ -0,0 +1,66 @@ +import { json, jsonError } from "../http"; +import { log } from "../log"; +import { getBoundary, MultipartError, multipartParts } from "../multipart"; +import { hashSecret, newDownloadKey, newFileId, sanitizeMime, sanitizeOriginalName } from "../security"; +import { UploadTooLargeError, type Storage } from "../storage"; + +export async function handleUpload( + req: Request, + storage: Storage, + maxFileSize: number, +): Promise { + const contentType = req.headers.get("content-type"); + const boundary = contentType ? getBoundary(contentType) : null; + if (!boundary || !req.body) return jsonError(400, "expected a multipart/form-data upload"); + + const id = newFileId(); + const key = id + newDownloadKey(); + + try { + let filename = ""; + let mime = "application/octet-stream"; + let size = 0; + let hasFile = false; + + for await (const part of multipartParts(req.body, boundary)) { + if (!hasFile) { + hasFile = true; + filename = part.filename; + mime = sanitizeMime(part.contentType); + size = await storage.saveFile(id, part.chunks, maxFileSize); + break; + } + for await (const _ of part.chunks) { + void _; + } + } + + if (!hasFile) return jsonError(400, "no file part in the upload"); + + const originalName = sanitizeOriginalName(filename); + await storage.writeMetadata({ + id, + originalName, + size, + mimeType: mime, + downloadKeyHash: hashSecret(key), + createdAt: new Date().toISOString(), + }); + + log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); + return json({ id, key, url: `/d/${key}`, originalName, size }); + } catch (err) { + await storage.delete(id); + await storage.deletePartial(id); + if (err instanceof UploadTooLargeError) { + log("warn", `upload rejected id=${id} exceeding max size ${maxFileSize}`); + return jsonError(413, "file too large"); + } + if (err instanceof MultipartError) { + log("warn", `upload rejected id=${id}: ${err.message}`); + return jsonError(400, "invalid multipart body"); + } + log("error", `upload failed id=${id}: ${err instanceof Error ? err.message : String(err)}`); + return jsonError(500, "upload failed"); + } +} diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..c435b5a --- /dev/null +++ b/src/security.ts @@ -0,0 +1,56 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export const ID_BYTES = 16; +export const KEY_BYTES = 32; +export const ID_LENGTH = 22; +export const KEY_LENGTH = 65; + +export const ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; +export const KEY_PATTERN = /^[A-Za-z0-9_-]{65}$/; + +export function randomToken(byteCount: number): string { + return randomBytes(byteCount).toString("base64url"); +} + +export function newFileId(): string { + return randomToken(ID_BYTES); +} + +export function newDownloadKey(): string { + return randomToken(KEY_BYTES); +} + +export function hashSecret(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +export function secretMatches(storedHash: string, candidate: string): boolean { + const stored = Buffer.from(storedHash, "hex"); + const computed = Buffer.from(hashSecret(candidate), "hex"); + if (stored.length !== computed.length) return false; + return timingSafeEqual(stored, computed); +} + +export function fileIdFromKey(key: string): string | null { + if (key.length !== KEY_LENGTH) return null; + return key.slice(0, ID_LENGTH); +} + +export function sanitizeOriginalName(name: string): string { + const base = name.replace(/\\/g, "/").split("/").pop() ?? ""; + const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "_").trim(); + return cleaned.length > 0 ? cleaned : "file"; +} + +export function sanitizeMime(value: string): string { + const cleaned = value.replace(/[\r\n"\\]/g, "").trim(); + return cleaned.length > 0 && cleaned.length <= 200 ? cleaned : "application/octet-stream"; +} + +export function contentDisposition(name: string): string { + const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => + "%" + c.charCodeAt(0).toString(16).toUpperCase(), + ); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..ed29567 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,127 @@ +import type { Server } from "bun"; +import { loadConfig } from "./config"; +import { json, jsonError, notFound, securityHeaders } from "./http"; +import { log } from "./log"; +import { RateLimiter } from "./ratelimit"; +import { handleDelete } from "./routes/delete"; +import { handleDownload } from "./routes/download"; +import { handleUpload } from "./routes/upload"; +import { Storage } from "./storage"; + +const cfg = loadConfig(); + +const storage = new Storage(cfg.storageDir); +await storage.init(); +const sweep = await storage.sweep(); +if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) { + log( + "info", + `startup sweep removed ${sweep.removedFiles} orphaned file(s), ${sweep.removedMetadata} orphaned metadata record(s)`, + ); +} + +const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs); +const startedAt = Date.now(); + +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" }, + "/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" }, + "/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" }, +}; + +const staticContent = new Map(); +for (const [route, info] of Object.entries(STATIC_ROUTES)) { + staticContent.set(route, { + body: await Bun.file(new URL(info.file, import.meta.url)).text(), + type: info.type, + }); +} + +const server = Bun.serve({ + hostname: cfg.host, + port: cfg.port, + maxRequestBodySize: Infinity, + fetch(req, srv) { + return route(req, srv).catch((err) => { + log("error", `unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); + return jsonError(500, "internal error"); + }); + }, +}); + +function clientIp(req: Request, srv: Server): string { + if (cfg.trustProxy) { + const xff = req.headers.get("x-forwarded-for"); + if (xff) { + const first = xff.split(",")[0]?.trim(); + if (first) return first; + } + const cf = req.headers.get("cf-connecting-ip"); + if (cf) return cf; + } + return srv.requestIP(req)?.address ?? "unknown"; +} + +function serveStatic(path: string): Response { + const entry = staticContent.get(path); + if (!entry) return notFound(); + return new Response(entry.body, { + headers: { + "content-type": entry.type, + "cache-control": "no-cache", + "content-security-policy": + "default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ...securityHeaders(), + }, + }); +} + +async function route(req: Request, srv: Server): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (method === "GET" && staticContent.has(path)) return serveStatic(path); + + if (path === "/api/health" && method === "GET") { + return json({ status: "ok", uptimeSeconds: Math.round((Date.now() - startedAt) / 1000) }); + } + + if (path === "/api/upload" && method === "POST") { + return handleUpload(req, storage, cfg.maxFileSize); + } + + if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) { + if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { + return jsonError(429, "too many requests"); + } + return handleDownload(req, storage, path.slice(3)); + } + + if (path.startsWith("/api/files/") && method === "DELETE") { + if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { + return jsonError(429, "too many requests"); + } + return handleDelete(req, storage, path.slice("/api/files/".length)); + } + + return notFound(); +} + +log( + "info", + `listening on http://${cfg.host}:${cfg.port} storage=${cfg.storageDir} maxUpload=${cfg.maxFileSize}`, +); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + log("info", `received ${signal}, shutting down`); + server.stop(); + process.exit(0); + }); +} + +process.on("unhandledRejection", (reason) => { + log("error", `unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`); +}); diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..7bbf4e5 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,142 @@ +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ID_PATTERN } from "./security"; + +export interface FileMeta { + id: string; + originalName: string; + size: number; + mimeType: string; + downloadKeyHash: string; + createdAt: string; +} + +export class UploadTooLargeError extends Error { + readonly maxBytes: number; + + constructor(maxBytes: number) { + super("file exceeds the maximum upload size"); + this.name = "UploadTooLargeError"; + this.maxBytes = maxBytes; + } +} + +export class Storage { + readonly filesDir: string; + readonly metadataDir: string; + + constructor(readonly root: string) { + this.filesDir = join(root, "files"); + this.metadataDir = join(root, "metadata"); + } + + async init(): Promise { + await mkdir(this.filesDir, { recursive: true }); + await mkdir(this.metadataDir, { recursive: true }); + } + + filePath(id: string): string { + if (!ID_PATTERN.test(id)) throw new Error("invalid file id"); + return join(this.filesDir, id); + } + + private metaPath(id: string): string { + if (!ID_PATTERN.test(id)) throw new Error("invalid file id"); + return join(this.metadataDir, `${id}.json`); + } + + async saveFile(id: string, chunks: AsyncIterable, maxBytes: number): Promise { + const partPath = join(this.filesDir, `${id}.part`); + const sink = Bun.file(partPath).writer(); + let size = 0; + let sinceFlush = 0; + try { + for await (const chunk of chunks) { + size += chunk.byteLength; + if (size > maxBytes) throw new UploadTooLargeError(maxBytes); + sink.write(chunk); + sinceFlush += chunk.byteLength; + if (sinceFlush >= 4 * 1024 * 1024) { + await sink.flush(); + sinceFlush = 0; + } + } + await sink.end(); + } catch (err) { + const ended = sink.end(); + if (ended instanceof Promise) await ended.catch(() => {}); + throw err; + } + await rename(partPath, this.filePath(id)); + return size; + } + + async writeMetadata(meta: FileMeta): Promise { + const target = this.metaPath(meta.id); + const tmp = `${target}.tmp`; + await writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); + await rename(tmp, target); + } + + async readMetadata(id: string): Promise { + try { + const raw = await readFile(this.metaPath(id), "utf8"); + const meta = JSON.parse(raw) as Partial; + if (typeof meta.id !== "string" || typeof meta.downloadKeyHash !== "string") return null; + return meta as FileMeta; + } catch { + return null; + } + } + + async delete(id: string): Promise { + await rm(this.filePath(id), { force: true }); + await rm(this.metaPath(id), { force: true }); + } + + async deletePartial(id: string): Promise { + await rm(join(this.filesDir, `${id}.part`), { force: true }); + } + + async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> { + let removedFiles = 0; + let removedMetadata = 0; + + for (const entry of await readdir(this.filesDir)) { + const full = join(this.filesDir, entry); + if (entry.endsWith(".part") || !ID_PATTERN.test(entry)) { + await rm(full, { force: true }); + removedFiles++; + continue; + } + try { + await stat(join(this.metadataDir, `${entry}.json`)); + } catch { + await rm(full, { force: true }); + removedFiles++; + } + } + + for (const entry of await readdir(this.metadataDir)) { + if (!entry.endsWith(".json")) { + await rm(join(this.metadataDir, entry), { force: true }); + removedMetadata++; + continue; + } + const id = entry.slice(0, -".json".length); + if (!ID_PATTERN.test(id)) { + await rm(join(this.metadataDir, entry), { force: true }); + removedMetadata++; + continue; + } + try { + await stat(join(this.filesDir, id)); + } catch { + await rm(join(this.metadataDir, entry), { force: true }); + removedMetadata++; + } + } + + return { removedFiles, removedMetadata }; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5cd1eae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"] +}