diff --git a/README.md b/README.md index fa0619c..574ad5c 100644 --- a/README.md +++ b/README.md @@ -1,439 +1,84 @@ -# File Drop (VYBECODED) +# File Drop -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 on the local filesystem and expire after 48 hours. +Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier, +on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent +directement dans le navigateur ; les autres fichiers se téléchargent. -Everything lives on disk — the filesystem is the source of truth. There is no -database, no object storage, and no queue. Expired files are swept automatically. +- **Backend** — Rust (Axum + Tokio) +- **Frontend** — Svelte (Vite + Tailwind) +- **Stockage** — système de fichiers local, sans base de données -## Features - -- Drag & drop / file-picker uploads with progress, straight from the browser. -- **Media files render inline** (image/video/audio/PDF/text) as direct, embeddable - links, with one-click **Direct / HTML / BBCode / Markdown** link formats. -- **Optional per-file password**: set a password to gate downloads behind a prompt. -- 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, default 10GB). -- Files **auto-expire after 48 hours** and are removed automatically. -- Authenticated deletion (possession of the file's secret key is required). -- Rate-limited download/delete endpoints to blunt brute force. -- Zero runtime dependencies (Bun is the only runtime). - -## Requirements - -- [Bun](https://bun.sh) >= 1.1 (developed and tested against 1.3). -- Any platform Bun runs on (Linux, macOS, Windows). - -## Installation +## Démarrage rapide ```bash -bun install +# 1. Construire le frontend (produit frontend/dist) +cd frontend +npm install +npm run build +cd .. + +# 2. Lancer le serveur (depuis la racine du projet) +cargo run --release --manifest-path backend/Cargo.toml ``` -There are no runtime dependencies; the only packages are dev-time -(`typescript`, `@types/bun`) used for type-checking. +Ouvrir ensuite http://localhost:3000. + +> Les chemins `STORAGE_DIR` et `STATIC_DIR` sont relatifs au dossier de +> lancement. Lance donc le serveur depuis la racine du projet (ou règle ces +> variables en absolu dans `.env`). ## Configuration -All configuration is via environment variables. Bun auto-loads `.env`, so you -can copy the example and edit it: +Copier `backend/.env.example` vers `.env` puis l'adapter. -```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` | `10GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). | -| `MAX_STORAGE_USAGE` | `0.5` | Max fraction (0.0–1.0) of the storage disk that may be used; uploads are rejected with `503 try again later` above this. | -| `FILE_TTL_MS` | `172800000` | Milliseconds before an upload expires and is deleted (48 hours, hard maximum). | -| `KEY_LENGTH` | `8` | Length in characters of the upload/download key token (a-z0-9). | -| `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/(key)` and `/api/files/`. | -| `RATE_LIMIT_MAX` | `300` | Max requests per IP per window. | -| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. | -| `TRUST_PROXY` | `0` | Set `1` only behind a proxy that sets `X-Forwarded-For`. | - -## 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 and returns a URL like - `https://files.example.com/8fJ...random-secret...`. -4. Copy the URL. Anyone possessing it can download the file. If you set a - password, they must enter it before the download starts. -5. The file is removed automatically after 48 hours, or earlier by explicit - deletion. - -## Filesystem layout - -``` -storage/ -├── .secret # server signing secret (0600), auto-generated -├── files/ -│ ├── # the raw uploaded bytes -│ └── -└── metadata/ - ├── .json # metadata for each file - └── .json -``` - -Example metadata record (`storage/metadata/.json`): - -```json -{ - "id": "u8wcfcm7", - "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. If a download password is set, only -its scrypt hash is stored (see [password protection](#password-protection)). -The server signing secret for download cookies lives in `storage/.secret` -(`0600`), created on first start and reused across restarts. - -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. +| Variable | Défaut | Rôle | +| ---------------------- | ----------------- | ----------------------------------------- | +| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. | +| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. | +| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. | +| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. | +| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). | +| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). | +| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. | +| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. | +| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. | +| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). | ## API -### `POST /api/upload` +| Méthode | Route | Rôle | +| --------- | -------------------- | ------------------------------------------- | +| `POST` | `/api/upload` | Envoi d'un fichier (`multipart/form-data`). | +| `GET` | `/:key` | Téléchargement (inline pour les médias). | +| `POST` | `/api/auth/:key` | Mot de passe d'un fichier protégé. | +| `DELETE` | `/api/files/:id` | Suppression (en-tête `X-Delete-Key`). | +| `GET` | `/api/health` | État du service. | -Multipart/form-data with a `file` field and an **optional** `password` field -(this is what the web UI uses). When no password is sent, the file is -downloadable by anyone with the URL. +Exemple : ```bash -curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/upload +curl -F "file=@photo.png" http://localhost:3000/api/upload ``` -```json -{ - "id": "u8wcfcm7", - "key": "u8wcfcm7", - "url": "/u8wcfcm7.zip", - "originalName": "example.zip", - "downloadName": "u8wcfcm7.zip", - "size": 123456, - "mimeType": "application/zip", - "protected": true -} +Le lien renvoyé est de la forme `http://localhost:3000/.png`. Ajouter +`?dl=1` force le téléchargement au lieu de l'affichage. + +## Structure + +``` +backend/ serveur Rust (Axum) +frontend/ interface Svelte (Vite + Tailwind) +scripts/ tests +storage/ données (généré au premier lancement) ``` -`id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits -of entropy); the short length keeps share URLs tidy, and an optional password -can add a second factor of protection. - -### Uploading with curl (tutorial) - -Start an instance, then point curl at `/api/upload`: +## Tests ```bash -BASE=https://download.wf +cargo build # dans backend/ +bash scripts/e2e-test.sh ``` -**1. Public upload (no password).** The only required field is `file`: - -```bash -curl -sS -F "file=@myfile.zip" "$BASE/api/upload" -``` - -```json -{ - "id": "u8wcfcm7", - "key": "u8wcfcm7", - "url": "/u8wcfcm7.zip", - "originalName": "myfile.zip", - "size": 123456, - "mimeType": "application/zip", - "protected": false -} -``` - -`url` is the share link — put it in a variable with `jq`: - -```bash -url=$(curl -sS -F "file=@myfile.zip" "$BASE/api/upload" | jq -r .url) -echo "share: $BASE$url" -``` - -**2. Password-protected upload.** Add the `password` field; anyone with the link -must then enter it to download: - -```bash -curl -sS -F "password=hunter2" -F "file=@myfile.zip" "$BASE/api/upload" -# => "protected": true -``` - -**3. Download.** Public files stream immediately: - -```bash -curl -O "$BASE$url" -``` - -A password-protected file needs the auth cookie first: - -```bash -curl -sS -c cookies.txt -X POST -d "password=hunter2" "$BASE/api/auth/$key" -curl -sS -b cookies.txt -O "$BASE/$key" -``` - -**4. Troubleshooting.** - -- `400 expected a multipart/form-data upload` — missing or malformed - `Content-Type` (curl's `-F` already sets it correctly). -- `400 no file part in the upload` — no `file=` field was sent. -- `413 file too large` — the upload exceeds `MAX_FILE_SIZE`. -- `503 storage is full, try again later` — disk usage on the storage - filesystem is above `MAX_STORAGE_USAGE`; retry later. - -Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id` -(see below). - -### `GET /:key` (also `HEAD`) - -The download URL is the key at the site root, optionally with the file extension -appended for a Catbox-style link (`https://host/u8wcfcm7` or -`https://host/u8wcfcm7.zip`). The legacy `/d/:key` form still works as an alias. A -file without a password streams immediately; if a password is set, the server shows -a password prompt before streaming. A wrong or non-existent key returns a generic -`404`. - -Media files (images, video, audio, PDF, plain text) are served `inline` so they -render directly in the browser and can be embedded (``, Discord, etc.). -Everything else — and any URL with `?dl=1` — is sent as an `attachment` download. - -```bash -curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set -curl -O "http://localhost:3000/u8wcfcm7.zip?dl=1" # always force a download -``` - -### Password protection - -Files uploaded with a password are gated like this: - -1. `GET /:key` returns a small HTML password form. -2. The browser submits it to `POST /api/auth/:key` with `password=...`. -3. On success the server sets a short-lived (30 min), HttpOnly, SameSite=Lax, - signed cookie and redirects back to `/:key`, which then streams the file. - -```bash -curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key -curl -b cookies.txt -O http://localhost:3000/:key -``` - -The cookie is signed with a server secret (`storage/.secret`) so it cannot be -forged, and it survives server restarts. Without it, a password-protected file -is never downloadable — not even by the uploader. Files uploaded without a -password stream immediately and have no auth gate. - -### `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: u8wcfcm7" http://localhost:3000/api/files/u8wcfcm7 -``` - -### `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 8-character lowercase alphanumeric key - (`crypto.randomBytes` mapped onto `a-z0-9`, ~41 bits of entropy), used as both - the share id and the download key. Guessing one is infeasible at scale, and an - optional password can be an added second factor. 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`. -- **Passwords.** A file's password (if any) is stored only as an **scrypt** hash - (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed - cookie, so access is granted by the server and cannot be forged. -- **Path safety.** Filesystem paths are built only from server-generated, - regex-validated ids (`^[a-z0-9]{8}$`). The original filename is metadata - only and is sanitized (basename, control/null bytes stripped) before being - stored; downloads are sent as `.`, so the basename in - `Content-Disposition` is always the server-generated key. 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.** Non-media 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. Only a whitelist of - inert media types (image, video, audio, PDF, plain text — never HTML or SVG) is - served `inline` for direct/embed links. -- **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 11g; # >= 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 /: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. +Le test couvre l'envoi, le téléchargement, l'authentification par mot de passe, +les clés invalides, la sanitisation des noms et la suppression. diff --git a/assets/logo.svg b/assets/logo.svg deleted file mode 100644 index 2c500ea..0000000 --- a/assets/logo.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..25f9d8a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,36 @@ +# Filehost configuration (Rust backend). +# Copy to .env (at the project root) and edit as needed. + +# Bind address and port. +HOST=0.0.0.0 +PORT=3000 + +# Directory where files + metadata are stored. +STORAGE_DIR=./storage + +# Directory containing the built Svelte frontend (vite build output). +STATIC_DIR=./frontend/dist + +# Max upload size. Units: B, KB, MB, GB, TB (or plain bytes). +MAX_FILE_SIZE=10GB + +# Max fraction (0.0-1.0) of the storage disk that may be used; uploads are +# rejected with 503 above this. +MAX_STORAGE_USAGE=0.5 + +# Milliseconds before an upload expires and is deleted (48 hours hard maximum). +FILE_TTL_MS=172800000 + +# Length in characters of the upload/download key token (a-z0-9). +KEY_LENGTH=8 + +# Optional public base URL used to build absolute share links. +# PUBLIC_BASE_URL=https://files.example.com + +# Enable per-IP rate limiting on download/delete/auth endpoints. +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_MAX=300 +RATE_LIMIT_WINDOW_MS=60000 + +# Set 1 only behind a proxy that sets X-Forwarded-For. +TRUST_PROXY=0 diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..d0e731c --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,1286 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filehost" +version = "1.0.0" +dependencies = [ + "axum", + "base64", + "chrono", + "dotenvy", + "hmac", + "multer", + "nix", + "rand", + "scrypt", + "serde", + "serde_json", + "sha2", + "subtle", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..72d385d --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "filehost" +version = "1.0.0" +edition = "2021" +description = "Minimal self-hosted private file hosting service (Rust + Axum)" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +multer = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +scrypt = "0.11" +hmac = "0.12" +subtle = "2" +rand = "0.8" +dotenvy = "0.15" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +nix = { version = "0.29", features = ["fs"] } + +[profile.release] +lto = true +strip = true diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..159a442 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +const MAX_TTL_MS: i64 = 48 * 60 * 60 * 1000; // 48 hours hard maximum + +#[derive(Clone, Debug)] +pub struct Config { + pub host: String, + pub port: u16, + pub storage_dir: PathBuf, + pub static_dir: PathBuf, + pub max_file_size: u64, + pub file_ttl_ms: i64, + pub max_storage_usage: f64, + pub key_length: usize, + pub public_base_url: Option, + pub rate_limit_enabled: bool, + pub rate_limit_max: usize, + pub rate_limit_window_ms: u64, + pub trust_proxy: bool, +} + +pub fn parse_size(value: &str) -> Result { + let s = value.trim(); + let split = s.find(|c: char| !c.is_ascii_digit() && c != '.'); + let (num_part, unit_part) = match split { + Some(i) => (&s[..i], &s[i..]), + None => (s, ""), + }; + let num: f64 = num_part + .trim() + .parse() + .map_err(|_| format!("Invalid size value: \"{value}\""))?; + let unit = unit_part.trim().to_uppercase(); + let factor: f64 = match unit.as_str() { + "B" | "" => 1.0, + "K" | "KB" | "KIB" => 1024.0, + "M" | "MB" | "MIB" => 1024.0f64.powi(2), + "G" | "GB" | "GIB" => 1024.0f64.powi(3), + "T" | "TB" | "TIB" => 1024.0f64.powi(4), + _ => return Err(format!("Invalid size unit in: \"{value}\"")), + }; + Ok((num * factor) as u64) +} + +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +fn bool_from_env(value: Option<&str>, fallback: bool) -> bool { + match value { + Some(v) => !["0", "false", "no", "off"].contains(&v.trim().to_lowercase().as_str()), + None => fallback, + } +} + +pub fn load() -> Result { + let port = env("PORT") + .unwrap_or_else(|| "3000".to_string()) + .parse::() + .map_err(|_| format!("Invalid PORT: {:?}", env("PORT")))?; + if port == 0 { + return Err("Invalid PORT".to_string()); + } + + let host = env("HOST").unwrap_or_else(|| "0.0.0.0".to_string()); + + let storage_dir = PathBuf::from(env("STORAGE_DIR").unwrap_or_else(|| "./storage".to_string())); + let static_dir = PathBuf::from(env("STATIC_DIR").unwrap_or_else(|| "./frontend/dist".to_string())); + + let max_file_size = parse_size( + &env("MAX_FILE_SIZE").unwrap_or_else(|| "10GB".to_string()), + )?; + if max_file_size < 1024 { + return Err("MAX_FILE_SIZE must be at least 1KB".to_string()); + } + + let file_ttl_ms_raw = env("FILE_TTL_MS") + .unwrap_or_else(|| MAX_TTL_MS.to_string()) + .parse::() + .map_err(|_| "Invalid FILE_TTL_MS".to_string())?; + if file_ttl_ms_raw < 1 { + return Err("Invalid FILE_TTL_MS".to_string()); + } + let file_ttl_ms = file_ttl_ms_raw.min(MAX_TTL_MS); + + let max_storage_usage = env("MAX_STORAGE_USAGE") + .unwrap_or_else(|| "0.5".to_string()) + .parse::() + .map_err(|_| "Invalid MAX_STORAGE_USAGE".to_string())?; + if !(0.0..=1.0).contains(&max_storage_usage) { + return Err("Invalid MAX_STORAGE_USAGE".to_string()); + } + + let key_length = env("KEY_LENGTH") + .unwrap_or_else(|| "8".to_string()) + .parse::() + .map_err(|_| "Invalid KEY_LENGTH".to_string())?; + if !(4..=64).contains(&key_length) { + return Err("Invalid KEY_LENGTH".to_string()); + } + + let rate_limit_max = env("RATE_LIMIT_MAX") + .unwrap_or_else(|| "300".to_string()) + .parse::() + .map_err(|_| "Invalid RATE_LIMIT_MAX".to_string())?; + if rate_limit_max < 1 { + return Err("Invalid RATE_LIMIT_MAX".to_string()); + } + + let rate_limit_window_ms = env("RATE_LIMIT_WINDOW_MS") + .unwrap_or_else(|| "60000".to_string()) + .parse::() + .map_err(|_| "Invalid RATE_LIMIT_WINDOW_MS".to_string())?; + if rate_limit_window_ms < 1000 { + return Err("Invalid RATE_LIMIT_WINDOW_MS".to_string()); + } + + let trust_proxy = bool_from_env(env("TRUST_PROXY").as_deref(), false); + let rate_limit_enabled = bool_from_env(env("RATE_LIMIT_ENABLED").as_deref(), true); + + let public_base_url = match env("PUBLIC_BASE_URL") { + Some(raw) => { + let normalized = raw.trim_end_matches('/').to_string(); + let valid = (normalized.starts_with("http://") || normalized.starts_with("https://")) + && !normalized[normalized.find("://").map(|i| i + 3).unwrap_or(0)..] + .contains('/') + && !normalized.chars().any(char::is_whitespace); + if !valid { + return Err(format!("Invalid PUBLIC_BASE_URL: \"{raw}\"")); + } + Some(normalized) + } + None => None, + }; + + Ok(Config { + host, + port, + storage_dir, + static_dir, + max_file_size, + file_ttl_ms, + max_storage_usage, + key_length, + public_base_url, + rate_limit_enabled, + rate_limit_max, + rate_limit_window_ms, + trust_proxy, + }) +} diff --git a/backend/src/http.rs b/backend/src/http.rs new file mode 100644 index 0000000..6001fa7 --- /dev/null +++ b/backend/src/http.rs @@ -0,0 +1,59 @@ +use axum::body::Body; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::Response; +use serde::Serialize; +use std::net::SocketAddr; + +pub fn json(data: &T, status: StatusCode) -> Response { + let body = match serde_json::to_string(data) { + Ok(s) => s, + Err(_) => "{}".to_string(), + }; + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header("x-content-type-options", "nosniff") + .header("referrer-policy", "no-referrer") + .body(Body::from(body)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +pub fn json_error(status: StatusCode, message: &str) -> Response { + json(&serde_json::json!({ "error": message }), status) +} + +pub fn not_found() -> Response { + json_error(StatusCode::NOT_FOUND, "not found") +} + +pub fn redirect(location: &str) -> Response { + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, location) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +/// Resolves the client IP, honoring proxy headers only when trusted. +pub fn client_ip(trust_proxy: bool, addr: SocketAddr, headers: &HeaderMap) -> String { + if trust_proxy { + if let Some(xff) = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + { + if let Some(first) = xff.split(',').next() { + let first = first.trim(); + if !first.is_empty() { + return first.to_string(); + } + } + } + if let Some(cf) = headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + { + return cf.to_string(); + } + } + addr.ip().to_string() +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..dd6227e --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,150 @@ +mod config; +mod http; +mod ratelimit; +mod routes; +mod security; +mod storage; + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::routing::{delete, get, post}; +use axum::Router; + +use config::Config; +use ratelimit::RateLimiter; +use storage::Storage; + +#[derive(Clone)] +pub struct AppState { + pub config: Config, + pub storage: Arc, + pub secret: String, + pub limiter: Arc, + pub started_at: Instant, +} + +fn build_router(state: AppState) -> Router { + Router::new() + .route("/api/health", get(routes::health)) + .route("/api/upload", post(routes::upload::upload)) + .route("/api/auth/{key}", post(routes::auth::auth)) + .route("/api/files/{id}", delete(routes::delete::delete)) + .fallback(routes::download::fallback) + .with_state(state) +} + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let config = match config::load() { + Ok(c) => c, + Err(e) => { + tracing::error!("configuration error: {e}"); + std::process::exit(1); + } + }; + security::set_key_length(config.key_length); + + let storage = Arc::new(Storage::new(config.storage_dir.clone())); + if let Err(e) = storage.init().await { + tracing::error!("failed to initialize storage: {e}"); + std::process::exit(1); + } + let secret = match storage.load_secret().await { + Ok(s) => s, + Err(e) => { + tracing::error!("failed to load server secret: {e}"); + std::process::exit(1); + } + }; + let sweep = storage.sweep().await; + if sweep.0 > 0 || sweep.1 > 0 { + tracing::info!( + "startup sweep removed {} orphaned file(s), {} orphaned metadata record(s)", + sweep.0, + sweep.1 + ); + } + + let state = AppState { + limiter: Arc::new(RateLimiter::new(config.rate_limit_max, config.rate_limit_window_ms)), + started_at: Instant::now(), + config: config.clone(), + storage, + secret, + }; + + // Hourly expiration sweep. + let expiry_state = state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); + loop { + interval.tick().await; + let removed = expiry_state + .storage + .expire_expired(chrono::Utc::now().timestamp_millis()) + .await; + if removed > 0 { + tracing::info!("expiry sweep removed {removed} file(s)"); + } + } + }); + + let app = build_router(state); + + let addr = format!("{}:{}", config.host, config.port); + let listener = match tokio::net::TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + tracing::error!("failed to bind {addr}: {e}"); + std::process::exit(1); + } + }; + + tracing::info!( + "listening on http://{} storage={:?} maxUpload={}", + addr, + config.storage_dir, + config.max_file_size + ); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await + .expect("server error"); +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + + #[cfg(unix)] + let terminate = async { + let _ = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("shutdown signal received"); +} diff --git a/backend/src/ratelimit.rs b/backend/src/ratelimit.rs new file mode 100644 index 0000000..1d940a9 --- /dev/null +++ b/backend/src/ratelimit.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +struct Bucket { + count: usize, + reset_at: Instant, +} + +/// Simple per-IP fixed-window rate limiter (in-memory). +pub struct RateLimiter { + max: usize, + window: Duration, + buckets: Mutex>, +} + +impl RateLimiter { + pub fn new(max: usize, window_ms: u64) -> Self { + Self { + max, + window: Duration::from_millis(window_ms), + buckets: Mutex::new(HashMap::new()), + } + } + + /// Returns `true` if the request is allowed, `false` if rate limited. + pub fn check(&self, ip: &str) -> bool { + let now = Instant::now(); + let mut buckets = self.buckets.lock().unwrap(); + if buckets.len() > 10_000 { + buckets.retain(|_, b| b.reset_at > now); + } + let window = self.window; + let max = self.max; + let bucket = buckets + .entry(ip.to_string()) + .or_insert_with(|| Bucket { + count: 0, + reset_at: now + window, + }); + if bucket.reset_at <= now { + bucket.count = 0; + bucket.reset_at = now + window; + } + bucket.count += 1; + bucket.count <= max + } +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..94b00ab --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,189 @@ +use std::net::SocketAddr; + +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::Response; +use serde::Deserialize; + +use crate::http::{client_ip, not_found, redirect}; +use crate::{security, AppState}; + +pub const AUTH_COOKIE: &str = "fh_auth"; +pub const AUTH_TTL_MS: i64 = 30 * 60 * 1000; + +fn parse_cookie<'a>(header: Option<&'a HeaderValue>, name: &str) -> Option<&'a str> { + let header = header?.to_str().ok()?; + for part in header.split(';') { + let part = part.trim(); + if let Some((k, v)) = part.split_once('=') { + if k.trim() == name { + return Some(v.trim()); + } + } + } + None +} + +pub fn has_valid_auth(headers: &HeaderMap, secret: &str, key: &str) -> bool { + match parse_cookie(headers.get(header::COOKIE), AUTH_COOKIE) { + Some(token) => security::parse_auth_token(token, secret) + .map(|k| k == key) + .unwrap_or(false), + None => false, + } +} + +fn is_secure(trust_proxy: bool, headers: &HeaderMap) -> bool { + if trust_proxy { + return headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + == Some("https"); + } + false +} + +#[derive(Deserialize)] +pub struct AuthForm { + password: Option, +} + +pub async fn auth( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Path(key): Path, + headers: HeaderMap, + axum::Form(form): axum::Form, +) -> Response { + let ip = client_ip(state.config.trust_proxy, addr, &headers); + if state.config.rate_limit_enabled && !state.limiter.check(&ip) { + return crate::http::json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + + if !security::is_valid_key(&key) { + return not_found(); + } + + let meta = match state.storage.read_metadata(&key).await { + Some(m) => m, + None => return not_found(), + }; + + if meta.password_hash.is_none() { + return redirect(&format!("/{key}")); + } + + let password = form.password.unwrap_or_default(); + let ok = match &meta.password_hash { + Some(hash) => security::password_matches(hash, &password), + None => false, + }; + + if !ok { + tracing::warn!("auth failed id={key}"); + return redirect(&format!("/{key}?e=1")); + } + + let token = security::make_auth_token(&key, &state.secret, AUTH_TTL_MS); + let secure = if is_secure(state.config.trust_proxy, &headers) { + "; Secure" + } else { + "" + }; + let cookie = format!( + "{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax{secure}; Max-Age={}", + AUTH_TTL_MS / 1000 + ); + + tracing::info!("auth ok id={key}"); + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, format!("/{key}")) + .header(header::SET_COOKIE, cookie) + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| redirect(&format!("/{key}"))) +} + +pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response { + let message = if error { + "

Incorrect password. Please try again.

" + } else { + "" + }; + let safe_filename = escape_html(filename); + let html = format!( + r#" + + + + + + Protected file + + + +
+
+ logo + File Drop +
+

Protected file

+

Enter the password to download {safe_filename}.

+ {message} +
+ + +
+
+ +"# + ); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header("cache-control", "private, no-store") + .header("x-content-type-options", "nosniff") + .header("referrer-policy", "no-referrer") + .header( + "content-security-policy", + "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ) + .body(axum::body::Body::from(html)) + .unwrap_or_else(|_| not_found()) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/backend/src/routes/delete.rs b/backend/src/routes/delete.rs new file mode 100644 index 0000000..786b583 --- /dev/null +++ b/backend/src/routes/delete.rs @@ -0,0 +1,42 @@ +use std::net::SocketAddr; + +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; + +use crate::http::{client_ip, json, json_error, not_found}; +use crate::{security, AppState}; + +pub async fn delete( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Path(id): Path, + headers: HeaderMap, +) -> Response { + let ip = client_ip(state.config.trust_proxy, addr, &headers); + if state.config.rate_limit_enabled && !state.limiter.check(&ip) { + return json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + + if !security::is_valid_key(&id) { + return not_found(); + } + + let meta = match state.storage.read_metadata(&id).await { + Some(m) => m, + None => return not_found(), + }; + + let key = match headers.get("x-delete-key").and_then(|v| v.to_str().ok()) { + Some(k) if security::is_valid_key(k) => k, + _ => return not_found(), + }; + + if !security::secret_matches(&meta.download_key_hash, key) { + return not_found(); + } + + state.storage.delete(&id).await; + tracing::info!("deleted id={id} name={:?}", meta.original_name); + json(&serde_json::json!({ "ok": true }), StatusCode::OK) +} diff --git a/backend/src/routes/download.rs b/backend/src/routes/download.rs new file mode 100644 index 0000000..9e99e1e --- /dev/null +++ b/backend/src/routes/download.rs @@ -0,0 +1,129 @@ +use std::net::SocketAddr; + +use axum::body::Body; +use axum::extract::{ConnectInfo, State}; +use axum::http::{Method, Request, StatusCode}; +use axum::response::Response; +use tower::ServiceExt; +use tower_http::services::ServeDir; + +use crate::http::{client_ip, json_error, not_found}; +use crate::routes::auth::{has_valid_auth, render_password_form}; +use crate::{security, storage, AppState}; + +pub async fn handle_download( + state: AppState, + req: Request, + key: String, + ip: String, +) -> Response { + if state.config.rate_limit_enabled && !state.limiter.check(&ip) { + return json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + + let meta = match state.storage.read_metadata(&key).await { + Some(m) => m, + None => return not_found(), + }; + + let now_ms = chrono::Utc::now().timestamp_millis(); + if storage::is_expired(&meta, now_ms) { + state.storage.delete(&key).await; + tracing::info!("download rejected id={key} expired"); + return not_found(); + } + + if !security::secret_matches(&meta.download_key_hash, &key) { + return not_found(); + } + + if meta.password_hash.is_some() && !has_valid_auth(req.headers(), &state.secret, &key) { + let error = req + .uri() + .query() + .map(|q| q.split('&').any(|p| p == "e=1")) + .unwrap_or(false); + return render_password_form(&key, &meta.original_name, error); + } + + let file_path = state.storage.file_path(&key); + let file_meta = match tokio::fs::symlink_metadata(&file_path).await { + Ok(m) => m, + Err(_) => return not_found(), + }; + if !file_meta.file_type().is_file() { + return not_found(); + } + + let force_download = req + .uri() + .query() + .map(|q| q.split('&').any(|p| p == "dl=1")) + .unwrap_or(false); + let disposition = if force_download || !security::should_inline(&meta.mime_type) { + "attachment" + } else { + "inline" + }; + let filename = security::download_file_name(&key, &meta.original_name); + + let builder = Response::builder() + .status(StatusCode::OK) + .header("content-type", &meta.mime_type) + .header("content-length", file_meta.len().to_string()) + .header( + "content-disposition", + security::content_disposition(&filename, disposition), + ) + .header("cache-control", "private, no-store") + .header("x-content-type-options", "nosniff") + .header("referrer-policy", "no-referrer"); + + if req.method() == Method::HEAD { + return builder.body(Body::empty()).unwrap_or_else(|_| not_found()); + } + + let file = match tokio::fs::File::open(&file_path).await { + Ok(f) => f, + Err(_) => return not_found(), + }; + let stream = tokio_util::io::ReaderStream::new(file); + tracing::info!("download id={key} size={}", file_meta.len()); + builder + .body(Body::from_stream(stream)) + .unwrap_or_else(|_| not_found()) +} + +/// Handles every unmatched route: download URLs (`/{key}` and `/d/{key}`) and +/// the static Svelte frontend. +pub async fn fallback( + State(state): State, + ConnectInfo(addr): ConnectInfo, + req: Request, +) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let static_dir = state.config.static_dir.clone(); + + if method == Method::GET || method == Method::HEAD { + let raw = path + .strip_prefix("/d/") + .or_else(|| path.strip_prefix('/')) + .unwrap_or(""); + if !raw.is_empty() && !raw.contains('/') { + if let Some(key) = security::key_from_path(raw) { + let ip = client_ip(state.config.trust_proxy, addr, req.headers()); + return handle_download(state, req, key, ip).await; + } + } + } + + if method == Method::GET || method == Method::HEAD { + return match ServeDir::new(static_dir).oneshot(req).await { + Ok(res) => res.map(axum::body::Body::new), + Err(_) => not_found(), + }; + } + + not_found() +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..5448f00 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,21 @@ +pub mod auth; +pub mod delete; +pub mod download; +pub mod upload; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Response; + +use crate::http::json; +use crate::AppState; + +pub async fn health(State(state): State) -> Response { + json( + &serde_json::json!({ + "status": "ok", + "uptimeSeconds": state.started_at.elapsed().as_secs(), + }), + StatusCode::OK, + ) +} diff --git a/backend/src/routes/upload.rs b/backend/src/routes/upload.rs new file mode 100644 index 0000000..536f65f --- /dev/null +++ b/backend/src/routes/upload.rs @@ -0,0 +1,224 @@ +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::Response; +use tokio::io::AsyncWriteExt; + +use crate::http::{json, json_error}; +use crate::storage::FileMeta; +use crate::{security, AppState}; + +const MAX_PASSWORD_LENGTH: usize = 256; + +enum Outcome { + Ok, + Multipart, + TooLarge, + Internal, +} + +pub async fn upload( + State(state): State, + headers: HeaderMap, + body: Body, +) -> Response { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let boundary = match multer::parse_boundary(content_type) { + Ok(b) => b, + Err(_) => return json_error(StatusCode::BAD_REQUEST, "expected a multipart/form-data upload"), + }; + + let id = security::new_file_id(); + + let usage = state.storage.storage_usage_ratio().await; + if usage > state.config.max_storage_usage { + tracing::warn!( + "upload rejected id={id}: storage usage {:.1}% exceeds limit {:.0}%", + usage * 100.0, + state.config.max_storage_usage * 100.0 + ); + return json_error(StatusCode::SERVICE_UNAVAILABLE, "storage is full, try again later"); + } + + let constraints = multer::Constraints::new() + .size_limit(multer::SizeLimit::new().whole_stream(u64::MAX).per_field(u64::MAX)); + let mut multipart = + multer::Multipart::with_constraints(body.into_data_stream(), boundary, constraints); + + let mut filename = String::new(); + let mut mime = "application/octet-stream".to_string(); + let mut size: u64 = 0; + let mut has_file = false; + let mut password = String::new(); + let mut outcome = Outcome::Ok; + + 'fields: loop { + let mut field = match multipart.next_field().await { + Ok(Some(f)) => f, + Ok(None) => break, + Err(_) => { + outcome = Outcome::Multipart; + break; + } + }; + + if field.file_name().is_some() { + if !has_file { + has_file = true; + filename = field.file_name().unwrap_or("").to_string(); + let ct = field + .content_type() + .map(|m| m.as_ref().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + mime = security::sanitize_mime(&ct); + + let part_path = state.storage.part_path(&id); + let mut file = match tokio::fs::File::create(&part_path).await { + Ok(f) => f, + Err(_) => { + outcome = Outcome::Internal; + break 'fields; + } + }; + + loop { + match field.chunk().await { + Ok(Some(chunk)) => { + size += chunk.len() as u64; + if size > state.config.max_file_size { + outcome = Outcome::TooLarge; + break 'fields; + } + if file.write_all(&chunk).await.is_err() { + outcome = Outcome::Internal; + break 'fields; + } + } + Ok(None) => break, + Err(_) => { + outcome = Outcome::Internal; + break 'fields; + } + } + } + + let _ = file.flush().await; + drop(file); + + if matches!(outcome, Outcome::Ok) { + if tokio::fs::rename(&part_path, &state.storage.file_path(&id)) + .await + .is_err() + { + outcome = Outcome::Internal; + break 'fields; + } + } + } else { + while let Ok(Some(_)) = field.chunk().await {} + } + } else { + let name = field.name().unwrap_or("").to_string(); + if name == "password" && password.is_empty() { + let mut value = String::new(); + loop { + match field.chunk().await { + Ok(Some(chunk)) => { + value.push_str(&String::from_utf8_lossy(&chunk)); + if value.len() > MAX_PASSWORD_LENGTH { + outcome = Outcome::Multipart; + break 'fields; + } + } + Ok(None) => break, + Err(_) => { + outcome = Outcome::Multipart; + break 'fields; + } + } + } + password = value; + } else { + while let Ok(Some(_)) = field.chunk().await {} + } + } + } + + if !matches!(outcome, Outcome::Ok) || !has_file { + state.storage.delete(&id).await; + state.storage.delete_partial(&id).await; + } + + match outcome { + Outcome::TooLarge => { + tracing::warn!("upload rejected id={id} exceeding max size {}", state.config.max_file_size); + return json_error(StatusCode::PAYLOAD_TOO_LARGE, "file too large"); + } + Outcome::Multipart => { + tracing::warn!("upload rejected id={id}: invalid multipart body"); + return json_error(StatusCode::BAD_REQUEST, "invalid multipart body"); + } + Outcome::Internal => { + tracing::error!("upload failed id={id}"); + return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed"); + } + Outcome::Ok => {} + } + + if !has_file { + return json_error(StatusCode::BAD_REQUEST, "no file part in the upload"); + } + + let original_name = security::sanitize_original_name(&filename); + let password_hash = if !password.trim().is_empty() { + security::hash_password(&password) + } else { + None + }; + + let now = chrono::Utc::now(); + let download_name = security::download_file_name(&id, &original_name); + + let meta = FileMeta { + id: id.clone(), + original_name: original_name.clone(), + size, + mime_type: mime.clone(), + download_key_hash: security::hash_secret(&id), + password_hash, + created_at: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + expires_at: Some( + (now + chrono::Duration::milliseconds(state.config.file_ttl_ms)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + ), + }; + + if state.storage.write_metadata(&meta).await.is_err() { + state.storage.delete(&id).await; + return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed"); + } + + let url = match &state.config.public_base_url { + Some(base) => format!("{base}/{download_name}"), + None => format!("/{download_name}"), + }; + + tracing::info!("upload ok id={id} size={size} name={original_name:?}"); + + json( + &serde_json::json!({ + "id": id, + "key": id, + "url": url, + "originalName": original_name, + "downloadName": download_name, + "size": size, + "mimeType": mime, + "protected": meta.password_hash.is_some(), + }), + StatusCode::OK, + ) +} diff --git a/backend/src/security.rs b/backend/src/security.rs new file mode 100644 index 0000000..139ec87 --- /dev/null +++ b/backend/src/security.rs @@ -0,0 +1,262 @@ +use base64::Engine; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use scrypt::{scrypt, Params}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use subtle::ConstantTimeEq; + +static KEY_LENGTH: AtomicUsize = AtomicUsize::new(8); + +pub fn set_key_length(len: usize) { + KEY_LENGTH.store(len, Ordering::Relaxed); +} + +pub fn key_length() -> usize { + KEY_LENGTH.load(Ordering::Relaxed) +} + +const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; +const CHARSET_SIZE: usize = 36; +const MAX_BYTE: u8 = (256 / CHARSET_SIZE) as u8 * CHARSET_SIZE as u8; // 252 + +/// Cryptographically secure, unguessable lowercase-alphanumeric id/key. +pub fn new_file_id() -> String { + let len = key_length(); + let mut out = String::with_capacity(len); + while out.len() < len { + let mut byte = [0u8; 1]; + rand::rngs::OsRng.fill_bytes(&mut byte); + let b = byte[0]; + if b >= MAX_BYTE { + continue; + } + out.push(CHARSET[(b as usize) % CHARSET_SIZE] as char); + } + out +} + +/// A bare key of the configured length (`a-z0-9` only). +pub fn is_valid_key(key: &str) -> bool { + key.len() == key_length() + && key + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +/// Accepts a bare key (`abc12345`) or a key with a cosmetic file extension +/// (`abc12345.png`). The key is everything before the first dot; the extension +/// is ignored for lookup and never touches the filesystem. +pub fn key_from_path(segment: &str) -> Option { + if segment.is_empty() || segment.contains('/') { + return None; + } + let key = segment.split('.').next().unwrap_or(""); + if is_valid_key(key) { + Some(key.to_string()) + } else { + None + } +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +fn hex_decode(s: &str) -> Result, ()> { + if s.len() % 2 != 0 { + return Err(()); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ())) + .collect() +} + +pub fn hash_secret(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex_encode(&digest) +} + +pub fn secret_matches(stored_hex: &str, candidate: &str) -> bool { + let Ok(stored) = hex_decode(stored_hex) else { + return false; + }; + let computed = Sha256::digest(candidate.as_bytes()); + stored.as_slice().ct_eq(computed.as_slice()).into() +} + +const SCRYPT_LOG_N: u8 = 14; // N = 16384 (matches Node's scryptSync default) +const SCRYPT_R: u32 = 8; +const SCRYPT_P: u32 = 1; +const SCRYPT_LEN: usize = 64; + +fn scrypt_params() -> Result { + Params::new(SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P, SCRYPT_LEN) +} + +/// Returns `scrypt::` or `None` on parameter error. +pub fn hash_password(password: &str) -> Option { + let mut salt = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let mut out = [0u8; SCRYPT_LEN]; + scrypt( + password.as_bytes(), + &salt, + &scrypt_params().ok()?, + &mut out, + ) + .ok()?; + Some(format!( + "scrypt:{}:{}", + hex_encode(&salt), + hex_encode(&out) + )) +} + +pub fn password_matches(stored: &str, password: &str) -> bool { + let parts: Vec<&str> = stored.split(':').collect(); + if parts.len() != 3 || parts[0] != "scrypt" { + return false; + } + let Ok(salt) = hex_decode(parts[1]) else { + return false; + }; + let Ok(expected) = hex_decode(parts[2]) else { + return false; + }; + if expected.len() != SCRYPT_LEN { + return false; + } + let Ok(params) = scrypt_params() else { + return false; + }; + let mut computed = [0u8; SCRYPT_LEN]; + if scrypt(password.as_bytes(), &salt, ¶ms, &mut computed).is_err() { + return false; + } + expected.as_slice().ct_eq(computed.as_slice()).into() +} + +type HmacSha256 = Hmac; + +fn hmac_sign(payload: &str, secret: &str) -> String { + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any size"); + mac.update(payload.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +pub fn make_auth_token(key: &str, secret: &str, ttl_ms: i64) -> String { + let expires = chrono::Utc::now().timestamp_millis() + ttl_ms; + let payload = format!("{key}.{expires}"); + format!("{payload}.{}", hmac_sign(&payload, secret)) +} + +pub fn parse_auth_token(token: &str, secret: &str) -> Option { + let last_dot = token.rfind('.')?; + let payload = &token[..last_dot]; + let signature = &token[last_dot + 1..]; + let expected = hmac_sign(payload, secret); + if !bool::from(signature.as_bytes().ct_eq(expected.as_bytes())) { + return None; + } + let dot = payload.rfind('.')?; + let key = &payload[..dot]; + let expires = payload[dot + 1..].parse::().ok()?; + if expires <= chrono::Utc::now().timestamp_millis() { + return None; + } + if !is_valid_key(key) { + return None; + } + Some(key.to_string()) +} + +pub fn sanitize_original_name(name: &str) -> String { + let base = name.replace('\\', "/"); + let base = base.rsplit('/').next().unwrap_or(""); + let cleaned: String = base + .chars() + .map(|c| if (c as u32) < 0x20 || c == '\u{7f}' { '_' } else { c }) + .collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + "file".to_string() + } else { + cleaned.to_string() + } +} + +pub fn sanitize_mime(value: &str) -> String { + let cleaned: String = value + .chars() + .filter(|c| !matches!(c, '\r' | '\n' | '"' | '\\')) + .collect(); + let cleaned = cleaned.trim(); + if !cleaned.is_empty() && cleaned.len() <= 200 { + cleaned.to_string() + } else { + "application/octet-stream".to_string() + } +} + +fn urlencode_utf8(s: &str) -> String { + let mut out = String::new(); + for b in s.as_bytes() { + match *b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'!' | b'~' => { + out.push(*b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +pub fn content_disposition(name: &str, disposition: &str) -> String { + let ascii: String = name + .chars() + .map(|c| { + if ('\u{20}'..='\u{7e}').contains(&c) && c != '"' && c != '\\' { + c + } else { + '_' + } + }) + .collect(); + let encoded = urlencode_utf8(name); + format!( + "{disposition}; filename=\"{ascii}\"; filename*=UTF-8''{encoded}" + ) +} + +pub fn download_file_name(key: &str, original_name: &str) -> String { + match original_name.rfind('.') { + Some(dot) if dot > 0 && dot < original_name.len() - 1 => { + format!("{key}.{}", &original_name[dot + 1..]) + } + _ => key.to_string(), + } +} + +/// Whether a file should render inline in the browser rather than be forced as +/// a download. Restricted to inert media types so the XSS protection is kept. +pub fn should_inline(mime_type: &str) -> bool { + let mime = mime_type.split(';').next().unwrap_or("").trim().to_lowercase(); + if mime.is_empty() || mime == "application/octet-stream" { + return false; + } + if mime == "image/svg+xml" || mime == "text/html" || mime == "application/xhtml+xml" { + return false; + } + mime.starts_with("image/") + || mime.starts_with("video/") + || mime.starts_with("audio/") + || mime == "text/plain" + || mime == "application/pdf" +} diff --git a/backend/src/storage.rs b/backend/src/storage.rs new file mode 100644 index 0000000..fd51095 --- /dev/null +++ b/backend/src/storage.rs @@ -0,0 +1,216 @@ +use std::path::PathBuf; + +use base64::Engine; +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +use crate::security::is_valid_key; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileMeta { + pub id: String, + pub original_name: String, + pub size: u64, + pub mime_type: String, + pub download_key_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_hash: Option, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +pub fn is_expired(meta: &FileMeta, now_ms: i64) -> bool { + match &meta.expires_at { + Some(s) => chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.timestamp_millis() <= now_ms) + .unwrap_or(false), + None => false, + } +} + +pub struct Storage { + root: PathBuf, + files_dir: PathBuf, + metadata_dir: PathBuf, +} + +impl Storage { + pub fn new(root: PathBuf) -> Self { + let files_dir = root.join("files"); + let metadata_dir = root.join("metadata"); + Self { + root, + files_dir, + metadata_dir, + } + } + + pub async fn init(&self) -> std::io::Result<()> { + tokio::fs::create_dir_all(&self.files_dir).await?; + tokio::fs::create_dir_all(&self.metadata_dir).await?; + Ok(()) + } + + pub async fn load_secret(&self) -> std::io::Result { + let path = self.root.join(".secret"); + if let Ok(existing) = tokio::fs::read_to_string(&path).await { + let existing = existing.trim().to_string(); + if !existing.is_empty() { + return Ok(existing); + } + } + + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true).mode(0o600); + let mut f = opts.open(&path)?; + f.write_all(secret.as_bytes())?; + } + #[cfg(not(unix))] + { + tokio::fs::write(&path, &secret).await?; + } + Ok(secret) + } + + pub fn file_path(&self, id: &str) -> PathBuf { + self.files_dir.join(id) + } + + pub fn part_path(&self, id: &str) -> PathBuf { + self.files_dir.join(format!("{id}.part")) + } + + fn meta_path(&self, id: &str) -> PathBuf { + self.metadata_dir.join(format!("{id}.json")) + } + + pub async fn storage_usage_ratio(&self) -> f64 { + match nix::sys::statvfs::statvfs(&self.root) { + Ok(stats) => { + let total = stats.blocks() * stats.fragment_size(); + let avail = stats.blocks_available() * stats.fragment_size(); + if total == 0 { + return 0.0; + } + let ratio = 1.0 - (avail as f64 / total as f64); + ratio.clamp(0.0, 1.0) + } + Err(_) => 0.0, + } + } + + pub async fn write_metadata(&self, meta: &FileMeta) -> std::io::Result<()> { + let target = self.meta_path(&meta.id); + let tmp = self.metadata_dir.join(format!("{}.json.tmp", meta.id)); + let body = serde_json::to_string_pretty(meta) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + tokio::fs::write(&tmp, format!("{body}\n")).await?; + tokio::fs::rename(&tmp, &target).await + } + + pub async fn read_metadata(&self, id: &str) -> Option { + if !is_valid_key(id) { + return None; + } + let raw = tokio::fs::read_to_string(self.meta_path(id)).await.ok()?; + let meta: FileMeta = serde_json::from_str(&raw).ok()?; + if meta.id.is_empty() || meta.download_key_hash.is_empty() { + return None; + } + Some(meta) + } + + pub async fn delete(&self, id: &str) { + let _ = tokio::fs::remove_file(self.file_path(id)).await; + let _ = tokio::fs::remove_file(self.meta_path(id)).await; + } + + pub async fn delete_partial(&self, id: &str) { + let _ = tokio::fs::remove_file(self.part_path(id)).await; + } + + pub async fn expire_expired(&self, now_ms: i64) -> usize { + let mut removed = 0; + let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await else { + return 0; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") { + continue; + } + let id = &name[..name.len() - 5]; + if !is_valid_key(id) { + continue; + } + if let Some(meta) = self.read_metadata(id).await { + if is_expired(&meta, now_ms) { + self.delete(id).await; + removed += 1; + } + } + } + removed + } + + /// Consistency repair: remove orphans (file without metadata, metadata + /// without file, leftover `.part` files). Never touches valid pairs. + pub async fn sweep(&self) -> (usize, usize) { + let mut removed_files = 0; + let mut removed_metadata = 0; + + if let Ok(mut entries) = tokio::fs::read_dir(&self.files_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy().to_string(); + if name.ends_with(".part") || !is_valid_key(&name) { + let _ = tokio::fs::remove_file(entry.path()).await; + removed_files += 1; + continue; + } + if tokio::fs::metadata(self.metadata_dir.join(format!("{name}.json"))) + .await + .is_err() + { + let _ = tokio::fs::remove_file(entry.path()).await; + removed_files += 1; + } + } + } + + if let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy().to_string(); + if !name.ends_with(".json") { + let _ = tokio::fs::remove_file(entry.path()).await; + removed_metadata += 1; + continue; + } + let id = &name[..name.len() - 5]; + if !is_valid_key(id) { + let _ = tokio::fs::remove_file(entry.path()).await; + removed_metadata += 1; + continue; + } + if tokio::fs::metadata(self.files_dir.join(id)).await.is_err() { + let _ = tokio::fs::remove_file(entry.path()).await; + removed_metadata += 1; + } + } + } + + (removed_files, removed_metadata) + } +} diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 811b1a2..0000000 --- a/bun.lock +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 index 62020d2..7f7a5b5 100644 --- a/deploy/filehost.service +++ b/deploy/filehost.service @@ -9,7 +9,7 @@ User=filehost Group=filehost WorkingDirectory=/opt/filehost EnvironmentFile=/opt/filehost/.env -ExecStart=/usr/local/bin/bun run src/server.ts +ExecStart=/opt/filehost/filehost Restart=on-failure RestartSec=3 TimeoutStopSec=30 @@ -22,4 +22,4 @@ ProtectHome=true ReadWritePaths=/opt/filehost/storage [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..1864f28 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,330 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "filehost-frontend", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^4.2.19", + "tailwindcss": "^3.4.17", + "vite": "^5.4.11", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.5", "", { "os": "android", "cpu": "arm" }, "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.5", "", { "os": "android", "cpu": "arm64" }, "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.5", "", { "os": "none", "cpu": "arm64" }, "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@3.1.2", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.10", "svelte-hmr": "^0.16.0", "vitefu": "^0.2.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@2.1.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.414", "", {}, "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "read-cache": ["read-cache@1.0.2", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.62.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.5", "@rollup/rollup-android-arm64": "4.62.5", "@rollup/rollup-darwin-arm64": "4.62.5", "@rollup/rollup-darwin-x64": "4.62.5", "@rollup/rollup-freebsd-arm64": "4.62.5", "@rollup/rollup-freebsd-x64": "4.62.5", "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", "@rollup/rollup-linux-arm-musleabihf": "4.62.5", "@rollup/rollup-linux-arm64-gnu": "4.62.5", "@rollup/rollup-linux-arm64-musl": "4.62.5", "@rollup/rollup-linux-loong64-gnu": "4.62.5", "@rollup/rollup-linux-loong64-musl": "4.62.5", "@rollup/rollup-linux-ppc64-gnu": "4.62.5", "@rollup/rollup-linux-ppc64-musl": "4.62.5", "@rollup/rollup-linux-riscv64-gnu": "4.62.5", "@rollup/rollup-linux-riscv64-musl": "4.62.5", "@rollup/rollup-linux-s390x-gnu": "4.62.5", "@rollup/rollup-linux-x64-gnu": "4.62.5", "@rollup/rollup-linux-x64-musl": "4.62.5", "@rollup/rollup-openbsd-x64": "4.62.5", "@rollup/rollup-openharmony-arm64": "4.62.5", "@rollup/rollup-win32-arm64-msvc": "4.62.5", "@rollup/rollup-win32-ia32-msvc": "4.62.5", "@rollup/rollup-win32-x64-gnu": "4.62.5", "@rollup/rollup-win32-x64-msvc": "4.62.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="], + + "svelte-hmr": ["svelte-hmr@0.16.0", "", { "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vitefu": ["vitefu@0.2.5", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["vite"] }, "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e1a81fb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + File Drop + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f59f0d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "filehost-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^4.2.19", + "tailwindcss": "^3.4.17", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..078c1f5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..078c1f5 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..39ca20a --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,132 @@ + + +
+
+
+ File Drop +
+

File Drop

+

Private file hosting

+
+
+ New upload +
+ +
+ + + notify(e.detail.message, e.detail.kind)} /> +
+
+ + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..59240d2 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,88 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + body { + @apply bg-base text-ink font-sans; + min-height: 100vh; + background-image: + radial-gradient(1200px 600px at 15% -10%, rgba(91, 140, 255, 0.14), transparent 60%), + radial-gradient(1000px 500px at 100% 0%, rgba(91, 140, 255, 0.08), transparent 55%); + background-attachment: fixed; + } + + ::selection { + background: rgba(91, 140, 255, 0.3); + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: #232936; + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; + } + + ::-webkit-scrollbar-thumb:hover { + background: #2f3746; + background-clip: content-box; + } + + summary { + list-style: none; + } + + summary::-webkit-details-marker { + display: none; + } +} + +@layer components { + .card { + @apply rounded-2xl border border-edge bg-surface/80 backdrop-blur-sm; + } + + .btn { + @apply inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold + transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-50; + } + + .btn-primary { + @apply bg-accent text-white hover:bg-accent-strong + shadow-[0_8px_24px_-8px_rgba(91,140,255,0.7)]; + } + + .btn-ghost { + @apply bg-transparent text-muted border border-edge hover:text-ink hover:border-muted/60; + } + + .btn-danger { + @apply bg-transparent text-danger border border-danger/40 hover:bg-danger/10; + } + + .field { + @apply w-full rounded-xl border border-edge bg-base/70 px-3.5 py-3 text-sm text-ink + placeholder:text-muted/60 outline-none transition focus:border-accent focus:ring-2 + focus:ring-accent/20; + } +} + +@layer utilities { + .text-gradient { + @apply bg-gradient-to-r from-accent to-[#a78bfa] bg-clip-text text-transparent; + } +} diff --git a/frontend/src/components/Dropzone.svelte b/frontend/src/components/Dropzone.svelte new file mode 100644 index 0000000..478be9e --- /dev/null +++ b/frontend/src/components/Dropzone.svelte @@ -0,0 +1,91 @@ + + +
e.preventDefault()} + on:dragenter={onDragEnter} + on:dragleave={onDragLeave} + on:drop={onDrop} +> + { + handleFiles(e.target.files); + e.target.value = ''; + }} + /> + +
+ + + + +
+ + {#if file} +

{file.name}

+

Drop another file to replace it

+ {:else} +

Drag & drop a file here

+

or click to browse — up to 10 GB

+ {/if} +
diff --git a/frontend/src/components/FileList.svelte b/frontend/src/components/FileList.svelte new file mode 100644 index 0000000..74a7171 --- /dev/null +++ b/frontend/src/components/FileList.svelte @@ -0,0 +1,65 @@ + + +
+
+
+

Your files

+ {#if $uploads.length} + {$uploads.length} file{$uploads.length === 1 ? '' : 's'} + {/if} +
+ {#if $uploads.length} + + {/if} +
+ + {#if $uploads.length} +
+ {#each $uploads as item (item.id)} + onDelete(e.detail)} /> + {/each} +
+ {:else} +
+
+ + + +
+

No files yet.

+

Upload your first file to see it here.

+
+ {/if} +
diff --git a/frontend/src/components/FileRow.svelte b/frontend/src/components/FileRow.svelte new file mode 100644 index 0000000..7268064 --- /dev/null +++ b/frontend/src/components/FileRow.svelte @@ -0,0 +1,80 @@ + + +
+
+
+ + + + + {#if ext} + + {ext} + + {/if} +
+ +
+
+ {name} + + {item.protected ? 'Protected' : 'Public'} + +
+
{formatBytes(item.size)} · {formatDate(item.createdAt)}
+
+
+ +
+
+ Download + + +
+ +
+ + Links ▾ + +
+ +
+
+
+
diff --git a/frontend/src/components/LinkFormats.svelte b/frontend/src/components/LinkFormats.svelte new file mode 100644 index 0000000..3cecbc1 --- /dev/null +++ b/frontend/src/components/LinkFormats.svelte @@ -0,0 +1,30 @@ + + +
+ {#each formats as fmt} + + {/each} +
diff --git a/frontend/src/components/ResultPanel.svelte b/frontend/src/components/ResultPanel.svelte new file mode 100644 index 0000000..82639f1 --- /dev/null +++ b/frontend/src/components/ResultPanel.svelte @@ -0,0 +1,43 @@ + + +{#if result} +
+
+ {name} + + {result.protected ? 'Protected' : 'Public'} + +
+ +
+ + +
+ +
+

Share links

+ +
+
+{/if} diff --git a/frontend/src/components/Toast.svelte b/frontend/src/components/Toast.svelte new file mode 100644 index 0000000..bf769d4 --- /dev/null +++ b/frontend/src/components/Toast.svelte @@ -0,0 +1,41 @@ + + +{#if visible} +
+
+ {message} +
+
+{/if} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..58dcc83 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,55 @@ +export function uploadFile(file, password, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const form = new FormData(); + if (password) form.append('password', password); + form.append('file', file); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error('Invalid server response.')); + } + return; + } + 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 */ + } + reject(new Error(message)); + }); + + xhr.addEventListener('error', () => reject(new Error('Network error during upload.'))); + xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.'))); + + xhr.open('POST', '/api/upload'); + xhr.send(form); + }); +} + +export async function deleteFile(item) { + const res = await fetch(`/api/files/${item.id}`, { + method: 'DELETE', + headers: { 'X-Delete-Key': item.key } + }); + if (!res.ok) { + let message = 'Deletion failed.'; + try { + const data = await res.json(); + if (data && data.error) message = data.error; + } catch { + /* keep fallback message */ + } + throw new Error(message); + } + return res.json(); +} diff --git a/frontend/src/lib/formats.js b/frontend/src/lib/formats.js new file mode 100644 index 0000000..0a8645b --- /dev/null +++ b/frontend/src/lib/formats.js @@ -0,0 +1,101 @@ +export function downloadUrl(item) { + return new URL(item.url, window.location.origin).href; +} + +export function forcedDownloadUrl(item) { + const url = new URL(downloadUrl(item)); + url.searchParams.set('dl', '1'); + return url.href; +} + +export function displayName(item) { + return item.downloadName || item.originalName || 'file'; +} + +export function fileExtension(item) { + const name = displayName(item); + const dot = name.lastIndexOf('.'); + if (dot > 0 && dot < name.length - 1) return name.slice(dot + 1).toUpperCase(); + return ''; +} + +export function linkLabel(item) { + return item.originalName || item.downloadName || 'file'; +} + +export function buildLinkFormats(item) { + const url = downloadUrl(item); + const name = linkLabel(item); + const mime = item.mimeType || ''; + const isImage = mime.startsWith('image/') && mime !== 'image/svg+xml'; + + let html; + let bbcode; + let markdown; + if (isImage) { + html = `${name}`; + bbcode = `[img]${url}[/img]`; + markdown = `![${name}](${url})`; + } else { + html = `${name}`; + bbcode = `[url=${url}]${name}[/url]`; + markdown = `[${name}](${url})`; + } + + return [ + { label: 'Direct', value: url }, + { label: 'HTML', value: html }, + { label: 'BBCode', value: bbcode }, + { label: 'Markdown', value: markdown } + ]; +} + +export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '-'; + 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]}`; +} + +export function formatDate(iso) { + try { + return new Date(iso).toLocaleString(); + } catch { + return ''; + } +} + +export 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 && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return fallback(); + } + } + return fallback(); +} diff --git a/frontend/src/lib/store.js b/frontend/src/lib/store.js new file mode 100644 index 0000000..315c0d5 --- /dev/null +++ b/frontend/src/lib/store.js @@ -0,0 +1,50 @@ +import { writable } from 'svelte/store'; + +const STORAGE_KEY = 'filehost.uploads'; + +function load() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function persist(list) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } catch { + /* storage unavailable — ignore */ + } +} + +export const uploads = writable(load()); + +export function addUpload(record) { + uploads.update((list) => { + const idx = list.findIndex((item) => item.id === record.id); + const next = [...list]; + if (idx !== -1) next[idx] = record; + else next.unshift(record); + persist(next); + return next; + }); +} + +export function removeUpload(id) { + uploads.update((list) => { + const next = list.filter((item) => item.id !== id); + persist(next); + return next; + }); +} + +export function clearUploads() { + uploads.set([]); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..09f41e9 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,8 @@ +import './app.css'; +import App from './App.svelte'; + +const app = new App({ + target: document.getElementById('app') +}); + +export default app; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..d4f0ca1 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,31 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{svelte,js}'], + theme: { + extend: { + colors: { + base: '#0b0d12', + surface: '#12151c', + raised: '#171b24', + edge: '#232936', + ink: '#e8ecf3', + muted: '#98a1b3', + accent: '#5b8cff', + 'accent-strong': '#3f6ef0', + danger: '#ef5b5b', + ok: '#46b684' + }, + fontFamily: { + sans: [ + 'Inter', + 'system-ui', + '-apple-system', + 'Segoe UI', + 'Roboto', + 'sans-serif' + ] + } + } + }, + plugins: [] +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e38bd1b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { + proxy: { + '/api': 'http://127.0.0.1:3000' + } + } +}); diff --git a/package.json b/package.json deleted file mode 100644 index 7022b25..0000000 --- a/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/build.sh b/scripts/build.sh new file mode 100755 index 0000000..c3ac360 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Build the whole project: Svelte frontend + Rust backend. +# +# Usage: +# bash scripts/build.sh # release build +# bash scripts/build.sh --debug # debug build (faster) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROFILE="release" + +if [[ "${1:-}" == "--debug" ]]; then + PROFILE="debug" +fi + +command -v cargo >/dev/null 2>&1 || { echo "cargo (Rust) est requis." >&2; exit 1; } +command -v npm >/dev/null 2>&1 || { echo "npm (Node.js) est requis." >&2; exit 1; } + +echo "==> Frontend (Svelte)" +cd "$ROOT/frontend" +if [[ ! -d node_modules ]]; then + echo " installation des dépendances..." + npm install +fi +npm run build + +echo "==> Backend (Rust)" +cd "$ROOT/backend" +if [[ "$PROFILE" == "release" ]]; then + cargo build --release +else + cargo build +fi + +echo +echo "Build terminé." +if [[ "$PROFILE" == "release" ]]; then + echo " binaire : $ROOT/backend/target/release/filehost" +else + echo " binaire : $ROOT/backend/target/debug/filehost" +fi +echo " frontend : $ROOT/frontend/dist" diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh new file mode 100644 index 0000000..535e30f --- /dev/null +++ b/scripts/e2e-test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# End-to-end test: starts the Rust server and runs the API smoke test against it. +# +# Usage: bash scripts/e2e-test.sh (requires a built backend: cargo build in backend/) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${PORT:-3100}" +BASE_URL="http://127.0.0.1:${PORT}" +STORAGE="$(mktemp -d)" + +SERVER_PID="" +cleanup() { + if [[ -n "${SERVER_PID}" ]]; then + kill "${SERVER_PID}" 2>/dev/null || true + fi + rm -rf "${STORAGE}" +} +trap cleanup EXIT + +if [[ ! -x "${ROOT}/backend/target/debug/filehost" ]]; then + echo "backend binary not found; run 'cargo build' in backend/ first" >&2 + exit 1 +fi + +STORAGE_DIR="${STORAGE}" STATIC_DIR="${ROOT}/frontend/dist" PORT="${PORT}" \ + "${ROOT}/backend/target/debug/filehost" & +SERVER_PID=$! + +# Wait for readiness. +for _ in $(seq 1 50); do + if curl -sS -f "${BASE_URL}/api/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done + +# Static frontend checks. +INDEX_CT="$(curl -sS -o /dev/null -w '%{content_type}' "${BASE_URL}/")" +LOGO_CODE="$(curl -sS -o /dev/null -w '%{http_code}' "${BASE_URL}/logo.svg")" +FAVICON_CODE="$(curl -sS -o /dev/null -w '%{http_code}' "${BASE_URL}/favicon.svg")" +echo "static / -> ${INDEX_CT}" +echo "static /logo.svg -> ${LOGO_CODE}" +echo "static /favicon.svg -> ${FAVICON_CODE}" + +if [[ "${INDEX_CT}" != text/html* ]]; then + echo "FAIL: static / did not return HTML (got '${INDEX_CT}')" >&2 + exit 1 +fi +if [[ "${LOGO_CODE}" != "200" ]]; then + echo "FAIL: static /logo.svg returned ${LOGO_CODE}" >&2 + exit 1 +fi +if [[ "${FAVICON_CODE}" != "200" ]]; then + echo "FAIL: static /favicon.svg returned ${FAVICON_CODE}" >&2 + exit 1 +fi + +echo "running smoke test against ${BASE_URL}" +BASE_URL="${BASE_URL}" bash "${ROOT}/scripts/smoke-test.sh" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2d59a4b..920223e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -8,6 +8,10 @@ # Requires a running instance and `bun` on PATH. set -euo pipefail +# bun colorizes booleans/numbers on stdout even when piped, which breaks the +# plain-text comparisons below (e.g. `protected` == "false"). +export NO_COLOR=1 + BASE="${BASE_URL:-http://127.0.0.1:3000}" PW="hunter2" TMP="$(mktemp -d)" diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index b3a9987..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { makeAuthToken, parseAuthToken } from "./security"; - -export const AUTH_COOKIE = "fh_auth"; -export const AUTH_TTL_MS = 30 * 60 * 1000; - -export function parseCookie(header: string | null, name: string): string | null { - if (!header) return null; - for (const part of header.split(";")) { - const idx = part.indexOf("="); - if (idx === -1) continue; - if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim(); - } - return null; -} - -export function hasValidAuth(req: Request, secret: string, key: string): boolean { - const token = parseCookie(req.headers.get("cookie"), AUTH_COOKIE); - if (!token) return false; - const parsed = parseAuthToken(token, secret); - return parsed !== null && parsed.key === key; -} - -export function authCookieValue(key: string, secret: string): string { - const token = makeAuthToken(key, secret, AUTH_TTL_MS); - return `${AUTH_COOKIE}=${token}`; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export function renderPasswordForm( - key: string, - filename: string, - error: boolean, -): Response { - const message = error - ? '

Incorrect password. Please try again.

' - : ""; - const html = ` - - - - - - Protected file - - - -
- -

Protected file

-

Enter the password to download ${escapeHtml(filename)}.

- ${message} -
- - -
-
- -`; - return new Response(html, { - status: 200, - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": "private, no-store", - "x-content-type-options": "nosniff", - "referrer-policy": "no-referrer", - "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", - }, - }); -} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 7f54902..0000000 --- a/src/config.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { resolve } from "node:path"; - -export interface Config { - host: string; - port: number; - storageDir: string; - maxFileSize: number; - fileTtlMs: number; - maxStorageUsage: number; - keyLength: number; - publicBaseUrl?: string; - rateLimitEnabled: boolean; - rateLimitMax: number; - rateLimitWindowMs: number; - trustProxy: boolean; -} - -const MAX_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours hard maximum -const DEFAULT_TTL_MS = MAX_TTL_MS; - -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 ?? "10GB"); - if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB"); - - const fileTtlMsRaw = Number(env.FILE_TTL_MS ?? String(DEFAULT_TTL_MS)); - if (!Number.isInteger(fileTtlMsRaw) || fileTtlMsRaw < 1) { - throw new Error(`Invalid FILE_TTL_MS: ${env.FILE_TTL_MS}`); - } - const fileTtlMs = Math.min(fileTtlMsRaw, MAX_TTL_MS); - - const maxStorageUsage = Number(env.MAX_STORAGE_USAGE ?? "0.5"); - if (!Number.isFinite(maxStorageUsage) || maxStorageUsage < 0 || maxStorageUsage > 1) { - throw new Error(`Invalid MAX_STORAGE_USAGE: ${env.MAX_STORAGE_USAGE}`); - } - - 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}`); - } - - const trustProxy = boolFromEnv(env.TRUST_PROXY, false); - - const keyLength = Number(env.KEY_LENGTH ?? "8"); - if (!Number.isInteger(keyLength) || keyLength < 4 || keyLength > 64) { - throw new Error(`Invalid KEY_LENGTH: ${env.KEY_LENGTH}`); - } - - let publicBaseUrl: string | undefined; - const rawBase = env.PUBLIC_BASE_URL?.trim(); - if (rawBase) { - const normalized = rawBase.replace(/\/+$/, ""); - if (!/^https?:\/\/[^\s/]+$/.test(normalized)) { - throw new Error(`Invalid PUBLIC_BASE_URL: "${rawBase}"`); - } - publicBaseUrl = normalized; - } - - return { - host, - port, - storageDir, - maxFileSize, - fileTtlMs, - maxStorageUsage, - keyLength, - publicBaseUrl, - rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true), - rateLimitMax, - rateLimitWindowMs, - trustProxy, - }; -} diff --git a/src/http.ts b/src/http.ts deleted file mode 100644 index 419c16a..0000000 --- a/src/http.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index d7befb0..0000000 --- a/src/log.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 3d1536c..0000000 --- a/src/multipart.ts +++ /dev/null @@ -1,150 +0,0 @@ -const CR = 0x0d; -const LF = 0x0a; -const MAX_HEADER_LINE_BYTES = 64 * 1024; - -export interface MultipartPart { - name: string; - filename: string | null; - 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]; - } - const name = /name\s*=\s*"([^"]*)"/i.exec(disposition)?.[1] ?? ""; - - yield { - name, - filename: filename ?? null, - 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 deleted file mode 100644 index 59152ab..0000000 --- a/src/public/app.js +++ /dev/null @@ -1,502 +0,0 @@ -"use strict"; - -const $ = (id) => document.getElementById(id); - -const STORAGE_KEY = "filehost.uploads"; - -const dropzone = $("dropzone"); -const fileInput = $("fileInput"); -const chosenFile = $("chosenFile"); -const pwInput = $("pwInput"); -const uploadBtn = $("uploadBtn"); -const newUploadBtn = $("newUploadBtn"); -const clearListBtn = $("clearListBtn"); -const progress = $("progress"); -const progressBar = $("progressBar"); -const progressPct = $("progressPct"); -const progressName = $("progressName"); -const result = $("result"); -const resultUrl = $("resultUrl"); -const resultName = $("resultName"); -const resultLock = $("resultLock"); -const copyBtn = $("copyBtn"); -const linkFormats = $("linkFormats"); -const notice = $("notice"); -const fileList = $("fileList"); -const fileCount = $("fileCount"); -const emptyState = $("emptyState"); - -let currentFile = null; -let uploading = false; - -function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes < 0) return "-"; - 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; - currentFile = null; - result.classList.add("hidden"); - progress.classList.add("hidden"); - progressBar.style.width = "0%"; - progressPct.textContent = "0%"; - chosenFile.classList.add("hidden"); - chosenFile.textContent = ""; - fileInput.value = ""; - pwInput.value = ""; - uploadBtn.disabled = false; - uploadBtn.textContent = "Upload"; - clearNotice(); -} - -// ---- client-side 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 — 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; -} - -function forcedDownloadUrl(item) { - const url = new URL(downloadUrl(item)); - url.searchParams.set("dl", "1"); - return url.href; -} - -function linkName(item) { - return item.originalName || item.downloadName || "file"; -} - -function buildLinkFormats(item) { - const url = downloadUrl(item); - const name = linkName(item); - const mime = item.mimeType || ""; - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml"; - let html; - let bbcode; - let markdown; - if (isImage) { - html = `${name}`; - bbcode = `[img]${url}[/img]`; - markdown = `![${name}](${url})`; - } else { - html = `${name}`; - bbcode = `[url=${url}]${name}[/url]`; - markdown = `[${name}](${url})`; - } - return [ - { label: "Direct", value: url }, - { label: "HTML", value: html }, - { label: "BBCode", value: bbcode }, - { label: "Markdown", value: markdown }, - ]; -} - -function createLinkFormatButtons(item) { - return buildLinkFormats(item).map((fmt) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "link-fmt"; - btn.textContent = fmt.label; - btn.title = fmt.value; - btn.addEventListener("click", async () => { - const ok = await copyText(fmt.value); - setCopyFeedback(btn, ok ? "Copied" : "Failed"); - }); - return btn; - }); -} - -function renderLinkFormats(item) { - linkFormats.innerHTML = ""; - for (const btn of createLinkFormatButtons(item)) linkFormats.appendChild(btn); -} - -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 formatDate(iso) { - try { - return new Date(iso).toLocaleString(); - } catch { - return ""; - } -} - -function displayName(item) { - return item.downloadName || item.originalName || "file"; -} - -function renderList() { - const list = loadUploads(); - fileList.innerHTML = ""; - emptyState.classList.toggle("hidden", list.length > 0); - fileCount.textContent = list.length === 0 ? "" : `${list.length} file${list.length === 1 ? "" : "s"}`; - - for (const item of list) { - const tr = document.createElement("tr"); - - const nameTd = document.createElement("td"); - const nameWrap = document.createElement("div"); - nameWrap.className = "file-name"; - const name = document.createElement("span"); - const shown = displayName(item); - name.textContent = shown; - name.title = shown; - const lock = document.createElement("span"); - lock.className = "lock"; - lock.textContent = item.protected ? "Protected" : "Public"; - if (!item.protected) lock.classList.add("public"); - nameWrap.appendChild(name); - nameWrap.appendChild(lock); - nameTd.appendChild(nameWrap); - - const sizeTd = document.createElement("td"); - sizeTd.className = "file-size"; - sizeTd.textContent = formatBytes(item.size); - sizeTd.setAttribute("data-label", "Size"); - - const dateTd = document.createElement("td"); - dateTd.className = "file-date"; - dateTd.textContent = formatDate(item.createdAt); - dateTd.setAttribute("data-label", "Uploaded"); - - const actionsTd = document.createElement("td"); - const wrap = document.createElement("div"); - wrap.className = "actions-wrap"; - const actions = document.createElement("div"); - actions.className = "row-actions"; - - const dl = document.createElement("a"); - dl.className = "btn btn-primary"; - dl.href = forcedDownloadUrl(item); - dl.textContent = "Download"; - dl.title = "Force download (direct link renders inline in the browser)"; - - const copy = document.createElement("button"); - copy.type = "button"; - copy.className = "btn btn-ghost"; - copy.textContent = "Copy"; - copy.addEventListener("click", async () => { - const ok = await copyText(downloadUrl(item)); - setCopyFeedback(copy, ok ? "Copied" : "Failed"); - }); - - const del = document.createElement("button"); - del.type = "button"; - del.className = "btn btn-danger"; - del.textContent = "Delete"; - del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id))); - - actions.appendChild(dl); - actions.appendChild(copy); - actions.appendChild(del); - - const links = document.createElement("details"); - links.className = "row-links"; - const summary = document.createElement("summary"); - summary.textContent = "Links"; - const panel = document.createElement("div"); - panel.className = "row-links-panel"; - for (const btn of createLinkFormatButtons(item)) panel.appendChild(btn); - links.appendChild(summary); - links.appendChild(panel); - - wrap.appendChild(actions); - wrap.appendChild(links); - actionsTd.appendChild(wrap); - - tr.appendChild(nameTd); - tr.appendChild(sizeTd); - tr.appendChild(dateTd); - tr.appendChild(actionsTd); - fileList.appendChild(tr); - } -} - -async function deleteFile(item, onSuccess) { - const ok = window.confirm(`Delete "${displayName(item)}" 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 = displayName(data); - resultLock.textContent = data.protected ? "Protected" : "Public"; - resultLock.classList.toggle("public", !data.protected); - resultUrl.value = downloadUrl(data); - renderLinkFormats(data); - addUpload({ - id: data.id, - key: data.key, - url: data.url, - originalName: data.originalName, - downloadName: data.downloadName || data.originalName, - mimeType: data.mimeType || "", - size: data.size, - createdAt: new Date().toISOString(), - protected: !!data.protected, - }); -} - -function startUpload(file, password) { - if (uploading) return; - clearNotice(); - progress.classList.remove("hidden"); - progressName.textContent = file.name; - setProgress(0, file.size); - uploading = true; - uploadBtn.disabled = true; - uploadBtn.textContent = "Uploading\u2026"; - - const xhr = new XMLHttpRequest(); - const form = new FormData(); - if (password) form.append("password", password); - form.append("file", file); - - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) setProgress(e.loaded, e.total); - }); - - xhr.addEventListener("load", () => { - uploading = false; - uploadBtn.disabled = false; - uploadBtn.textContent = "Upload"; - if (xhr.status >= 200 && xhr.status < 300) { - progress.classList.add("hidden"); - try { - showResult(JSON.parse(xhr.responseText)); - } catch { - 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; - uploadBtn.disabled = false; - uploadBtn.textContent = "Upload"; - progress.classList.add("hidden"); - showNotice("Network error during upload.", "error"); - }); - - xhr.addEventListener("abort", () => { - uploading = false; - uploadBtn.disabled = false; - uploadBtn.textContent = "Upload"; - progress.classList.add("hidden"); - }); - - xhr.open("POST", "/api/upload"); - xhr.send(form); -} - -function selectFile(file) { - if (!file) return; - if (file.size === 0) { - showNotice("Empty files are not supported.", "error"); - return; - } - currentFile = file; - chosenFile.textContent = file.name; - chosenFile.classList.remove("hidden"); - clearNotice(); -} - -function submitUpload() { - if (!currentFile) { - showNotice("Choose a file first.", "error"); - return; - } - startUpload(currentFile, pwInput.value); -} - -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; - selectFile(file); -}); - -pwInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") submitUpload(); -}); - -uploadBtn.addEventListener("click", submitUpload); - -newUploadBtn.addEventListener("click", () => { - window.scrollTo({ top: 0, behavior: "smooth" }); - pwInput.focus(); -}); - -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; - selectFile(file); -}); - -copyBtn.addEventListener("click", async () => { - const ok = await copyText(resultUrl.value); - setCopyFeedback(copyBtn, ok ? "Copied" : "Failed"); -}); - -clearListBtn.addEventListener("click", () => { - if (loadUploads().length === 0) return; - const ok = window.confirm( - "Clear this list? This only removes the entries from THIS browser. The 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 deleted file mode 100644 index 158aa01..0000000 --- a/src/public/index.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - File Drop - - - -
-
-
- -
-

File Drop

- Private file hosting -
-
-
- - -
-
- -
- - -
-
-

Your files

- -
-

- Stored locally in this browser only. -

- -
- - - - - - - - - - -
NameSizeUploadedActions
-
- -

No files yet. Upload your first file.

-
-
-
- - - \ No newline at end of file diff --git a/src/public/style.css b/src/public/style.css deleted file mode 100644 index 1be7230..0000000 --- a/src/public/style.css +++ /dev/null @@ -1,700 +0,0 @@ -:root { - --bg: #0e1013; - --surface: #15181d; - --surface-2: #1a1e24; - --border: #262b33; - --text: #e7eaf0; - --muted: #98a1af; - --accent: #3f7bf6; - --accent-strong: #2f63d8; - --danger: #e05555; - --ok: #46b076; - --radius: 8px; -} - -* { - 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; -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -.app { - max-width: 1180px; - margin: 0 auto; - padding: 0 20px 48px; -} - -/* ---- top bar ---- */ -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 20px 0 18px; - border-bottom: 1px solid var(--border); - margin-bottom: 22px; -} - -.brand { - display: flex; - align-items: center; - gap: 12px; -} - -.logo { - width: 40px; - height: 40px; - display: block; -} - -.brand-text h1 { - margin: 0; - font-size: 18px; - font-weight: 650; - line-height: 1.1; -} - -.sub { - font-size: 12px; - color: var(--muted); -} - -.topbar-actions { - display: flex; - gap: 8px; -} - -/* ---- buttons ---- */ -.btn { - border: 1px solid transparent; - border-radius: var(--radius); - padding: 9px 14px; - font-size: 13px; - font-weight: 600; - transition: background 0.12s, border-color 0.12s, color 0.12s; -} - -.btn-primary { - background: var(--accent); - color: #fff; -} - -.btn-primary:hover { - background: var(--accent-strong); -} - -.btn-primary:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn-ghost { - background: transparent; - border-color: var(--border); - color: var(--muted); -} - -.btn-ghost:hover { - color: var(--text); - border-color: var(--muted); -} - -.btn-danger { - background: transparent; - border: 1px solid var(--danger); - color: var(--danger); -} - -.btn-danger:hover { - background: rgba(224, 85, 85, 0.1); -} - -.btn-block { - width: 100%; - margin-top: 14px; -} - -/* ---- layout ---- */ -.layout { - display: grid; - grid-template-columns: 340px 1fr; - gap: 20px; - align-items: start; -} - -.panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; -} - -.panel h2 { - margin: 0 0 16px; - font-size: 15px; - font-weight: 650; - letter-spacing: 0.2px; -} - -/* ---- upload panel ---- */ -.upload-panel { - position: sticky; - top: 20px; -} - -.dropzone { - border: 1.5px dashed var(--border); - border-radius: var(--radius); - padding: 26px 16px; - 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(63, 123, 246, 0.06); -} - -.dz-main { - margin: 0; - font-weight: 600; - font-size: 14px; -} - -.dz-sub { - margin: 6px 0 0; - color: var(--muted); - font-size: 12px; -} - -.dz-file { - margin: 12px 0 0; - font-size: 13px; - font-weight: 600; - color: var(--accent); - word-break: break-all; -} - -.field { - margin-top: 16px; -} - -.field label { - display: block; - font-size: 12px; - font-weight: 600; - color: var(--muted); - margin-bottom: 6px; - letter-spacing: 0.3px; -} - -.field label .optional { - font-weight: 500; - text-transform: lowercase; - letter-spacing: 0; - opacity: 0.75; -} - -.field input { - width: 100%; - background: var(--bg); - border: 1px solid var(--border); - color: var(--text); - border-radius: var(--radius); - padding: 11px 12px; - font-size: 14px; -} - -.field input:focus { - outline: none; - border-color: var(--accent); -} - -/* ---- progress ---- */ -.progress { - margin-top: 16px; -} - -.progress-label { - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 12px; - color: var(--muted); - margin-bottom: 8px; -} - -.progress-label span:first-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.progress-track { - height: 6px; - background: var(--surface-2); - border-radius: 99px; - overflow: hidden; -} - -.progress-bar { - height: 100%; - width: 0%; - background: var(--accent); - border-radius: 99px; - transition: width 0.15s; -} - -/* ---- result banner ---- */ -.result { - margin-top: 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface-2); - padding: 12px; -} - -.result-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.result-head span:first-child { - font-size: 13px; - font-weight: 600; - word-break: break-all; -} - -.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: var(--radius); - padding: 10px 12px; - font-size: 13px; -} - -/* ---- link formats (Catbox-style) ---- */ -.link-formats { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; -} - -.link-fmt { - border: 1px solid var(--border); - background: var(--surface); - color: var(--muted); - border-radius: var(--radius); - padding: 6px 10px; - font-size: 12px; - font-weight: 600; - transition: color 0.12s, border-color 0.12s; -} - -.link-fmt:hover { - color: var(--text); - border-color: var(--muted); -} - -/* ---- files panel ---- */ -.panel-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -.panel-head h2 { - margin-bottom: 4px; -} - -.count { - font-size: 12px; - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.files-note { - margin: 0 0 16px; - font-size: 12px; - color: var(--muted); -} - -.table-wrap { - border: 1px solid var(--border); - border-radius: var(--radius); - overflow-x: auto; -} - -.files-table { - width: 100%; - border-collapse: collapse; - min-width: 520px; -} - -.files-table th, -.files-table td { - text-align: left; - padding: 12px 14px; - border-bottom: 1px solid var(--border); - font-size: 13px; - vertical-align: middle; -} - -.files-table thead th { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.6px; - color: var(--muted); - background: var(--surface-2); - border-bottom: 1px solid var(--border); - font-weight: 600; -} - -.files-table tbody tr:last-child td { - border-bottom: none; -} - -.files-table tbody tr:hover td { - background: rgba(255, 255, 255, 0.015); -} - -.col-name { - width: 40%; -} - -.col-size { - width: 12%; -} - -.col-date { - width: 20%; -} - -.col-actions { - width: 28%; - text-align: right; -} - -.file-name { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - word-break: break-all; -} - -.lock { - flex-shrink: 0; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.4px; - color: var(--muted); - background: rgba(63, 123, 246, 0.12); - border: 1px solid rgba(63, 123, 246, 0.3); - border-radius: 99px; - padding: 2px 8px; -} - -.lock.public { - background: rgba(130, 190, 140, 0.1); - border-color: rgba(130, 190, 140, 0.3); -} - -.file-sub { - display: block; - font-size: 12px; - color: var(--muted); - font-weight: 400; - margin-top: 2px; - word-break: break-all; -} - -.file-size, -.file-date { - color: var(--muted); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.row-actions { - display: flex; - justify-content: flex-end; - gap: 6px; -} - -.row-actions .btn { - padding: 7px 10px; - font-size: 12px; -} - -.actions-wrap { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; -} - -.row-links { - font-size: 12px; - color: var(--muted); - text-align: right; -} - -.row-links summary { - display: inline-block; - list-style: none; - cursor: pointer; - font-weight: 600; - user-select: none; -} - -.row-links summary::-webkit-details-marker { - display: none; -} - -.row-links summary::before { - content: "▸ "; -} - -.row-links[open] summary::before { - content: "▾ "; -} - -.row-links summary:hover { - color: var(--text); -} - -.row-links-panel { - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: flex-end; - margin-top: 6px; -} - -.empty { - text-align: center; - color: var(--muted); - font-size: 13px; - padding: 28px 0 6px; - margin: 0; -} - -/* ---- notice ---- */ -#notice { - margin-top: 14px; - font-size: 13px; - border-radius: var(--radius); - padding: 10px 12px; -} - -#notice.error { - color: var(--danger); - background: rgba(224, 85, 85, 0.08); - border: 1px solid rgba(224, 85, 85, 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; -} - -/* ---- responsive ---- */ -@media (max-width: 860px) { - .layout { - grid-template-columns: 1fr; - } - - .upload-panel { - position: static; - } -} - -@media (max-width: 640px) { - .app { - padding: 0 14px 36px; - } - - .topbar { - padding: 14px 0 12px; - margin-bottom: 16px; - } - - .logo { - width: 34px; - height: 34px; - } - - .brand { - gap: 10px; - min-width: 0; - } - - .brand-text h1 { - font-size: 16px; - } - - .sub { - font-size: 11px; - } - - .topbar-actions { - gap: 6px; - } - - .topbar-actions .btn { - padding: 8px 10px; - font-size: 12px; - } - - .panel { - padding: 16px; - } - - .dropzone { - padding: 34px 16px; - } - - .btn-block { - padding: 13px; - } - - /* 16px inputs prevent iOS auto-zoom on focus */ - .field input, - .url-row input { - font-size: 16px; - } - - /* files table -> stacked cards */ - .table-wrap { - overflow: visible; - border: none; - border-radius: 0; - background: transparent; - } - - .files-table { - display: block; - min-width: 0; - } - - .files-table thead { - display: none; - } - - .files-table tbody { - display: block; - } - - .files-table tr { - display: block; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 12px 14px; - margin-bottom: 10px; - } - - .files-table tbody tr:last-child { - margin-bottom: 0; - } - - .files-table td { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 7px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; - white-space: normal; - } - - .files-table td:last-child { - border-bottom: none; - } - - .files-table tbody tr:last-child td { - border-bottom: 1px solid var(--border); - } - - .files-table tbody tr:last-child td:last-child { - border-bottom: none; - } - - .files-table td[data-label]::before { - content: attr(data-label); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--muted); - flex-shrink: 0; - } - - .file-name { - flex-wrap: wrap; - } - - .row-actions { - justify-content: stretch; - } - - .row-actions .btn { - flex: 1; - text-align: center; - padding: 11px 8px; - font-size: 13px; - } - - .actions-wrap { - flex: 1 1 auto; - min-width: 0; - align-items: stretch; - } - - .row-links { - text-align: left; - } - - .row-links-panel { - justify-content: flex-start; - } -} \ No newline at end of file diff --git a/src/ratelimit.ts b/src/ratelimit.ts deleted file mode 100644 index e0d66b9..0000000 --- a/src/ratelimit.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/auth.ts b/src/routes/auth.ts deleted file mode 100644 index c241576..0000000 --- a/src/routes/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AUTH_COOKIE, AUTH_TTL_MS } from "../auth"; -import { notFound } from "../http"; -import { log } from "../log"; -import { fileIdFromKey, KEY_PATTERN, makeAuthToken, passwordMatches } from "../security"; -import type { Storage } from "../storage"; - -export async function handleAuth( - req: Request, - storage: Storage, - rawKey: string, - secret: string, - secure: boolean, -): 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 (!meta.passwordHash) { - return new Response(null, { status: 302, headers: { location: `/${rawKey}` } }); - } - - const password = await req.formData().then( - (form) => { - const value = form.get("password"); - return typeof value === "string" ? value : ""; - }, - () => "", - ); - - if (!passwordMatches(meta.passwordHash, password)) { - log("warn", `auth failed id=${id}`); - return new Response(null, { status: 302, headers: { location: `/${rawKey}?e=1` } }); - } - - const token = makeAuthToken(rawKey, secret, AUTH_TTL_MS); - const cookie = `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${ - secure ? "; Secure" : "" - }; Max-Age=${Math.floor(AUTH_TTL_MS / 1000)}`; - - log("info", `auth ok id=${id}`); - return new Response(null, { status: 302, headers: { location: `/${rawKey}`, "set-cookie": cookie } }); -} \ No newline at end of file diff --git a/src/routes/delete.ts b/src/routes/delete.ts deleted file mode 100644 index d1aec25..0000000 --- a/src/routes/delete.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index b1a7b0b..0000000 --- a/src/routes/download.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { lstat } from "node:fs/promises"; -import { hasValidAuth, renderPasswordForm } from "../auth"; -import { notFound } from "../http"; -import { log } from "../log"; -import { contentDisposition, downloadFileName, keyFromPath, secretMatches, shouldInline } from "../security"; -import { isExpired, type Storage } from "../storage"; - -export async function handleDownload( - req: Request, - storage: Storage, - rawKey: string, - secret: string, -): Promise { - const id = keyFromPath(rawKey); - if (!id) return notFound(); - - const meta = await storage.readMetadata(id); - if (!meta) return notFound(); - if (isExpired(meta)) { - await storage.delete(id); - log("info", `download rejected id=${id} expired`); - return notFound(); - } - if (!secretMatches(meta.downloadKeyHash, id)) return notFound(); - - if (meta.passwordHash && !hasValidAuth(req, secret, id)) { - const url = new URL(req.url); - return renderPasswordForm(id, meta.originalName, url.searchParams.has("e")); - } - - 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( - downloadFileName(id, meta.originalName), - new URL(req.url).searchParams.get("dl") === "1" || !shouldInline(meta.mimeType) - ? "attachment" - : "inline", - ), - "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 deleted file mode 100644 index 743bb82..0000000 --- a/src/routes/upload.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { json, jsonError } from "../http"; -import { log } from "../log"; -import { getBoundary, MultipartError, multipartParts, type MultipartPart } from "../multipart"; -import { - downloadFileName, - hashPassword, - hashSecret, - newFileId, - sanitizeMime, - sanitizeOriginalName, -} from "../security"; -import { UploadTooLargeError, type Storage } from "../storage"; - -const MAX_PASSWORD_LENGTH = 256; - -async function readField(part: MultipartPart, limit: number): Promise { - let value = ""; - for await (const chunk of part.chunks) { - value += Buffer.from(chunk).toString("utf8"); - if (value.length > limit) throw new MultipartError("field too long"); - } - return value; -} - -export async function handleUpload( - req: Request, - storage: Storage, - maxFileSize: number, - ttlMs: number, - publicBaseUrl?: string, - maxStorageUsage = 0.5, -): 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; - - try { - const usage = await storage.storageUsageRatio(); - if (usage > maxStorageUsage) { - log( - "warn", - `upload rejected id=${id}: storage usage ${(usage * 100).toFixed(1)}% exceeds limit ${(maxStorageUsage * 100).toFixed(0)}%`, - ); - return jsonError(503, "storage is full, try again later"); - } - - let filename = ""; - let mime = "application/octet-stream"; - let size = 0; - let hasFile = false; - let password = ""; - - for await (const part of multipartParts(req.body, boundary)) { - if (part.filename != null) { - if (!hasFile) { - hasFile = true; - filename = part.filename; - mime = sanitizeMime(part.contentType); - size = await storage.saveFile(id, part.chunks, maxFileSize); - } else { - for await (const _ of part.chunks) { - void _; - } - } - } else if (part.name === "password" && password === "") { - password = await readField(part, MAX_PASSWORD_LENGTH); - } else { - for await (const _ of part.chunks) { - void _; - } - } - } - - if (!hasFile) return jsonError(400, "no file part in the upload"); - - const originalName = sanitizeOriginalName(filename); - const passwordHash = password.trim() ? hashPassword(password) : undefined; - await storage.writeMetadata({ - id, - originalName, - size, - mimeType: mime, - downloadKeyHash: hashSecret(key), - passwordHash, - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + ttlMs).toISOString(), - }); - - const downloadName = downloadFileName(key, originalName); - const url = publicBaseUrl ? `${publicBaseUrl}/${downloadName}` : `/${downloadName}`; - log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); - return json({ - id, - key, - url, - originalName, - downloadName, - size, - mimeType: mime, - protected: !!passwordHash, - }); - } 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 deleted file mode 100644 index 3a25898..0000000 --- a/src/security.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; - -export let KEY_LENGTH = 8; - -// Keys are lowercase alphanumeric only (a-z0-9). -export let KEY_PATTERN: RegExp = /^[a-z0-9]{8}$/; -// The id and the download key are the same token. -export let ID_PATTERN = KEY_PATTERN; - -export function setKeyLength(length: number): void { - KEY_LENGTH = length; - KEY_PATTERN = new RegExp(`^[a-z0-9]{${length}}$`); - ID_PATTERN = KEY_PATTERN; -} - -const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"; -const CHARSET_SIZE = KEY_CHARSET.length; -const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE; - -export function newFileId(): string { - let out = ""; - const bytes = randomBytes(KEY_LENGTH * 2); - let i = 0; - while (out.length < KEY_LENGTH) { - const b = bytes[i++]; - if (b === undefined) throw new Error("random source exhausted"); - if (b >= MAX_BYTE) continue; - out += KEY_CHARSET[b % CHARSET_SIZE]; - } - return out; -} - -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; -} - -// Accepts a bare key ("abc12345") or a key with a cosmetic file extension -// ("abc12345.png", Catbox-style). The key is everything before the first dot; -// the extension is ignored for lookup and never touches the filesystem. -export function keyFromPath(segment: string): string | null { - if (!segment || segment.includes("/")) return null; - const key = segment.split(".")[0]!; - return KEY_PATTERN.test(key) ? key : null; -} - -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, - disposition: "inline" | "attachment" = "attachment", -): string { - const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); - const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => - "%" + c.charCodeAt(0).toString(16).toUpperCase(), - ); - return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`; -} - -// Whether a file should be rendered inline in the browser (like a direct -// Catbox link) rather than forced as a download. Restricted to media types -// that cannot execute scripts, so the XSS protection stays intact: HTML, -// SVG, JS, and other active/unknown types are always downloaded. -export function shouldInline(mimeType: string): boolean { - const mime = (mimeType || "").split(";")[0]!.trim().toLowerCase(); - if (!mime || mime === "application/octet-stream") return false; - if (mime === "image/svg+xml" || mime === "text/html" || mime === "application/xhtml+xml") { - return false; - } - return ( - mime.startsWith("image/") || - mime.startsWith("video/") || - mime.startsWith("audio/") || - mime === "text/plain" || - mime === "application/pdf" - ); -} - -export function downloadFileName(key: string, originalName: string): string { - const dot = originalName.lastIndexOf("."); - const ext = dot > 0 && dot < originalName.length - 1 ? originalName.slice(dot + 1) : ""; - return ext ? `${key}.${ext}` : key; -} - -export function hashPassword(password: string): string { - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync(password, salt, 64).toString("hex"); - return `scrypt:${salt}:${hash}`; -} - -export function passwordMatches(stored: string, password: string): boolean { - const parts = stored.split(":"); - if (parts.length !== 3 || parts[0] !== "scrypt") return false; - const salt = parts[1]!; - const expected = Buffer.from(parts[2]!, "hex"); - const computed = scryptSync(password, salt, 64); - if (expected.length !== computed.length) return false; - return timingSafeEqual(expected, computed); -} - -export function safeStringEqual(a: string, b: string): boolean { - const ab = Buffer.from(a); - const bb = Buffer.from(b); - if (ab.length !== bb.length) return false; - return timingSafeEqual(ab, bb); -} - -function hmacSign(payload: string, secret: string): string { - return createHmac("sha256", secret).update(payload).digest("base64url"); -} - -export function makeAuthToken(key: string, secret: string, ttlMs: number): string { - const expires = Date.now() + ttlMs; - const payload = `${key}.${expires}`; - return `${payload}.${hmacSign(payload, secret)}`; -} - -export function parseAuthToken(token: string, secret: string): { key: string } | null { - const lastDot = token.lastIndexOf("."); - if (lastDot <= 0) return null; - const payload = token.slice(0, lastDot); - const signature = token.slice(lastDot + 1); - if (!safeStringEqual(signature, hmacSign(payload, secret))) return null; - const dot = payload.lastIndexOf("."); - if (dot <= 0) return null; - const key = payload.slice(0, dot); - const expires = Number(payload.slice(dot + 1)); - if (!Number.isFinite(expires) || expires <= Date.now()) return null; - if (!KEY_PATTERN.test(key)) return null; - return { key }; -} diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index e69135e..0000000 --- a/src/server.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 { handleAuth } from "./routes/auth"; -import { handleDelete } from "./routes/delete"; -import { handleDownload } from "./routes/download"; -import { handleUpload } from "./routes/upload"; -import { keyFromPath, setKeyLength } from "./security"; -import { Storage } from "./storage"; - -const cfg = loadConfig(); -setKeyLength(cfg.keyLength); - -const storage = new Storage(cfg.storageDir); -await storage.init(); -const secret = await storage.loadSecret(); -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 expiryTimer = setInterval(() => { - storage - .expireExpired() - .then((removed) => { - if (removed > 0) log("info", `expiry sweep removed ${removed} file(s)`); - }) - .catch((err) => { - log("error", `expiry sweep failed: ${err instanceof Error ? err.message : String(err)}`); - }); -}, 60 * 60 * 1000); - -const STATIC_ROUTES: Record = { - "/": { file: "./public/index.html", type: "text/html; charset=utf-8" }, - "/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" }, - "/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" }, - "/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" }, - "/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" }, -}; - -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(), - }, - }); -} - -function isSecure(req: Request, url: URL): boolean { - if (cfg.trustProxy) return req.headers.get("x-forwarded-proto") === "https"; - return url.protocol === "https:"; -} - -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, cfg.fileTtlMs, cfg.publicBaseUrl, cfg.maxStorageUsage); - } - - if (path.startsWith("/api/auth/") && method === "POST") { - if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { - return jsonError(429, "too many requests"); - } - return handleAuth(req, storage, path.slice("/api/auth/".length), secret, isSecure(req, url)); - } - - if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) { - const key = keyFromPath(path.slice(3)); - if (!key) return notFound(); - if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { - return jsonError(429, "too many requests"); - } - return handleDownload(req, storage, key, secret); - } - - const rootKey = path.slice(1); - if (rootKey && !rootKey.includes("/") && (method === "GET" || method === "HEAD")) { - const key = keyFromPath(rootKey); - if (!key) return notFound(); - if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { - return jsonError(429, "too many requests"); - } - return handleDownload(req, storage, key, secret); - } - - 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`); - clearInterval(expiryTimer); - 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 deleted file mode 100644 index d94069d..0000000 --- a/src/storage.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { mkdir, readdir, readFile, rename, rm, stat, statfs, writeFile } from "node:fs/promises"; -import { randomBytes } from "node:crypto"; -import { join } from "node:path"; -import { ID_PATTERN } from "./security"; - -export interface FileMeta { - id: string; - originalName: string; - size: number; - mimeType: string; - downloadKeyHash: string; - passwordHash?: string; - createdAt: string; - expiresAt?: string; -} - -export function isExpired(meta: FileMeta, now: number = Date.now()): boolean { - if (!meta.expiresAt) return false; - const expires = Date.parse(meta.expiresAt); - return Number.isFinite(expires) && expires <= now; -} - -export class UploadTooLargeError extends Error { - 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 }); - } - - async loadSecret(): Promise { - const path = join(this.root, ".secret"); - try { - const existing = (await readFile(path, "utf8")).trim(); - if (existing) return existing; - } catch { - /* missing or unreadable: create below */ - } - const secret = randomBytes(32).toString("base64url"); - await writeFile(path, secret, { encoding: "utf8", mode: 0o600 }); - return secret; - } - - filePath(id: string): string { - if (!ID_PATTERN.test(id)) throw new Error("invalid file id"); - return join(this.filesDir, id); - } - - async storageUsageRatio(): Promise { - try { - const stats = await statfs(this.root); - const total = stats.blocks * stats.bsize; - const avail = stats.bavail * stats.bsize; - if (total <= 0) return 0; - return Math.min(1, Math.max(0, 1 - avail / total)); - } catch { - return 0; - } - } - - 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 expireExpired(now: number = Date.now()): Promise { - let removed = 0; - for (const entry of await readdir(this.metadataDir)) { - if (!entry.endsWith(".json")) continue; - const id = entry.slice(0, -".json".length); - if (!ID_PATTERN.test(id)) continue; - const meta = await this.readMetadata(id); - if (meta && isExpired(meta, now)) { - await this.delete(id); - removed++; - } - } - return removed; - } - - async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> { - let removedFiles = 0; - let removedMetadata = 0; - - 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 deleted file mode 100644 index 5cd1eae..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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"] -}