This commit is contained in:
Anaïs Saraiva
2026-08-25 12:20:31 +02:00
parent ccc29212cd
commit abf8736fdd
59 changed files with 4228 additions and 3008 deletions
+59 -414
View File
@@ -1,439 +1,84 @@
# File Drop (VYBECODED)
# File Drop
A minimal, self-hosted private file hosting service built with **Bun** and
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
stored on the local filesystem and expire after 48 hours.
Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier,
on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent
directement dans le navigateur ; les autres fichiers se téléchargent.
Everything lives on disk — the filesystem is the source of truth. There is no
database, no object storage, and no queue. Expired files are swept automatically.
- **Backend** — Rust (Axum + Tokio)
- **Frontend** — Svelte (Vite + Tailwind)
- **Stockage** — système de fichiers local, sans base de données
## Features
- Drag & drop / file-picker uploads with progress, straight from the browser.
- **Media files render inline** (image/video/audio/PDF/text) as direct, embeddable
links, with one-click **Direct / HTML / BBCode / Markdown** link formats.
- **Optional per-file password**: set a password to gate downloads behind a prompt.
- Storage on the local filesystem; files survive restarts.
- A cryptographically secure, unguessable download key for every file.
- Streaming uploads and downloads — no full buffering in RAM, large files work.
- Configurable maximum upload size (`413` when exceeded, default 10GB).
- Files **auto-expire after 48 hours** and are removed automatically.
- Authenticated deletion (possession of the file's secret key is required).
- Rate-limited download/delete endpoints to blunt brute force.
- Zero runtime dependencies (Bun is the only runtime).
## Requirements
- [Bun](https://bun.sh) >= 1.1 (developed and tested against 1.3).
- Any platform Bun runs on (Linux, macOS, Windows).
## Installation
## Démarrage rapide
```bash
bun install
# 1. Construire le frontend (produit frontend/dist)
cd frontend
npm install
npm run build
cd ..
# 2. Lancer le serveur (depuis la racine du projet)
cargo run --release --manifest-path backend/Cargo.toml
```
There are no runtime dependencies; the only packages are dev-time
(`typescript`, `@types/bun`) used for type-checking.
Ouvrir ensuite http://localhost:3000.
> Les chemins `STORAGE_DIR` et `STATIC_DIR` sont relatifs au dossier de
> lancement. Lance donc le serveur depuis la racine du projet (ou règle ces
> variables en absolu dans `.env`).
## Configuration
All configuration is via environment variables. Bun auto-loads `.env`, so you
can copy the example and edit it:
Copier `backend/.env.example` vers `.env` puis l'adapter.
```bash
cp .env.example .env
```
| Variable | Default | Description |
| ---------------------- | ------------- | ----------------------------------------------------------------- |
| `HOST` | `0.0.0.0` | Bind address. |
| `PORT` | `3000` | Bind port. |
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). |
| `MAX_FILE_SIZE` | `10GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). |
| `MAX_STORAGE_USAGE` | `0.5` | Max fraction (0.0–1.0) of the storage disk that may be used; uploads are rejected with `503 try again later` above this. |
| `FILE_TTL_MS` | `172800000` | Milliseconds before an upload expires and is deleted (48 hours, hard maximum). |
| `KEY_LENGTH` | `8` | Length in characters of the upload/download key token (a-z0-9). |
| `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/(key)` and `/api/files/`. |
| `RATE_LIMIT_MAX` | `300` | Max requests per IP per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. |
| `TRUST_PROXY` | `0` | Set `1` only behind a proxy that sets `X-Forwarded-For`. |
## Running locally
```bash
bun run start # production (env-driven)
bun run dev # watch mode, restarts on change
```
Then open http://localhost:3000.
## Usage
1. Open the site.
2. Drop `example.zip` (or click to pick a file).
3. The server stores it and returns a URL like
`https://files.example.com/8fJ...random-secret...`.
4. Copy the URL. Anyone possessing it can download the file. If you set a
password, they must enter it before the download starts.
5. The file is removed automatically after 48 hours, or earlier by explicit
deletion.
## Filesystem layout
```
storage/
├── .secret # server signing secret (0600), auto-generated
├── files/
│ ├── <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.
| Variable | Défaut | Rôle |
| ---------------------- | ----------------- | ----------------------------------------- |
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. |
| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. |
| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. |
| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. |
| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). |
| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). |
| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. |
| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. |
| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. |
| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). |
## API
### `POST /api/upload`
| Méthode | Route | Rôle |
| --------- | -------------------- | ------------------------------------------- |
| `POST` | `/api/upload` | Envoi d'un fichier (`multipart/form-data`). |
| `GET` | `/:key` | Téléchargement (inline pour les médias). |
| `POST` | `/api/auth/:key` | Mot de passe d'un fichier protégé. |
| `DELETE` | `/api/files/:id` | Suppression (en-tête `X-Delete-Key`). |
| `GET` | `/api/health` | État du service. |
Multipart/form-data with a `file` field and an **optional** `password` field
(this is what the web UI uses). When no password is sent, the file is
downloadable by anyone with the URL.
Exemple :
```bash
curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/upload
curl -F "[email protected]" http://localhost:3000/api/upload
```
```json
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
"url": "/u8wcfcm7.zip",
"originalName": "example.zip",
"downloadName": "u8wcfcm7.zip",
"size": 123456,
"mimeType": "application/zip",
"protected": true
}
Le lien renvoyé est de la forme `http://localhost:3000/<clé>.png`. Ajouter
`?dl=1` force le téléchargement au lieu de l'affichage.
## Structure
```
backend/ serveur Rust (Axum)
frontend/ interface Svelte (Vite + Tailwind)
scripts/ tests
storage/ données (généré au premier lancement)
```
`id` and `key` are the same 8-character lowercase alphanumeric token (~41 bits
of entropy); the short length keeps share URLs tidy, and an optional password
can add a second factor of protection.
### Uploading with curl (tutorial)
Start an instance, then point curl at `/api/upload`:
## Tests
```bash
BASE=https://download.wf
cargo build # dans backend/
bash scripts/e2e-test.sh
```
**1. Public upload (no password).** The only required field is `file`:
```bash
curl -sS -F "[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.
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.
-39
View File
@@ -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

+36
View File
@@ -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
+1286
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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
+151
View File
@@ -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,
})
}
+59
View File
@@ -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()
}
+150
View File
@@ -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");
}
+48
View File
@@ -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
}
}
+189
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+42
View File
@@ -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)
}
+129
View File
@@ -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()
}
+21
View File
@@ -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,
)
}
+224
View File
@@ -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,
)
}
+262
View File
@@ -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, &params, &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"
}
+216
View File
@@ -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)
}
}
-24
View File
@@ -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=="],
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ User=filehost
Group=filehost
WorkingDirectory=/opt/filehost
EnvironmentFile=/opt/filehost/.env
ExecStart=/usr/local/bin/bun run src/server.ts
ExecStart=/opt/filehost/filehost
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
@@ -22,4 +22,4 @@ ProtectHome=true
ReadWritePaths=/opt/filehost/storage
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
+330
View File
@@ -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=="],
}
}
+15
View File
@@ -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>
+19
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+11
View File
@@ -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

+11
View File
@@ -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

+132
View File
@@ -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} />
+88
View File
@@ -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;
}
}
+91
View File
@@ -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 &amp; drop a file here</p>
<p class="mt-1 text-xs text-muted">or click to browse — up to 10&nbsp;GB</p>
{/if}
</div>
+65
View File
@@ -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>
+80
View File
@@ -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}
+41
View File
@@ -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}
+55
View File
@@ -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();
}
+101
View File
@@ -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 = `![${name}](${url})`;
} 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();
}
+50
View File
@@ -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 */
}
}
+8
View File
@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;
+31
View File
@@ -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: []
};
+11
View File
@@ -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'
}
}
});
-16
View File
@@ -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"
}
}
+42
View File
@@ -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"
+60
View File
@@ -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"
+4
View File
@@ -8,6 +8,10 @@
# Requires a running instance and `bun` on PATH.
set -euo pipefail
# bun colorizes booleans/numbers on stdout even when piped, which breaks the
# plain-text comparisons below (e.g. `protected` == "false").
export NO_COLOR=1
BASE="${BASE_URL:-http://127.0.0.1:3000}"
PW="hunter2"
TMP="$(mktemp -d)"
-112
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
-150
View File
@@ -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;
}
}
}
-502
View File
@@ -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 = `![${name}](${url})`;
} 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();
-104
View File
@@ -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 &amp; 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>
-700
View File
@@ -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;
}
}
-29
View File
@@ -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);
}
}
}
-45
View File
@@ -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 } });
}
-23
View File
@@ -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 });
}
-58
View File
@@ -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 });
}
-119
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
}
-17
View File
@@ -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"]
}