This document explains how file upload and download works in FxFiles for two user types:
- Non-blox user — cloud-only, no hardware device
- Blox user — has a paired FxBlox device on their LAN
Both paths use the same S3-compatible API and same client-side encryption. The only differences are the endpoint URL and auth token type.
- User opens File Browser screen, taps a file (or selects multiple)
- Taps "Upload to Cloud" → snackbar: "Queued for upload: photo.jpg"
- File is added to
SyncService._uploadQueue(persistent — survives app restart) - Background queue processor picks it up → progress indicator shows upload %
- On completion → snackbar: "File synced" → file appears in Cloud Browser
Folder uploads recursively queue all files preserving relative paths.
- User opens Cloud Browser (route
/fula?bucket=...&prefix=...) - Browses encrypted cloud storage (folders and files shown with original names)
- Taps a file → snackbar: "Downloading photo.jpg..."
- File downloads to
/Download/folder (or category-specific folder) - On completion → snackbar: "Downloaded photo.jpg"
FxFiles app
│
├─ 1. AuthService.getEncryptionKey()
│ → Argon2id("fula-files-v1", "google:{userId}:{email}") → 32-byte AES key
│
├─ 2. FulaApiService.createBucket("images") [if not exists]
│ → PUT https://s3.cloud.fx.land/images
│ → Header: Authorization: Bearer <JWT>
│
├─ 3. FulaApiService.encryptAndUploadLargeFile(bucket, key, data)
│ → fula_client Rust FFI: putFlat(client, bucket, path, data, contentType)
│ ├─ Encrypts file with AES-256-GCM using derived key
│ ├─ Encrypts metadata (filename) with FlatNamespace obfuscation
│ └─ Sends PUT https://s3.cloud.fx.land/{bucket}/{obfuscated-key}
│ Header: Authorization: Bearer <JWT>
│ Body: encrypted file bytes
│
└─ Server side (fula-api cloud gateway):
├─ Auth middleware validates JWT → extracts user_id → BLAKE3 hash
├─ Stores encrypted bytes in kubo → gets CID (content-addressed)
├─ Creates ObjectMetadata (CID, size, owner_id, content_type)
├─ Opens user-scoped bucket: "{hashed_user_id}:{bucket_name}"
├─ Stores object in Prolly Tree index
├─ Flushes bucket → new root CID
├─ Persists bucket registry → pins registry CID to remote pinning service
└─ Returns 200 OK with ETag (= CID)
FxFiles app
│
├─ 1. FulaApiService.downloadAndDecrypt(bucket, key, encryptionKey)
│ → fula_client Rust FFI: getFlat(client, bucket, path)
│ ├─ Sends GET https://s3.cloud.fx.land/{bucket}/{obfuscated-key}
│ │ Header: Authorization: Bearer <JWT>
│ ├─ Server: looks up object in user-scoped Prolly Tree → gets CID
│ │ → fetches block from kubo (IPFS /api/v0/cat for DAGs, /api/v0/block/get for raw)
│ │ → returns encrypted bytes with ETag, Content-Type headers
│ ├─ Decrypts file with AES-256-GCM using derived key
│ └─ Returns plaintext Uint8List
│
└─ 2. Write to disk: File(downloadPath).writeAsBytes(data)
Key points:
- Encryption/decryption is always client-side (fula_client Rust library via FFI)
- Server never sees plaintext — stores encrypted blobs addressed by CID
- Filenames are obfuscated on server (FlatNamespace mode); original names in encrypted index
- ETag = IPFS CID (content-addressed, not MD5)
- User goes to Settings → My Devices → Pair Blox
- App opens deeplink to FxBlox companion app, passing JWT
- FxBlox app calls blox's
AutoPinPair(token, endpoint)via libp2p- go-fula stores
auto_pin_token,auto_pin_endpoint,auto_pin_pairing_secretinbox_props.json - Returns pairing secret + hardware ID
- go-fula stores
- FxBlox app returns deeplink:
fxfiles://autopin-complete?secret=...&hardwareId=... - FxFiles stores pairing secret, hardware ID, peer ID in SecureStorage
BloxDiscoveryServiceruns mDNS scan every 30 seconds for_fulatower._tcp.local- Extracts from TXT records:
s3Port=9000,autoPinPort=3501,gatewayPort=8080, peer ID, hardware ID - Builds
BloxDevicewiths3Url = "http://{blox-ip}:9000" - When paired blox found on LAN →
FulaApiService.switchToLocalGateway(bloxDevice.s3Url, pairingSecret)
Identical to non-blox user from the user's perspective. The upload still goes to the cloud gateway because:
- The blox is a sync consumer, not an upload target
- Uploads go to
s3.cloud.fx.land→ cloud kubo → remote pinning service - The blox's
fula-pinningdaemon then pulls the data down via IPFS P2P
User experience is identical (tap file → downloads), but faster over LAN:
- If blox is on the same network,
FulaApiServiceswitches to local endpoint - Download comes from blox at
http://192.168.x.x:9000instead ofhttps://s3.cloud.fx.land - Same encryption, same file format, same API
Same as non-blox upload — always goes to cloud:
FxFiles → PUT https://s3.cloud.fx.land/{bucket}/{key}
→ Auth: Bearer <JWT>
→ cloud gateway stores in cloud kubo, pins to pinning service
Then asynchronously (no user involvement):
fula-pinning daemon (on blox, every 3 min):
├─ GET remote pinning service API (user's JWT from box_props.json)
├─ Gets list of pinned CIDs for this user
├─ Compares against local kubo pins
├─ For missing CIDs: ipfs pin add <cid>
│ → kubo fetches blocks over IPFS P2P network from cloud kubo
├─ Writes bucket registry CID to /internal/fula-gateway/registry.cid
└─ fula-gateway polls this file every 30s, reloads registry when CID changes
FxFiles app (blox on same LAN)
│
├─ BloxDiscoveryService found paired blox at 192.168.1.100
├─ FulaApiService.switchToLocalGateway("http://192.168.1.100:9000", pairingSecret)
│
├─ 1. FulaApiService.downloadAndDecrypt(bucket, key, encryptionKey)
│ → fula_client Rust FFI: getFlat(client, bucket, path)
│ ├─ Sends GET http://192.168.1.100:9000/{bucket}/{obfuscated-key}
│ │ Header: Authorization: Bearer <pairing_secret> ← NOT JWT
│ │
│ ├─ Local fula-gateway (on blox):
│ │ ├─ Auth middleware: validates bearer == box_props.json pairing_secret
│ │ ├─ Derives owner_id from box_props.json JWT sub claim (BLAKE3 hash)
│ │ ├─ Loads registry from registry.cid (synced by fula-pinning)
│ │ ├─ Opens user-scoped bucket → Prolly Tree lookup → gets CID
│ │ ├─ Fetches block from LOCAL kubo (127.0.0.1:5001)
│ │ │ → Data already present because fula-pinning synced it
│ │ └─ Returns encrypted bytes + X-Fula-Content-Cid header
│ │
│ ├─ Decrypts file with AES-256-GCM (same key, same algorithm)
│ └─ Returns plaintext Uint8List
│
└─ 2. Write to disk (identical)
If blox is not reachable (user is away from home), switchToCloudGateway() is called and the flow is identical to non-blox user — download from s3.cloud.fx.land with JWT auth.
| Aspect | Non-Blox User | Blox User (LAN) | Blox User (Remote) |
|---|---|---|---|
| Upload endpoint | s3.cloud.fx.land |
s3.cloud.fx.land (same) |
s3.cloud.fx.land (same) |
| Upload auth | Bearer JWT | Bearer JWT | Bearer JWT |
| Download endpoint | s3.cloud.fx.land |
http://{blox-ip}:9000 |
s3.cloud.fx.land |
| Download auth | Bearer JWT | Bearer pairing_secret | Bearer JWT |
| Download speed | WAN (internet) | LAN (local network) | WAN (internet) |
| Encryption | Client-side AES-256-GCM | Same | Same |
| Encryption key | Argon2id from user creds | Same key | Same key |
| Data on blox? | No | Yes (synced by fula-pinning) | Yes (but not used) |
| Offline access | No | Yes (if data synced) | No |
- Key derivation:
Argon2id("fula-files-v1", "google:{userId}:{email}")→ 32-byte key - File encryption: AES-256-GCM (handled transparently by
fula_clientRust library) - Metadata privacy: FlatNamespace obfuscation — original filenames stored only in encrypted index
- Cross-platform: Same derived key on Flutter (native) and WebUI (WASM)
- Server-side: No encryption — gateway stores encrypted blobs as-is in IPFS
- Same key for cloud and local: Encryption is user-scoped, not endpoint-scoped
User uploads via FxFiles
↓
Cloud S3 Gateway (s3.cloud.fx.land)
↓
Cloud Kubo (stores blocks) + Remote Pinning Service (records CIDs)
↓
fula-pinning daemon on blox (polls pinning service every 3 min using user's JWT)
↓
Finds new CIDs → ipfs pin add → local kubo fetches blocks over IPFS P2P
↓
Writes registry.cid → fula-gateway reloads → files now available on LAN
| Component | File | Role |
|---|---|---|
| Upload queue | FxFiles/lib/core/services/sync_service.dart |
Queues, retries, progress |
| S3 client | FxFiles/lib/core/services/fula_api_service.dart |
putFlat(), getFlat(), gateway switching |
| Encryption | fula_client Rust crate (FFI) |
AES-256-GCM encrypt/decrypt |
| Blox discovery | FxFiles/lib/core/services/blox_discovery_service.dart |
mDNS _fulatower._tcp.local |
| Cloud gateway | fula-api/crates/fula-cli/src/handlers/object.rs |
put_object, get_object |
| Local gateway | fula-ota/docker/fula-gateway/fula-local-gateway/ |
Same S3 API, bearer auth |
| Auto-pin sync | fula-ota/docker/fula-pinning/ |
Polls remote pinning → local kubo pin |
| mDNS advertise | go-fula/wap/cmd/mdns/mdns.go:165 |
s3Port=9000 in TXT records |
| Pairing | go-fula/blockchain/bl_autopin.go |
Stores credentials in box_props.json |
- Parallel uploads: Up to 5 concurrent uploads (configurable)
- Persistent queue: Stored in Hive
sync_queuebox — survives app restart - Retry logic: Exponential backoff (2s, 4s, 8s, 16s, 32s, capped at 5 min)
- Max retries: 5 attempts per file
- Auto-pause: Queue pauses for 2 minutes after 3 consecutive failures
- Progress tracking:
UploadProgressManagerwith speed estimation per file and batch
- Retryable: DNS failures, connection refused/reset, timeouts, bad gateway, SSL issues, EOF
- Permanent: 401/403/404/409/410/413/422, access denied, invalid key, quota exceeded
SyncStatus: notSynced | syncing | synced | error
Fields:
localPath, remotePath, remoteKey, bucket, status,
lastSyncedAt, etag, localSize, remoteSize, errorMessage,
displayPath (iOS PhotoKit), iosAssetId, contentCid (IPFS CID for Blox)- Android: WorkManager periodic task (15 min minimum)
- iOS: BGProcessingTask
- Task types:
periodicSync— 15-min periodic processinguploadTask— One-off file upload (9-min timeout)downloadTask— One-off file download (9-min timeout)retryFailedTask— Retry all failed uploadscleanupTask— Clean up incomplete multipart uploads >24h old
initialize(endpoint, secretKey, accessToken, defaultBucket)
switchToLocalGateway(localUrl, pairingSecret) // For paired Blox
switchToCloudGateway(endpoint, accessToken)
uploadObject / uploadLargeFile(bucket, key, data)
downloadObject / downloadAndDecrypt(bucket, key)
deleteObject(bucket, key)
listObjects(bucket, prefix, recursive)
createBucket(bucket)
createShareToken(bucket, storageKey, recipientPublicKey, mode, expiresAt)- mDNS scan every 30 seconds for
_fulatower._tcp.local - Extracts from TXT records:
s3Port,autoPinPort,gatewayPort, peer ID, hardware ID - Builds
BloxDevicewiths3Url = "http://{blox-ip}:9000" - Can report missing CIDs to fula-pinning for priority pinning
- Polls auto-pin status (pinned count, pending count, last sync)
- Port: 9000 (LAN-only via firewall)
- Framework: Axum 0.8 + Tokio
- Auth: Bearer token from
box_props.jsonpairing secret - User scoping: BLAKE3 hash of JWT
subclaim →owner_id - CID watcher: Polls
/internal/fula-gateway/registry.cidevery 30s, reloads on change - Storage: Uses
fula-coreandfula-blockstorecrates from fula-api - S3 API support: GET, PUT, DELETE, HEAD, COPY objects; multipart uploads; bucket operations
- Special headers:
X-Fula-Content-Cidon every object response - Health check:
GET /healthz
- Port: 3501 (LAN-only via firewall)
- Language: Pure Go stdlib (no external dependencies)
- Sync interval: Every 3 minutes (configurable via
SYNC_INTERVAL) - Config polling: Reads
box_props.jsonevery 30 seconds for pair/unpair detection - Sync algorithm:
- Fetch all remote pins from pinning service (paginated, 1000/page)
- Fetch local recursive pins from Kubo
- Diff to find missing CIDs
- Pin each missing CID recursively via Kubo API
- Write registry CID to
/internal/fula-gateway/registry.cid(atomic write)
- Priority queue: HTTP API accepts up to 100 CIDs for immediate pinning
- HTTP endpoints:
GET /api/v1/auto-pin/status— daemon status JSONPOST /api/v1/auto-pin/report-missing— queue priority pins
- Auth: All endpoints require
Authorization: Bearer {pairing_secret}
Both services run with network_mode: "host" and depend on kubo:
fula-pinning:
image: ${FULA_PINNING:-functionland/fula-pinning:release}
container_name: fula_pinning
restart: unless-stopped
network_mode: "host"
volumes:
- /home/pi/.internal:/internal:rw,rshared
depends_on:
- kubo
environment:
- KUBO_API=http://127.0.0.1:5001
- AUTO_PIN_PORT=3501
- SYNC_INTERVAL=3m
- REGISTRY_CID_PATH=/internal/fula-gateway/registry.cid
fula-gateway:
image: ${FULA_GATEWAY:-functionland/fula-gateway:release}
container_name: fula_gateway
restart: unless-stopped
network_mode: "host"
volumes:
- /home/pi/.internal:/internal:rw,rshared
depends_on:
- kubo
environment:
- FULA_HOST=0.0.0.0
- FULA_PORT=9000
- IPFS_API_URL=http://127.0.0.1:5001
- REGISTRY_CID_PATH=/internal/fula-gateway/registry.cid
- BOX_PROPS_FILE=/internal/box_props.json
- RUST_LOG=infoBoth ports restricted to LAN-only access:
# Port 3501 (fula-pinning)
iptables -A "$CHAIN" -p tcp --dport 3501 -s 192.168.0.0/16 -j ACCEPT
iptables -A "$CHAIN" -p tcp --dport 3501 -s 10.0.0.0/8 -j ACCEPT
iptables -A "$CHAIN" -p tcp --dport 3501 -s 172.16.0.0/12 -j ACCEPT
# Port 9000 (fula-gateway)
iptables -A "$CHAIN" -p tcp --dport 9000 -s 192.168.0.0/16 -j ACCEPT
iptables -A "$CHAIN" -p tcp --dport 9000 -s 10.0.0.0/8 -j ACCEPT
iptables -A "$CHAIN" -p tcp --dport 9000 -s 172.16.0.0/12 -j ACCEPT/home/pi/.internal/
├── box_props.json # Pairing credentials (read by both services)
│ ├── auto_pin_token # JWT for remote pinning service
│ ├── auto_pin_endpoint # Remote pinning service URL
│ └── auto_pin_pairing_secret # Bearer secret for local HTTP APIs
├── fula-gateway/
│ └── registry.cid # Bucket registry CID (written by fula-pinning, read by fula-gateway)
└── ipfs_data/ # Kubo's IPFS repository
┌──────────────────────────────────────────────────────────────────────────┐
│ FxFiles Mobile App │
│ │
│ ┌─────────────┐ ┌───────────────┐ ┌──────────────────────┐ │
│ │ SyncService │ │ FulaApiService│ │ BloxDiscoveryService │ │
│ │ (queue mgr) │→ │ (S3 client) │ │ (mDNS scanner) │ │
│ └─────────────┘ └───────┬───────┘ └──────────┬───────────┘ │
│ │ │ │
│ fula_client FFI mDNS: _fulatower._tcp │
│ (AES-256-GCM encrypt) │ │
│ │ │ │
└───────────────────────────┼──────────────────────┼───────────────────────┘
│ │
┌─────────────┴──────────┐ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────────────────────────────┐
│ Cloud Gateway │ │ FxBlox Device (LAN) │
│ s3.cloud.fx.land │ │ │
│ │ │ ┌──────────────┐ ┌──────────────────┐ │
│ JWT auth │ │ │ fula-gateway │ │ fula-pinning │ │
│ Cloud Kubo │ │ │ :9000 (S3) │ │ :3501 (sync) │ │
│ Prolly Trees │ │ │ Bearer auth │ │ Bearer auth │ │
│ │ │ │ Reads CIDs │ │ Writes CIDs │ │
│ Pins to remote │ │ │ from local │ │ to local kubo │ │
│ pinning service │ │ │ kubo │ │ │ │
│ │ │ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │ │ │
│ │ │ ▼ ▼ │
│ │ │ ┌──────────────────────────────────────┐ │
│ │ │ │ Local Kubo (:5001) │ │
│ │ │ │ IPFS blocks pinned locally │ │
│ │ │ └──────────────────────────────────────┘ │
│ │ │ ▲ │
└─────────┬─────────┘ └─────────┼────────────────────────────────────┘
│ │
│ IPFS P2P (Bitswap) │
└────────────────────────┘
| Store | Backend | Contents |
|---|---|---|
sync_queue |
Hive box | Pending upload/download tasks (survives app restart) |
sync_states |
Hive box | Per-file sync status, etag, CID |
sync_mappings |
Hive box | Cloud-to-local file mappings (for reinstall) |
settings |
Hive box | General app settings |
| SecureStorage | Keychain/Keystore | Pairing secret, hardware ID, peer ID |
| File | Written By | Read By | Contents |
|---|---|---|---|
box_props.json |
go-fula (pairing) | fula-pinning, fula-gateway | JWT, endpoint, pairing secret |
registry.cid |
fula-pinning | fula-gateway | Latest bucket registry CID |
| Kubo datastore | Kubo | fula-gateway | IPFS blocks (content-addressed) |
- readiness-check.py: Monitors
fula_pinningandfula_gatewaycontainers; triggersfula.servicerestart if either stops - fula-gateway health:
GET /healthz(Docker HEALTHCHECK every 30s) - fula-pinning status:
GET /api/v1/auto-pin/statusreturns JSON withpaired,total_pinned,last_sync_at,next_sync_at - Watchtower: Both containers labeled for automatic image updates