Skip to content

wei840222/folio

Repository files navigation

folio

A lightweight file storage server with a web interface, local expiry sweeper, and optional private-file protection via Cloudflare Access JWT.

Features

  • Random filename generation: /uploads generates unique 8-character filenames.
  • Custom file paths: /files/:path supports 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.json and cleaned by an in-process background sweeper.
  • Private file redirect flow: private-index (tracked in data/private-files.json) matches on /files/:path redirect to /private-files/:path.
  • Cloudflare Access verification: /private-files/:path verifies Cf-Access-Jwt-Assertion or standard Authorization: Bearer <token> JWT (cached for 1 hour).
  • Web interface: built-in React/Vite upload UI.

Usage

Prerequisites

Running the Server

Linux/macOS:

RUST_LOG=info cargo run

Windows (PowerShell):

$env:RUST_LOG="info"; cargo run

With custom upload limits:

Linux/macOS:

RUST_LOG=info ROCKET_LIMITS='{file="5 MiB"}' cargo run

Windows (PowerShell):

$env:RUST_LOG="info"; $env:ROCKET_LIMITS='{file="5 MiB"}'; cargo run

Configuration

Configured with Folio.toml and/or environment variables.

Core

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

Private access (Cloudflare Access)

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.

Local Development / Testing (HS256)

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-secret

Testing with curl:

  1. 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
  1. 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" -i

Note: 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.

Example .env (production baseline)

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/certs

API

POST /uploads

Upload 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:

  1. Content-Type from multipart field (recommended) - explicitly specify using curl -F syntax
  2. Original filename extension - fallback if Content-Type is missing or generic

Response:

  • 201 Created
  • Location header: /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" -i

Example (Private):

curl -X POST \
  --form 'file=@secret.pdf;type=application/pdf' \
  -F "authorized_emails=bob@example.com,alice@example.com" \
  "http://localhost:8000/uploads" -i

Reference: See this article for detailed curl Content-Type syntax.

GET /files/:path

Download file content from uploads path.

  • 200 OK on success
  • 302 Found to /private-files/:path if file is marked private
  • 404 Not Found if missing

Example:

curl -i http://localhost:8000/files/sample.txt

GET /private-files/:path

Read private file content.

  • Requires request header: Cf-Access-Jwt-Assertion or Authorization: Bearer <token>
  • Validates JWT signature/issuer/audience/expiry
  • The aud field can be either a string or an array (Cloudflare Access sends it as array)
  • Checks per-file email authorization list

Response:

  • 200 OK when authorized
  • 401 Unauthorized on missing/invalid token (signature/issuer/audience/expiry)
  • 403 Forbidden on valid token but email not in file's authorized list

Example:

curl -H "Cf-Access-Jwt-Assertion: <jwt>" http://localhost:8000/private-files/secret.txt

POST /files/:path

Create file at explicit path.

  • 201 Created on success
  • 409 Conflict if already exists

Example:

curl -X POST -F "file=@sample.txt" "http://localhost:8000/files/docs/sample.txt"

PUT /files/:path

Create or overwrite file at explicit path.

  • 201 Created if new
  • 200 OK if overwritten

Example:

curl -X PUT -F "file=@sample.txt" "http://localhost:8000/files/docs/sample.txt"

DELETE /files/:path

Delete file at explicit path.

  • 200 OK on success
  • 404 Not Found if missing
  • 400 Bad Request if path is a directory

Example:

curl -X DELETE "http://localhost:8000/files/docs/sample.txt"

Rollout checklist (dev → staging → production)

  1. Configure environment variables (FOLIO_CF_ACCESS_*) and restart service.
  2. Verify public flow:
    • GET /files/<public-file> returns 200
  3. Verify private redirect flow:
    • GET /files/<private-file> returns 302 with Location: /private-files/<private-file>
  4. Verify auth failures:
    • no Cf-Access-Jwt-Assertion header on /private-files/... returns 401
    • invalid token returns 401
    • valid token but email not in the file's authorized_emails list returns 403
  5. Verify authorized access:
    • valid token + email matches the list returns 200
  6. Check logs for deny audit entries (code/status/path/method) and ensure no token leakage.

Notes

  • Local persistent data files:
    • data/expiry-index.json
    • data/private-files.json
  • garbage_collection_pattern exists in config but GC cleanup is not implemented.

Related docs

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors