Part O
This commit is contained in:
@@ -1,439 +1,84 @@
|
|||||||
# File Drop (VYBECODED)
|
# File Drop
|
||||||
|
|
||||||
A minimal, self-hosted private file hosting service built with **Bun** and
|
Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier,
|
||||||
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
|
on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent
|
||||||
stored on the local filesystem and expire after 48 hours.
|
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
|
- **Backend** — Rust (Axum + Tokio)
|
||||||
database, no object storage, and no queue. Expired files are swept automatically.
|
- **Frontend** — Svelte (Vite + Tailwind)
|
||||||
|
- **Stockage** — système de fichiers local, sans base de données
|
||||||
|
|
||||||
## Features
|
## Démarrage rapide
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```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
|
Ouvrir ensuite http://localhost:3000.
|
||||||
(`typescript`, `@types/bun`) used for type-checking.
|
|
||||||
|
> 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
|
## Configuration
|
||||||
|
|
||||||
All configuration is via environment variables. Bun auto-loads `.env`, so you
|
Copier `backend/.env.example` vers `.env` puis l'adapter.
|
||||||
can copy the example and edit it:
|
|
||||||
|
|
||||||
```bash
|
| Variable | Défaut | Rôle |
|
||||||
cp .env.example .env
|
| ---------------------- | ----------------- | ----------------------------------------- |
|
||||||
```
|
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. |
|
||||||
|
| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. |
|
||||||
| Variable | Default | Description |
|
| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. |
|
||||||
| ---------------------- | ------------- | ----------------------------------------------------------------- |
|
| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. |
|
||||||
| `HOST` | `0.0.0.0` | Bind address. |
|
| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). |
|
||||||
| `PORT` | `3000` | Bind port. |
|
| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). |
|
||||||
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). |
|
| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. |
|
||||||
| `MAX_FILE_SIZE` | `10GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). |
|
| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. |
|
||||||
| `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. |
|
| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. |
|
||||||
| `FILE_TTL_MS` | `172800000` | Milliseconds before an upload expires and is deleted (48 hours, hard maximum). |
|
| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. |
|
||||||
| `KEY_LENGTH` | `8` | Length in characters of the upload/download key token (a-z0-9). |
|
| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). |
|
||||||
| `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/
|
|
||||||
│ ├── <file-id> # the raw uploaded bytes
|
|
||||||
│ └── <file-id>
|
|
||||||
└── metadata/
|
|
||||||
├── <file-id>.json # metadata for each file
|
|
||||||
└── <file-id>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Example metadata record (`storage/metadata/<file-id>.json`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "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 `<file-id>.part` temp file and atomically renamed
|
|
||||||
into place on completion. At startup the server sweeps for orphans (a file
|
|
||||||
without metadata or metadata without a file, plus leftover `.part` files) and
|
|
||||||
removes them. This is consistency repair, not expiration — real files are never
|
|
||||||
touched.
|
|
||||||
|
|
||||||
## API
|
## 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
|
Exemple :
|
||||||
(this is what the web UI uses). When no password is sent, the file is
|
|
||||||
downloadable by anyone with the URL.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
|
curl -F "[email protected]" http://localhost:3000/api/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
Le lien renvoyé est de la forme `http://localhost:3000/<clé>.png`. Ajouter
|
||||||
{
|
`?dl=1` force le téléchargement au lieu de l'affichage.
|
||||||
"id": "u8wcfcm7",
|
|
||||||
"key": "u8wcfcm7",
|
## Structure
|
||||||
"url": "/u8wcfcm7.zip",
|
|
||||||
"originalName": "example.zip",
|
```
|
||||||
"downloadName": "u8wcfcm7.zip",
|
backend/ serveur Rust (Axum)
|
||||||
"size": 123456,
|
frontend/ interface Svelte (Vite + Tailwind)
|
||||||
"mimeType": "application/zip",
|
scripts/ tests
|
||||||
"protected": true
|
storage/ données (généré au premier lancement)
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits
|
## Tests
|
||||||
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`:
|
|
||||||
|
|
||||||
```bash
|
```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`:
|
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.
|
||||||
```bash
|
|
||||||
curl -sS -F "[email protected]" "$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 "[email protected]" "$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 "[email protected]" "$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 (`<img>`, 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 `<upload-key>.<extension>`, 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.
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="uploadGradient" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#4F46E5"/>
|
|
||||||
<stop offset="1" stop-color="#3B82F6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
|
|
||||||
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-opacity=".14"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Tuile blanche façon macOS, parfaitement centrée sur le canvas -->
|
|
||||||
<rect x="48" y="48" width="416" height="416" rx="94"
|
|
||||||
fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"
|
|
||||||
filter="url(#tileShadow)"/>
|
|
||||||
|
|
||||||
<!-- Document (bbox 168–344 horiz. / 144–368 vert. -> centré en 256,256) -->
|
|
||||||
<path
|
|
||||||
d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z"
|
|
||||||
fill="#fff"
|
|
||||||
stroke="#E7ECF5"
|
|
||||||
stroke-width="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Coin plié -->
|
|
||||||
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
|
|
||||||
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
|
|
||||||
|
|
||||||
<!-- Flèche d'upload, centrée sur x=256 comme le document -->
|
|
||||||
<path
|
|
||||||
d="M256 300V196
|
|
||||||
M222 230l34-34 34 34
|
|
||||||
M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14"
|
|
||||||
stroke="url(#uploadGradient)"
|
|
||||||
stroke-width="30"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -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
|
||||||
Generated
+1286
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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<String>,
|
||||||
|
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<u64, String> {
|
||||||
|
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<String> {
|
||||||
|
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<Config, String> {
|
||||||
|
let port = env("PORT")
|
||||||
|
.unwrap_or_else(|| "3000".to_string())
|
||||||
|
.parse::<u16>()
|
||||||
|
.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::<i64>()
|
||||||
|
.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::<f64>()
|
||||||
|
.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::<usize>()
|
||||||
|
.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::<usize>()
|
||||||
|
.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::<u64>()
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<T: Serialize>(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()
|
||||||
|
}
|
||||||
@@ -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<Storage>,
|
||||||
|
pub secret: String,
|
||||||
|
pub limiter: Arc<RateLimiter>,
|
||||||
|
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::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.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");
|
||||||
|
}
|
||||||
@@ -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<HashMap<String, Bucket>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
axum::Form(form): axum::Form<AuthForm>,
|
||||||
|
) -> 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 {
|
||||||
|
"<p class=\"err\">Incorrect password. Please try again.</p>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let safe_filename = escape_html(filename);
|
||||||
|
let html = format!(
|
||||||
|
r#"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<title>Protected file</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0e1013; color: #e7eaf0; min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
width: 100%; max-width: 400px; background: #15181d; border: 1px solid #262b33;
|
||||||
|
border-radius: 14px; padding: 28px; box-shadow: 0 10px 40px rgba(0,0,0,.4);
|
||||||
|
}}
|
||||||
|
.brand {{ display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }}
|
||||||
|
.brand img {{ width: 34px; height: 34px; }}
|
||||||
|
.brand span {{ font-size: 15px; font-weight: 650; }}
|
||||||
|
h1 {{ margin: 0 0 6px; font-size: 20px; }}
|
||||||
|
p {{ margin: 0 0 18px; color: #98a1af; font-size: 14px; word-break: break-all; }}
|
||||||
|
.err {{ margin: 0 0 14px; color: #e05555; background: rgba(224,85,85,.08);
|
||||||
|
border: 1px solid rgba(224,85,85,.3); border-radius: 8px; padding: 10px 12px; }}
|
||||||
|
input[type="password"] {{
|
||||||
|
width: 100%; background: #0e1013; border: 1px solid #262b33; color: #e7eaf0;
|
||||||
|
border-radius: 8px; padding: 12px 13px; font-size: 14px; margin-bottom: 12px;
|
||||||
|
}}
|
||||||
|
input[type="password"]:focus {{ outline: none; border-color: #3f7bf6; }}
|
||||||
|
button {{ width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
|
||||||
|
padding: 12px 14px; font-size: 14px; font-weight: 600; background: #3f7bf6; color: #fff; }}
|
||||||
|
button:hover {{ background: #2f63d8; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/logo.svg" alt="logo" />
|
||||||
|
<span>File Drop</span>
|
||||||
|
</div>
|
||||||
|
<h1>Protected file</h1>
|
||||||
|
<p>Enter the password to download <strong>{safe_filename}</strong>.</p>
|
||||||
|
{message}
|
||||||
|
<form method="post" action="/api/auth/{key}">
|
||||||
|
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
|
||||||
|
<button type="submit">Download</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"#
|
||||||
|
);
|
||||||
|
|
||||||
|
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('\'', "'")
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<Body>,
|
||||||
|
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<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
@@ -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<AppState>) -> Response {
|
||||||
|
json(
|
||||||
|
&serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"uptimeSeconds": state.started_at.elapsed().as_secs(),
|
||||||
|
}),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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<Vec<u8>, ()> {
|
||||||
|
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, scrypt::errors::InvalidParams> {
|
||||||
|
Params::new(SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P, SCRYPT_LEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `scrypt:<salt_hex>:<hash_hex>` or `None` on parameter error.
|
||||||
|
pub fn hash_password(password: &str) -> Option<String> {
|
||||||
|
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<Sha256>;
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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::<i64>().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"
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<FileMeta> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
||||||
|
|
||||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ User=filehost
|
|||||||
Group=filehost
|
Group=filehost
|
||||||
WorkingDirectory=/opt/filehost
|
WorkingDirectory=/opt/filehost
|
||||||
EnvironmentFile=/opt/filehost/.env
|
EnvironmentFile=/opt/filehost/.env
|
||||||
ExecStart=/usr/local/bin/bun run src/server.ts
|
ExecStart=/opt/filehost/filehost
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
@@ -22,4 +22,4 @@ ProtectHome=true
|
|||||||
ReadWritePaths=/opt/filehost/storage
|
ReadWritePaths=/opt/filehost/storage
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -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/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||||
|
|
||||||
|
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||||
|
|
||||||
|
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
|
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
|
"aria-query": ["[email protected]", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"browserslist": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
|
||||||
|
|
||||||
|
"chokidar": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
|
"css-tree": ["[email protected]", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
|
||||||
|
|
||||||
|
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
|
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
|
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["[email protected]", "", {}, "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw=="],
|
||||||
|
|
||||||
|
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"esbuild": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"fast-glob": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
|
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
|
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
|
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||||
|
|
||||||
|
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
|
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
|
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
|
"locate-character": ["[email protected]", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"mdn-data": ["[email protected]", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
|
||||||
|
|
||||||
|
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
|
|
||||||
|
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
|
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
|
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||||
|
|
||||||
|
"node-releases": ["[email protected]", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
|
||||||
|
|
||||||
|
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
|
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
|
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"periscopic": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="],
|
||||||
|
|
||||||
|
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
|
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||||
|
|
||||||
|
"postcss-import": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
|
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"read-cache": ["[email protected]", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
|
||||||
|
|
||||||
|
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"resolve": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rollup": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
|
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"sucrase": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svelte": ["[email protected]", "", { "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": ["[email protected]", "", { "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
|
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"vite": ["[email protected]", "", { "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": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["vite"] }, "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q=="],
|
||||||
|
|
||||||
|
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<meta name="theme-color" content="#0b0d12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>File Drop</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="4" y1="4" x2="60" y2="60" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#5b8cff"/>
|
||||||
|
<stop offset="1" stop-color="#a78bfa"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#g)"/>
|
||||||
|
<path d="M32 42V24m0 0-6 6m6-6 6 6" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<path d="M20 42v2a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4v-2" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="4" y1="4" x2="60" y2="60" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#5b8cff"/>
|
||||||
|
<stop offset="1" stop-color="#a78bfa"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#g)"/>
|
||||||
|
<path d="M32 42V24m0 0-6 6m6-6 6 6" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<path d="M20 42v2a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4v-2" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1,132 @@
|
|||||||
|
<script>
|
||||||
|
import Dropzone from './components/Dropzone.svelte';
|
||||||
|
import ResultPanel from './components/ResultPanel.svelte';
|
||||||
|
import FileList from './components/FileList.svelte';
|
||||||
|
import Toast from './components/Toast.svelte';
|
||||||
|
import { uploadFile } from './lib/api.js';
|
||||||
|
import { addUpload } from './lib/store.js';
|
||||||
|
|
||||||
|
let currentFile = null;
|
||||||
|
let uploading = false;
|
||||||
|
let progress = 0;
|
||||||
|
let result = null;
|
||||||
|
let password = '';
|
||||||
|
let toast = { message: '', kind: 'info', id: 0 };
|
||||||
|
|
||||||
|
function notify(message, kind = 'info') {
|
||||||
|
toast = { message, kind, id: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(e) {
|
||||||
|
const file = e.detail;
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size === 0) {
|
||||||
|
notify('Empty files are not supported.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentFile = file;
|
||||||
|
notify('', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!currentFile || uploading) return;
|
||||||
|
uploading = true;
|
||||||
|
progress = 0;
|
||||||
|
result = null;
|
||||||
|
try {
|
||||||
|
const data = await uploadFile(currentFile, password, (loaded, total) => {
|
||||||
|
progress = total > 0 ? Math.round((loaded / total) * 100) : 0;
|
||||||
|
});
|
||||||
|
result = 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
|
||||||
|
});
|
||||||
|
notify('Upload complete.', 'success');
|
||||||
|
currentFile = null;
|
||||||
|
password = '';
|
||||||
|
} catch (e) {
|
||||||
|
notify(e.message || 'Upload failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
progress = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
|
||||||
|
<header class="flex items-center justify-between gap-4 border-b border-edge/60 py-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="/logo.svg" alt="File Drop" class="h-9 w-9" />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold leading-tight text-ink">File Drop</h1>
|
||||||
|
<p class="text-xs text-muted">Private file hosting</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-ghost px-4 py-2 text-xs">New upload</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mt-6 grid grid-cols-1 items-start gap-5 lg:grid-cols-[360px_1fr]">
|
||||||
|
<aside class="card p-5 lg:sticky lg:top-6">
|
||||||
|
<h2 class="mb-4 text-base font-semibold text-ink">Upload a file</h2>
|
||||||
|
|
||||||
|
<Dropzone bind:file={currentFile} disabled={uploading} on:file={onFile} />
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="pw" class="mb-1.5 block text-xs font-semibold text-muted">
|
||||||
|
Password <span class="font-normal opacity-70">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pw"
|
||||||
|
type="password"
|
||||||
|
class="field"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="Protect the download"
|
||||||
|
autocomplete="new-password"
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === 'Enter') submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if uploading}
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted">
|
||||||
|
<span class="truncate">{currentFile ? currentFile.name : 'Uploading…'}</span>
|
||||||
|
<span class="tabular-nums">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-surface">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-gradient-to-r from-accent to-[#a78bfa] transition-all duration-150"
|
||||||
|
style="width: {progress}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary mt-4 w-full py-3"
|
||||||
|
disabled={!currentFile || uploading}
|
||||||
|
on:click={submit}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<ResultPanel {result} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<FileList on:notify={(e) => notify(e.detail.message, e.detail.kind)} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toast {...toast} />
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let disabled = false;
|
||||||
|
export let file = null;
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let depth = 0;
|
||||||
|
let input;
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
const f = files && files.length ? files[0] : null;
|
||||||
|
if (f) dispatch('file', f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (!disabled) input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
depth = 0;
|
||||||
|
if (disabled) return;
|
||||||
|
handleFiles(e.dataTransfer && e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) {
|
||||||
|
depth += 1;
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
if (depth === 0) dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-edge bg-surface/40 px-6 py-10 text-center transition-all duration-200 hover:border-accent/60 hover:bg-surface/70 {dragging ? 'border-accent bg-accent/10' : ''} {disabled ? 'cursor-not-allowed opacity-60' : ''}"
|
||||||
|
class:dragging
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Choose a file"
|
||||||
|
on:click={onClick}
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
on:dragover={(e) => e.preventDefault()}
|
||||||
|
on:dragenter={onDragEnter}
|
||||||
|
on:dragleave={onDragLeave}
|
||||||
|
on:drop={onDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
on:change={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-[#a78bfa]/20 text-accent transition-transform duration-200 {dragging ? 'scale-110' : 'group-hover:scale-105'}"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-7 w-7" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0L7 9m5-5l5 5" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if file}
|
||||||
|
<p class="max-w-full truncate text-sm font-semibold text-ink">{file.name}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted">Drop another file to replace it</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm font-semibold text-ink">Drag & drop a file here</p>
|
||||||
|
<p class="mt-1 text-xs text-muted">or click to browse — up to 10 GB</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { uploads, removeUpload, clearUploads } from '../lib/store.js';
|
||||||
|
import { deleteFile } from '../lib/api.js';
|
||||||
|
import FileRow from './FileRow.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
async function onDelete(item) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Delete "${item.originalName || item.downloadName}" permanently? This cannot be undone.`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await deleteFile(item);
|
||||||
|
removeUpload(item.id);
|
||||||
|
dispatch('notify', { message: 'File deleted.', kind: 'success' });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch('notify', { message: e.message || 'Deletion failed.', kind: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClear() {
|
||||||
|
if ($uploads.length === 0) return;
|
||||||
|
const ok = window.confirm(
|
||||||
|
'Clear this list? This only removes entries from THIS browser. Files on the server are not touched.'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
clearUploads();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card p-5">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<h2 class="text-base font-semibold text-ink">Your files</h2>
|
||||||
|
{#if $uploads.length}
|
||||||
|
<span class="text-xs text-muted">{$uploads.length} file{$uploads.length === 1 ? '' : 's'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if $uploads.length}
|
||||||
|
<button type="button" class="text-xs font-semibold text-muted transition hover:text-danger" on:click={onClear}>
|
||||||
|
Clear list
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $uploads.length}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each $uploads as item (item.id)}
|
||||||
|
<FileRow {item} on:delete={(e) => onDelete(e.detail)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center py-14 text-center">
|
||||||
|
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-surface">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6 text-muted/60" stroke="currentColor" stroke-width="1.6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2M4 7h16M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted">No files yet.</p>
|
||||||
|
<p class="mt-1 text-xs text-muted/70">Upload your first file to see it here.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import {
|
||||||
|
forcedDownloadUrl,
|
||||||
|
downloadUrl,
|
||||||
|
displayName,
|
||||||
|
fileExtension,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
copyText
|
||||||
|
} from '../lib/formats.js';
|
||||||
|
import LinkFormats from './LinkFormats.svelte';
|
||||||
|
|
||||||
|
export let item;
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
$: name = displayName(item);
|
||||||
|
$: ext = fileExtension(item);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await copyText(downloadUrl(item));
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent/20 to-[#a78bfa]/20"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-accent" stroke="currentColor" stroke-width="1.7">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l4 4v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 3v4h4" />
|
||||||
|
</svg>
|
||||||
|
{#if ext}
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-1.5 -right-1.5 max-w-[40px] truncate rounded-md border border-edge bg-raised px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-muted"
|
||||||
|
>
|
||||||
|
{ext}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate text-sm font-semibold text-ink" title={name}>{name}</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider {item.protected ? 'border-accent/40 bg-accent/10 text-accent' : 'border-ok/40 bg-ok/10 text-ok'}"
|
||||||
|
>
|
||||||
|
{item.protected ? 'Protected' : 'Public'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-muted">{formatBytes(item.size)} · {formatDate(item.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:items-end">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-2 text-xs">Download</a>
|
||||||
|
<button type="button" class="btn btn-ghost px-3 py-2 text-xs" on:click={copy}>
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger px-3 py-2 text-xs" on:click={() => dispatch('delete', item)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer select-none text-xs font-semibold text-muted transition hover:text-ink">
|
||||||
|
Links ▾
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 md:flex md:justify-end">
|
||||||
|
<LinkFormats {item} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script>
|
||||||
|
import { buildLinkFormats, copyText } from '../lib/formats.js';
|
||||||
|
|
||||||
|
export let item;
|
||||||
|
export let size = 'sm'; // 'sm' | 'md'
|
||||||
|
|
||||||
|
let copied = null;
|
||||||
|
$: formats = buildLinkFormats(item);
|
||||||
|
|
||||||
|
async function copy(fmt) {
|
||||||
|
await copyText(fmt.value);
|
||||||
|
copied = fmt.label;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copied === fmt.label) copied = null;
|
||||||
|
}, 1400);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each formats as fmt}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={fmt.value}
|
||||||
|
class="rounded-lg border border-edge bg-surface px-2.5 font-semibold text-muted transition hover:border-muted/60 hover:text-ink {size === 'sm' ? 'py-1.5 text-[11px]' : 'py-2 text-xs'}"
|
||||||
|
on:click={() => copy(fmt)}
|
||||||
|
>
|
||||||
|
{copied === fmt.label ? 'Copied' : fmt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script>
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { downloadUrl, displayName, copyText } from '../lib/formats.js';
|
||||||
|
import LinkFormats from './LinkFormats.svelte';
|
||||||
|
|
||||||
|
export let result;
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
$: url = result ? downloadUrl(result) : '';
|
||||||
|
$: name = result ? displayName(result) : '';
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await copyText(url);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<div class="card p-4" transition:fade={{ duration: 180 }}>
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span class="truncate text-sm font-semibold text-ink" title={name}>{name}</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {result.protected ? 'border-accent/40 bg-accent/10 text-accent' : 'border-ok/40 bg-ok/10 text-ok'}"
|
||||||
|
>
|
||||||
|
{result.protected ? 'Protected' : 'Public'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="field !py-2.5 text-xs" type="text" readonly value={url} aria-label="Download URL" />
|
||||||
|
<button type="button" class="btn btn-primary px-4" on:click={copy}>
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted">Share links</p>
|
||||||
|
<LinkFormats item={result} size="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script>
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
export let message = '';
|
||||||
|
export let kind = 'info';
|
||||||
|
export let id = 0;
|
||||||
|
|
||||||
|
let visible = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
visible = !!message;
|
||||||
|
if (message) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
}, 4200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: message, id, reset();
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="fixed bottom-5 left-1/2 z-50 -translate-x-1/2 px-4" transition:fly={{ y: 12, duration: 180 }}>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-md {kind === 'error'
|
||||||
|
? 'border-danger/40 bg-danger/15 text-danger'
|
||||||
|
: kind === 'success'
|
||||||
|
? 'border-ok/40 bg-ok/15 text-ok'
|
||||||
|
: 'border-edge bg-raised/95 text-ink'}"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 = `<img src="${url}" alt="${name}">`;
|
||||||
|
bbcode = `[img]${url}[/img]`;
|
||||||
|
markdown = ``;
|
||||||
|
} else {
|
||||||
|
html = `<a href="${url}" target="_blank" rel="noopener">${name}</a>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import './app.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app')
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -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: []
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Executable
+42
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
# Requires a running instance and `bun` on PATH.
|
# Requires a running instance and `bun` on PATH.
|
||||||
set -euo pipefail
|
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}"
|
BASE="${BASE_URL:-http://127.0.0.1:3000}"
|
||||||
PW="hunter2"
|
PW="hunter2"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
|
|||||||
-112
@@ -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, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderPasswordForm(
|
|
||||||
key: string,
|
|
||||||
filename: string,
|
|
||||||
error: boolean,
|
|
||||||
): Response {
|
|
||||||
const message = error
|
|
||||||
? '<p class="err">Incorrect password. Please try again.</p>'
|
|
||||||
: "";
|
|
||||||
const html = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="referrer" content="no-referrer" />
|
|
||||||
<title>Protected file</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: #0f1115; color: #e6e8ee; min-height: 100vh;
|
|
||||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a;
|
|
||||||
border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
|
||||||
}
|
|
||||||
.brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; }
|
|
||||||
h1 { margin: 0 0 4px; font-size: 19px; }
|
|
||||||
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; }
|
|
||||||
.err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
|
|
||||||
border: 1px solid rgba(224,82,82,.3); border-radius: 8px; padding: 9px 12px; }
|
|
||||||
input[type="password"] {
|
|
||||||
width: 100%; background: #0f1115; border: 1px solid #2a2f3a; color: #e6e8ee;
|
|
||||||
border-radius: 8px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
button { width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
|
|
||||||
padding: 11px 14px; font-size: 14px; background: #4f8cff; color: #fff; }
|
|
||||||
button:hover { background: #3a74e0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="card">
|
|
||||||
<svg class="brand-logo" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="uG" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#4F46E5"/>
|
|
||||||
<stop offset="1" stop-color="#3B82F6"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="48" y="48" width="416" height="416" rx="94" fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"/>
|
|
||||||
<path d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z" fill="#fff" stroke="#E7ECF5" stroke-width="4"/>
|
|
||||||
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
|
|
||||||
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
|
|
||||||
<path d="M256 300V196 M222 230l34-34 34 34 M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14" stroke="url(#uG)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<h1>Protected file</h1>
|
|
||||||
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
|
|
||||||
${message}
|
|
||||||
<form method="post" action="/api/auth/${key}">
|
|
||||||
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
|
|
||||||
<button type="submit">Download</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
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'",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
-113
@@ -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<string, number> = {
|
|
||||||
B: 1,
|
|
||||||
K: 1 << 10,
|
|
||||||
KB: 1 << 10,
|
|
||||||
KIB: 1 << 10,
|
|
||||||
M: 1 << 20,
|
|
||||||
MB: 1 << 20,
|
|
||||||
MIB: 1 << 20,
|
|
||||||
G: 1 << 30,
|
|
||||||
GB: 1 << 30,
|
|
||||||
GIB: 1 << 30,
|
|
||||||
T: 1 << 40,
|
|
||||||
TB: 1 << 40,
|
|
||||||
TIB: 1 << 40,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseSize(value: string): number {
|
|
||||||
const match = /^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$/.exec(value);
|
|
||||||
if (!match) throw new Error(`Invalid size value: "${value}"`);
|
|
||||||
const num = parseFloat(match[1]!);
|
|
||||||
const unit = (match[2] || "B").toUpperCase();
|
|
||||||
const factor = SIZE_UNITS[unit];
|
|
||||||
if (factor === undefined) throw new Error(`Invalid size unit in: "${value}"`);
|
|
||||||
return Math.floor(num * factor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function boolFromEnv(value: string | undefined, fallback: boolean): boolean {
|
|
||||||
if (value === undefined) return fallback;
|
|
||||||
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
|
||||||
const port = Number(env.PORT ?? "3000");
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
throw new Error(`Invalid PORT: ${env.PORT}`);
|
|
||||||
}
|
|
||||||
const host = env.HOST ?? "0.0.0.0";
|
|
||||||
const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
|
|
||||||
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
export function securityHeaders(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
"x-content-type-options": "nosniff",
|
|
||||||
"referrer-policy": "no-referrer",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json; charset=utf-8", ...securityHeaders(), ...headers },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonError(status: number, message: string): Response {
|
|
||||||
return json({ error: message }, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notFound(): Response {
|
|
||||||
return jsonError(404, "not found");
|
|
||||||
}
|
|
||||||
-10
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Uint8Array>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MultipartError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "MultipartError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBoundary(contentType: string): string | null {
|
|
||||||
const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
|
||||||
return match?.[1] ?? match?.[2] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
||||||
const reader = stream.getReader();
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) return;
|
|
||||||
if (value && value.byteLength > 0) yield value;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* multipartParts(
|
|
||||||
body: ReadableStream<Uint8Array>,
|
|
||||||
boundary: string,
|
|
||||||
): AsyncGenerator<MultipartPart> {
|
|
||||||
const DELIMITER = Buffer.from(`--${boundary}`);
|
|
||||||
const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]);
|
|
||||||
const FINAL_MARKER = `--${boundary}--`;
|
|
||||||
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
let eof = false;
|
|
||||||
const source = streamChunks(body);
|
|
||||||
|
|
||||||
const readMore = async (): Promise<void> => {
|
|
||||||
if (eof) return;
|
|
||||||
const { done, value } = await source.next();
|
|
||||||
if (done) {
|
|
||||||
eof = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value) buffer = Buffer.concat([buffer, Buffer.from(value)]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readLine = async (): Promise<Buffer | null> => {
|
|
||||||
for (;;) {
|
|
||||||
const lf = buffer.indexOf(LF);
|
|
||||||
if (lf !== -1) {
|
|
||||||
if (lf > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long");
|
|
||||||
let line = buffer.subarray(0, lf);
|
|
||||||
buffer = buffer.subarray(lf + 1);
|
|
||||||
if (line.byteLength > 0 && line[line.byteLength - 1] === CR) {
|
|
||||||
line = line.subarray(0, line.byteLength - 1);
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
if (buffer.byteLength > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long");
|
|
||||||
if (eof) return buffer.byteLength > 0 ? buffer : null;
|
|
||||||
await readMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const contentChunks = async function* (): AsyncGenerator<Uint8Array> {
|
|
||||||
for (;;) {
|
|
||||||
const idx = buffer.indexOf(CRLF_DELIMITER);
|
|
||||||
if (idx !== -1) {
|
|
||||||
if (idx > 0) yield buffer.subarray(0, idx);
|
|
||||||
buffer = buffer.subarray(idx + 2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const keep = Math.min(CRLF_DELIMITER.byteLength - 1, buffer.byteLength);
|
|
||||||
const emit = buffer.byteLength - keep;
|
|
||||||
if (emit > 0) {
|
|
||||||
yield buffer.subarray(0, emit);
|
|
||||||
buffer = buffer.subarray(emit);
|
|
||||||
}
|
|
||||||
if (eof) {
|
|
||||||
if (buffer.byteLength > 0) yield buffer;
|
|
||||||
buffer = Buffer.alloc(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await readMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const line = await readLine();
|
|
||||||
if (line === null) throw new MultipartError("no multipart boundary found");
|
|
||||||
if (line.equals(DELIMITER)) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const headers = new Map<string, string>();
|
|
||||||
for (;;) {
|
|
||||||
const line = await readLine();
|
|
||||||
if (line === null) return;
|
|
||||||
if (line.byteLength === 0) break;
|
|
||||||
const text = line.toString("utf8");
|
|
||||||
const colon = text.indexOf(":");
|
|
||||||
if (colon > 0) {
|
|
||||||
headers.set(text.slice(0, colon).trim().toLowerCase(), text.slice(colon + 1).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposition = headers.get("content-disposition") ?? "";
|
|
||||||
let filename: string | undefined;
|
|
||||||
const star = /filename\*\s*=\s*UTF-8''([^\s;]+)/i.exec(disposition);
|
|
||||||
if (star?.[1]) {
|
|
||||||
try {
|
|
||||||
filename = decodeURIComponent(star[1]);
|
|
||||||
} catch {
|
|
||||||
filename = star[1];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition);
|
|
||||||
filename = quoted?.[1];
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = `<img src="${url}" alt="${name}">`;
|
|
||||||
bbcode = `[img]${url}[/img]`;
|
|
||||||
markdown = ``;
|
|
||||||
} else {
|
|
||||||
html = `<a href="${url}" target="_blank" rel="noopener">${name}</a>`;
|
|
||||||
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();
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="referrer" content="no-referrer" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
||||||
<title>File Drop</title>
|
|
||||||
<link rel="stylesheet" href="/style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app">
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="brand">
|
|
||||||
<img class="logo" src="/logo.svg" width="40" height="40" alt="File Drop" />
|
|
||||||
<div class="brand-text">
|
|
||||||
<h1>File Drop</h1>
|
|
||||||
<span class="sub">Private file hosting</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<button id="newUploadBtn" type="button" class="btn btn-primary">New upload</button>
|
|
||||||
<button id="clearListBtn" type="button" class="btn btn-ghost">Clear list</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
|
||||||
<aside class="panel upload-panel">
|
|
||||||
<h2>Upload a file</h2>
|
|
||||||
|
|
||||||
<div id="dropzone" class="dropzone" role="button" tabindex="0" aria-label="Choose a file">
|
|
||||||
<input type="file" id="fileInput" hidden />
|
|
||||||
<p class="dz-main">Drag & drop a file here</p>
|
|
||||||
<p class="dz-sub">or click to choose</p>
|
|
||||||
<p id="chosenFile" class="dz-file hidden"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="pwInput">Password <span class="optional">(optional)</span></label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="pwInput"
|
|
||||||
placeholder="Optional — protects the download"
|
|
||||||
autocomplete="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="progress" class="progress hidden">
|
|
||||||
<div class="progress-label">
|
|
||||||
<span id="progressName"></span>
|
|
||||||
<span id="progressPct">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-track">
|
|
||||||
<div id="progressBar" class="progress-bar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="uploadBtn" type="button" class="btn btn-primary btn-block">Upload</button>
|
|
||||||
|
|
||||||
<div id="result" class="result hidden">
|
|
||||||
<div class="result-head">
|
|
||||||
<span id="resultName"></span>
|
|
||||||
<span id="resultLock" class="lock">Protected</span>
|
|
||||||
</div>
|
|
||||||
<div class="url-row">
|
|
||||||
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
|
|
||||||
<button id="copyBtn" type="button" class="btn btn-primary">Copy</button>
|
|
||||||
</div>
|
|
||||||
<div id="linkFormats" class="link-formats"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="notice" class="hidden"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="panel files-panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>Your files</h2>
|
|
||||||
<span id="fileCount" class="count"></span>
|
|
||||||
</div>
|
|
||||||
<p class="files-note">
|
|
||||||
Stored locally in this browser only.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="files-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-name">Name</th>
|
|
||||||
<th class="col-size">Size</th>
|
|
||||||
<th class="col-date">Uploaded</th>
|
|
||||||
<th class="col-actions">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="fileList"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="emptyState" class="empty">No files yet. Upload your first file.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
interface Bucket {
|
|
||||||
count: number;
|
|
||||||
resetAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RateLimiter {
|
|
||||||
private buckets = new Map<string, Bucket>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly max: number,
|
|
||||||
private readonly windowMs: number,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
check(ip: string): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
if (this.buckets.size > 10_000) this.prune(now);
|
|
||||||
const existing = this.buckets.get(ip);
|
|
||||||
const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + this.windowMs };
|
|
||||||
if (!existing || bucket !== existing) this.buckets.set(ip, bucket);
|
|
||||||
bucket.count++;
|
|
||||||
return bucket.count <= this.max;
|
|
||||||
}
|
|
||||||
|
|
||||||
private prune(now: number): void {
|
|
||||||
for (const [ip, bucket] of this.buckets) {
|
|
||||||
if (bucket.resetAt <= now) this.buckets.delete(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Response> {
|
|
||||||
if (!KEY_PATTERN.test(rawKey)) return notFound();
|
|
||||||
const id = fileIdFromKey(rawKey);
|
|
||||||
if (!id) return notFound();
|
|
||||||
|
|
||||||
const meta = await storage.readMetadata(id);
|
|
||||||
if (!meta) return notFound();
|
|
||||||
|
|
||||||
if (!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 } });
|
|
||||||
}
|
|
||||||
@@ -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<Response> {
|
|
||||||
if (!ID_PATTERN.test(rawId)) return notFound();
|
|
||||||
|
|
||||||
const meta = await storage.readMetadata(rawId);
|
|
||||||
if (!meta) return notFound();
|
|
||||||
|
|
||||||
const key = req.headers.get("x-delete-key");
|
|
||||||
if (!key || !KEY_PATTERN.test(key)) return notFound();
|
|
||||||
if (!secretMatches(meta.downloadKeyHash, key)) return notFound();
|
|
||||||
|
|
||||||
await storage.delete(rawId);
|
|
||||||
log("info", `deleted id=${rawId} name=${JSON.stringify(meta.originalName)}`);
|
|
||||||
return json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -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<Response> {
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -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<string> {
|
|
||||||
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<Response> {
|
|
||||||
const contentType = req.headers.get("content-type");
|
|
||||||
const boundary = contentType ? getBoundary(contentType) : null;
|
|
||||||
if (!boundary || !req.body) return jsonError(400, "expected a multipart/form-data upload");
|
|
||||||
|
|
||||||
const id = newFileId();
|
|
||||||
const key = id;
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-151
@@ -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 };
|
|
||||||
}
|
|
||||||
-168
@@ -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<string, { file: string; type: string }> = {
|
|
||||||
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
|
||||||
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
|
||||||
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
|
|
||||||
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
|
|
||||||
"/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const staticContent = new Map<string, { body: string; type: string }>();
|
|
||||||
for (const [route, info] of Object.entries(STATIC_ROUTES)) {
|
|
||||||
staticContent.set(route, {
|
|
||||||
body: await Bun.file(new URL(info.file, import.meta.url)).text(),
|
|
||||||
type: info.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
hostname: cfg.host,
|
|
||||||
port: cfg.port,
|
|
||||||
maxRequestBodySize: Infinity,
|
|
||||||
fetch(req, srv) {
|
|
||||||
return route(req, srv).catch((err) => {
|
|
||||||
log("error", `unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
||||||
return jsonError(500, "internal error");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function clientIp(req: Request, srv: Server<undefined>): string {
|
|
||||||
if (cfg.trustProxy) {
|
|
||||||
const xff = req.headers.get("x-forwarded-for");
|
|
||||||
if (xff) {
|
|
||||||
const first = xff.split(",")[0]?.trim();
|
|
||||||
if (first) return first;
|
|
||||||
}
|
|
||||||
const cf = req.headers.get("cf-connecting-ip");
|
|
||||||
if (cf) return cf;
|
|
||||||
}
|
|
||||||
return srv.requestIP(req)?.address ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function serveStatic(path: string): Response {
|
|
||||||
const entry = staticContent.get(path);
|
|
||||||
if (!entry) return notFound();
|
|
||||||
return new Response(entry.body, {
|
|
||||||
headers: {
|
|
||||||
"content-type": entry.type,
|
|
||||||
"cache-control": "no-cache",
|
|
||||||
"content-security-policy":
|
|
||||||
"default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
||||||
...securityHeaders(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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<undefined>): Promise<Response> {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const path = url.pathname;
|
|
||||||
const method = req.method;
|
|
||||||
|
|
||||||
if (method === "GET" && staticContent.has(path)) return serveStatic(path);
|
|
||||||
|
|
||||||
if (path === "/api/health" && method === "GET") {
|
|
||||||
return json({ status: "ok", uptimeSeconds: Math.round((Date.now() - startedAt) / 1000) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === "/api/upload" && method === "POST") {
|
|
||||||
return handleUpload(req, storage, cfg.maxFileSize, 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)}`);
|
|
||||||
});
|
|
||||||
-191
@@ -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<void> {
|
|
||||||
await mkdir(this.filesDir, { recursive: true });
|
|
||||||
await mkdir(this.metadataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSecret(): Promise<string> {
|
|
||||||
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<number> {
|
|
||||||
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<Uint8Array>, maxBytes: number): Promise<number> {
|
|
||||||
const partPath = join(this.filesDir, `${id}.part`);
|
|
||||||
const sink = Bun.file(partPath).writer();
|
|
||||||
let size = 0;
|
|
||||||
let sinceFlush = 0;
|
|
||||||
try {
|
|
||||||
for await (const chunk of chunks) {
|
|
||||||
size += chunk.byteLength;
|
|
||||||
if (size > maxBytes) throw new UploadTooLargeError(maxBytes);
|
|
||||||
sink.write(chunk);
|
|
||||||
sinceFlush += chunk.byteLength;
|
|
||||||
if (sinceFlush >= 4 * 1024 * 1024) {
|
|
||||||
await sink.flush();
|
|
||||||
sinceFlush = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await sink.end();
|
|
||||||
} catch (err) {
|
|
||||||
const ended = sink.end();
|
|
||||||
if (ended instanceof Promise) await ended.catch(() => {});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
await rename(partPath, this.filePath(id));
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeMetadata(meta: FileMeta): Promise<void> {
|
|
||||||
const target = this.metaPath(meta.id);
|
|
||||||
const tmp = `${target}.tmp`;
|
|
||||||
await writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
||||||
await rename(tmp, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readMetadata(id: string): Promise<FileMeta | null> {
|
|
||||||
try {
|
|
||||||
const raw = await readFile(this.metaPath(id), "utf8");
|
|
||||||
const meta = JSON.parse(raw) as Partial<FileMeta>;
|
|
||||||
if (typeof meta.id !== "string" || typeof meta.downloadKeyHash !== "string") return null;
|
|
||||||
return meta as FileMeta;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await rm(this.filePath(id), { force: true });
|
|
||||||
await rm(this.metaPath(id), { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePartial(id: string): Promise<void> {
|
|
||||||
await rm(join(this.filesDir, `${id}.part`), { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async expireExpired(now: number = Date.now()): Promise<number> {
|
|
||||||
let removed = 0;
|
|
||||||
for (const entry of await readdir(this.metadataDir)) {
|
|
||||||
if (!entry.endsWith(".json")) continue;
|
|
||||||
const id = entry.slice(0, -".json".length);
|
|
||||||
if (!ID_PATTERN.test(id)) continue;
|
|
||||||
const meta = await this.readMetadata(id);
|
|
||||||
if (meta && isExpired(meta, now)) {
|
|
||||||
await this.delete(id);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user