A lightweight file storage server with a web interface, local expiry sweeper, and optional private-file protection via Cloudflare Access JWT.
- Random filename generation:
/uploadsgenerates unique 8-character filenames. - Custom file paths:
/files/:pathsupports explicit create/update/delete. - Path normalization: file paths are normalized to prevent directory traversal attacks.
- Edge-level write protection: Cloudflare WAF blocks anonymous POST/PUT/DELETE on
/files/*to prevent abuse (see Security Model). - Local expiry index + sweeper: expiration is tracked in
data/expiry-index.jsonand cleaned by an in-process background sweeper. - Private file redirect flow: private-index (tracked in
data/private-files.json) matches on/files/:pathredirect to/private-files/:path. - Cloudflare Access verification:
/private-files/:pathverifiesCf-Access-Jwt-Assertionor standard `Authorization: Bearer *** JWT (RS256/JWKS with 1hr cache, or HS256 for local testing). - Web interface: Svelte 5 + Vite + TypeScript + Tailwind CSS 4 upload UI with drag & drop, short URL generation, and one-click copy.
flowchart TD
subgraph frontend["Web Frontend (Svelte 5)"]
direction LR
FE1[FileUploadZone] -->|POST /uploads| FE2[DownloadLink]
FE_DESC["Vite + TypeScript + Tailwind CSS 4"]
end
subgraph backend["Rust Backend (Actix Web 4)"]
direction LR
R1["/health"]
R2["/uploads POST"]
R3["/files GET"]
R4["/files POST"]
R5["/files PUT"]
R6["/files DELETE"]
R7["/private-files GET"]
R8["/ (SPA static)"]
end
frontend -->|HTTP| backend
subgraph state["Managed State"]
direction LR
S1["config (Figment TOML+ENV)"]
S2["ExpiryStore (60s sweeper)"]
S3["PrivateIndexStore (JSON)"]
S4["AccessAuth (JWT / JWKS)"]
end
backend --> state
| Layer | Technology |
|---|---|
| Backend | Rust 2024 edition, Actix Web 4, Figment (config), jsonwebtoken, reqwest |
| Frontend | Svelte 5, Vite, TypeScript, Tailwind CSS 4, Lucide icons |
| Storage | Local filesystem (uploads/) + JSON indices (data/) |
| Auth | Cloudflare Access JWT (RS256/JWKS or HS256) |
| CI/CD | Gitea Actions (Rust test + Trivy scan + Docker build-push) |
- Rust and Cargo (2024 edition)
- Node.js 20+ and pnpm (for frontend development)
Linux/macOS:
RUST_LOG=info cargo runWindows (PowerShell):
$env:RUST_LOG="info"; cargo runWith custom bind settings:
Linux/macOS:
RUST_LOG=info FOLIO_ADDRESS=0.0.0.0 FOLIO_PORT=8080 cargo runWindows (PowerShell):
$env:RUST_LOG="info"; $env:FOLIO_ADDRESS="0.0.0.0"; $env:FOLIO_PORT="8080"; cargo runConfigured with Folio.toml and/or environment variables.
| Key | Environment Variable | Default | Description |
|---|---|---|---|
address |
FOLIO_ADDRESS |
127.0.0.1 |
HTTP bind address |
port |
FOLIO_PORT |
8000 |
HTTP bind port |
web_path |
FOLIO_WEB_PATH |
./web/dist |
Path to static web assets |
uploads_path |
FOLIO_UPLOADS_PATH |
./uploads |
Upload storage path |
data_path |
FOLIO_DATA_PATH |
./data |
Persistent metadata (index/state) path |
| Environment Variable | Default | Description |
|---|---|---|
FOLIO_CF_ACCESS_ISSUER |
https://example.cloudflareaccess.com |
Expected JWT issuer |
FOLIO_CF_ACCESS_AUD |
(empty) | Expected audience (required for production) |
FOLIO_CF_ACCESS_JWKS_URL |
${ISSUER}/cdn-cgi/access/certs |
JWK Set URL for signature verification |
FOLIO_CF_ACCESS_HS256_SECRET |
(unset) | Optional HS256 verifier secret (for local testing) |
Authorization is per-file based. Access lists are defined during upload via the authorized_emails field.
When FOLIO_CF_ACCESS_HS256_SECRET is set, Folio will use this secret to verify JWTs instead of fetching JWKS from Cloudflare. This is useful for manual testing without a real Cloudflare Access setup.
Example Configuration (.env):
FOLIO_CF_ACCESS_ISSUER=https://issuer.example.com
FOLIO_CF_ACCESS_AUD=folio-app
FOLIO_CF_ACCESS_HS256_SECRET=my-local-secretTesting with curl:
- Upload a private file for a specific user:
curl -X POST \
-F "file=@secret.txt" \
-F "authorized_emails=tester@example.com" \
"http://localhost:8000/uploads" -i- Access the file using a generated HS256 token (you can use jwt.io to generate one with
my-local-secret):
# Token payload should include:
# {
# "iss": "https://issuer.example.com",
# "aud": "folio-app", // Can also be an array: ["folio-app"]
# "sub": "user-123",
# "email": "tester@example.com",
# "exp": <future_timestamp>
# }
curl -H "Cf-Access-Jwt-Assertion: *** \
"http://localhost:8000/private-files/<generated-id>.txt" -iNote: The aud (audience) field can be either a string or an array. Cloudflare Access typically sends it as an array ["audience-id"]. Both formats are supported.
FOLIO_CF_ACCESS_ISSUER=https://<team>.cloudflareaccess.com
FOLIO_CF_ACCESS_AUD=<your-access-audience>
FOLIO_CF_ACCESS_JWKS_URL=https://<team>.cloudflareaccess.com/cdn-cgi/access/certsUpload a file with generated ID-based filename.
- Content-Type:
multipart/form-data - Query parameters:
| Name | Required | Type | Description | Default |
|---|---|---|---|---|
expire |
❌ | Query string | TTL (10s, 5m, 24h, 7d) |
168h |
- Form-data fields:
| Name | Required | Type | Description |
|---|---|---|---|
file |
✅ | File | File payload |
authorized_emails |
❌ | String | Comma-separated list of emails allowed to access this file. Presence of this field automatically marks the file as private. |
Note on file extensions:
The server determines file extension in the following order:
- Content-Type from multipart field (recommended) - explicitly specify using
curl -Fsyntax - Original filename extension - fallback if Content-Type is missing or generic
Response:
201 CreatedLocationheader:/files/<generated-name>
Example (Public):
# Recommended: explicitly set Content-Type to ensure correct extension
curl -X POST \
--form 'file=@sample.txt;type=text/plain' \
"http://localhost:8000/uploads?expire=1h" -i
# Alternative: using -F (shorter syntax, same result)
curl -X POST -F "file=@sample.txt;type=text/plain" \
"http://localhost:8000/uploads?expire=1h" -iExample (Private):
curl -X POST \
--form 'file=@secret.pdf;type=application/pdf' \
-F "authorized_emails=bob@example.com,alice@example.com" \
"http://localhost:8000/uploads" -iReference: See this article for detailed curl Content-Type syntax.
Download file content from uploads path.
200 OKon success302 Foundto/private-files/:pathif file is marked private404 Not Foundif missing
Example:
curl -i http://localhost:8000/files/sample.txtRead private file content.
- Requires request header:
Cf-Access-Jwt-Assertionor `Authorization: Bearer *** - Validates JWT signature/issuer/audience/expiry
- The
audfield can be either a string or an array (Cloudflare Access sends it as array) - Checks per-file email authorization list
Response:
200 OKwhen authorized401 Unauthorizedon missing/invalid token (signature/issuer/audience/expiry)403 Forbiddenon valid token but email not in file's authorized list
Example:
curl -H "Cf-Access-Jwt-Assertion: *** http://localhost:8000/private-files/secret.txtCreate file at explicit path.
201 Createdon success409 Conflictif already exists
Example:
curl -X POST -F "file=@sample.txt" "http://localhost:8000/files/docs/sample.txt"Create or overwrite file at explicit path.
201 Createdif new200 OKif overwritten
Example:
curl -X PUT -F "file=@sample.txt" "http://localhost:8000/files/docs/sample.txt"Delete file at explicit path.
200 OKon success404 Not Foundif missing400 Bad Requestif path is a directory
Example:
curl -X DELETE "http://localhost:8000/files/docs/sample.txt"# Run with debug logging
RUST_LOG=debug cargo run
# Run tests
cargo testcd web
# Install dependencies (requires pnpm)
pnpm install
# Development server with hot reload
pnpm dev
# Production build
pnpm run buildfolio/
├── src/ # Rust backend source
│ ├── main.rs # Application entry point, route mounting
│ ├── config.rs # Figment-based configuration (TOML + env)
│ ├── auth.rs # Cloudflare Access JWT validation (RS256/HS256)
│ ├── files.rs # File CRUD operations, path validation
│ ├── uploads.rs # Random filename generation, multipart uploads
│ ├── expiry.rs # Background sweeper for file expiration
│ ├── private_index.rs # Private file metadata (authorized emails)
│ └── test_utils.rs # Test helpers
├── web/ # Svelte frontend
│ ├── src/
│ │ ├── App.svelte # Main upload UI
│ │ └── components/ # UI components (FileUploadZone, DownloadLink)
│ ├── package.json
│ ├── svelte.config.js
│ └── vite.config.ts
├── data/ # Runtime data (created at runtime)
│ ├── expiry-index.json # File expiration tracking
│ └── private-files.json # Private file authorization
├── uploads/ # Uploaded files (created at runtime)
├── .gitea/workflows/ # CI/CD pipelines
│ ├── rust.yml # Rust test + Trivy scan
│ └── docker.yml # Docker build + push
└── Dockerfile # Multi-stage build (pnpm + Rust)
The project uses Gitea Actions for CI/CD.
| Workflow | Trigger | Description |
|---|---|---|
rust.yml |
Push/PR to main, tags *.*.* |
Build, test, Trivy vulnerability scan |
docker.yml |
Push/PR to main, tags *.*.* |
Docker build, Trivy image scan, push to registry |
Docker images are pushed to: registry-gitea.home-infra.weii.cloud/home-infra/folio
- Configure environment variables (
FOLIO_CF_ACCESS_*) and restart service. - Verify public flow:
GET /files/<public-file>returns200
- Verify private redirect flow:
GET /files/<private-file>returns302withLocation: /private-files/<private-file>
- Verify auth failures:
- no
Cf-Access-Jwt-Assertionheader on/private-files/...returns401 - invalid token returns
401 - valid token but email not in the file's
authorized_emailslist returns403
- no
- Verify authorized access:
- valid token + email matches the list returns
200
- valid token + email matches the list returns
- Check logs for deny audit entries (code/status/path/method) and ensure no token leakage.
- Local persistent data files:
data/expiry-index.jsondata/private-files.json