VYBECODEDDDDDDDDD
This commit is contained in:
@@ -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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# App
|
||||||
|
storage/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
+247
@@ -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-<date>.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`).
|
||||||
@@ -1,3 +1,317 @@
|
|||||||
# downloadwf
|
# File Drop
|
||||||
|
|
||||||
download.wf
|
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/
|
||||||
|
│ ├── <file-id> # the raw uploaded bytes
|
||||||
|
│ └── <file-id>
|
||||||
|
└── metadata/
|
||||||
|
├── <file-id>.json # metadata for each file
|
||||||
|
└── <file-id>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example metadata record (`storage/metadata/<file-id>.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "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 `<file-id>.part` temp file and atomically renamed
|
||||||
|
into place on completion. At startup the server sweeps for orphans (a file
|
||||||
|
without metadata or metadata without a file, plus leftover `.part` files) and
|
||||||
|
removes them. This is consistency repair, not expiration — real files are never
|
||||||
|
touched.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /api/upload`
|
||||||
|
|
||||||
|
Multipart/form-data with a `file` field (this is what the web UI uses).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -F "[email protected]" 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.
|
||||||
@@ -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/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||||
|
|
||||||
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+78
@@ -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 <name> <command...>
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
if "$@"; then
|
||||||
|
pass=$((pass + 1))
|
||||||
|
echo "ok: $name" >&2
|
||||||
|
else
|
||||||
|
fail=$((fail + 1))
|
||||||
|
echo "FAIL: $name" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get() { # get <jsonfile> <key>
|
||||||
|
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
|
||||||
@@ -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<string, number> = {
|
||||||
|
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<string, string | undefined> = 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
export function securityHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy": "no-referrer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): 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");
|
||||||
|
}
|
||||||
+10
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Uint8Array>): AsyncGenerator<Uint8Array> {
|
||||||
|
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<Uint8Array>,
|
||||||
|
boundary: string,
|
||||||
|
): AsyncGenerator<MultipartFilePart> {
|
||||||
|
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<void> => {
|
||||||
|
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<Buffer | null> => {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<string, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
<title>File drop</title>
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<h1>File drop</h1>
|
||||||
|
<p class="tagline">Drop a file to get a private download link. Files are kept forever.</p>
|
||||||
|
|
||||||
|
<div id="dropzone" class="dropzone" role="button" tabindex="0" aria-label="Upload a file">
|
||||||
|
<input type="file" id="fileInput" hidden />
|
||||||
|
<p class="dz-main">Drag & drop a file here</p>
|
||||||
|
<p class="dz-sub">or click to choose</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progress" class="progress hidden">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="progressName"></span>
|
||||||
|
<span id="progressPct">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div id="progressBar" class="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result" class="result hidden">
|
||||||
|
<div class="result-meta">
|
||||||
|
<strong id="resultName"></strong>
|
||||||
|
<span id="resultSize"></span>
|
||||||
|
</div>
|
||||||
|
<div class="url-row">
|
||||||
|
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
|
||||||
|
<button id="copyBtn" type="button">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button id="deleteBtn" type="button" class="danger">Delete file</button>
|
||||||
|
<button id="againBtn" type="button" class="ghost">Upload another</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notice" class="hidden"></div>
|
||||||
|
|
||||||
|
<section id="fileListWrap" class="files hidden" aria-label="Your uploaded files">
|
||||||
|
<div class="files-head">
|
||||||
|
<h2>Your files</h2>
|
||||||
|
<button id="clearListBtn" type="button" class="ghost">Clear list</button>
|
||||||
|
</div>
|
||||||
|
<p class="files-note">
|
||||||
|
Stored locally in this browser only. Other visitors cannot see them.
|
||||||
|
</p>
|
||||||
|
<ul id="fileList"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface Bucket {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Response> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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<Response> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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<Response> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
+127
@@ -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<string, { file: string; type: string }> = {
|
||||||
|
"/": { 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<string, { body: string; type: string }>();
|
||||||
|
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<undefined>): 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<undefined>): Promise<Response> {
|
||||||
|
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)}`);
|
||||||
|
});
|
||||||
+142
@@ -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<void> {
|
||||||
|
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<Uint8Array>, maxBytes: number): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<FileMeta | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(this.metaPath(id), "utf8");
|
||||||
|
const meta = JSON.parse(raw) as Partial<FileMeta>;
|
||||||
|
if (typeof meta.id !== "string" || typeof meta.downloadKeyHash !== "string") return null;
|
||||||
|
return meta as FileMeta;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await rm(this.filePath(id), { force: true });
|
||||||
|
await rm(this.metaPath(id), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePartial(id: string): Promise<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user