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 reduce traversal risk.
- 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 standardAuthorization: Bearer <token>JWT (cached for 1 hour). - Web interface: built-in React/Vite upload UI.
Linux/macOS:
RUST_LOG=info cargo runWindows (PowerShell):
$env:RUST_LOG="info"; cargo runWith custom upload limits:
Linux/macOS:
RUST_LOG=info ROCKET_LIMITS='{file="5 MiB"}' cargo runWindows (PowerShell):
$env:RUST_LOG="info"; $env:ROCKET_LIMITS='{file="5 MiB"}'; cargo runConfigured with Folio.toml and/or environment variables.
| Key | Environment Variable | Default | Description |
|---|---|---|---|
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 now 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: <your-hs256-token>" \
"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-AssertionorAuthorization: Bearer <token> - 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: <jwt>" 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"- 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
garbage_collection_patternexists in config but GC cleanup is not implemented.