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 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.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 *** 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.

Architecture

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
Loading

Tech Stack

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)

Usage

Prerequisites

Running the Server

Linux/macOS:

RUST_LOG=info cargo run

Windows (PowerShell):

$env:RUST_LOG="info"; cargo run

With custom bind settings:

Linux/macOS:

RUST_LOG=info FOLIO_ADDRESS=0.0.0.0 FOLIO_PORT=8080 cargo run

Windows (PowerShell):

$env:RUST_LOG="info"; $env:FOLIO_ADDRESS="0.0.0.0"; $env:FOLIO_PORT="8080"; cargo run

Configuration

Configured with Folio.toml and/or environment variables.

Core

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

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 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: *** \
  "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 ***
  • 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: *** 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"

Development

Backend

# Run with debug logging
RUST_LOG=debug cargo run

# Run tests
cargo test

Frontend

cd web

# Install dependencies (requires pnpm)
pnpm install

# Development server with hot reload
pnpm dev

# Production build
pnpm run build

Project Structure

folio/
├── 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)

CI/CD

The project uses Gitea Actions for CI/CD.

Workflows

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

Registry

Docker images are pushed to: registry-gitea.home-infra.weii.cloud/home-infra/folio

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors