Files
IPFS-portal/ARCHITECTURE.md
T
maikrolf 88f2c7e5aa feat: require a write token or session on mutating portal API routes
- Adds IPFS_WRITE_TOKEN to lib/config.ts
- Default-deny per route (not per method): the public explorer issues reads as POST
- Existing portal session JWT remains valid, so the logged-in UI keeps working
- Public gateway reads (/ipfs/<cid>) are unchanged
2026-09-30 21:10:44 +02:00

359 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IPFS Portal — Architecture
## Overview
IPFS Portal is a **Next.js SPA** (Single Page Application) that provides a browser-based interface for managing an IPFS node, uploading files, and handling crypto payments via a smart contract. All pages are `'use client'` — no server-side rendering.
### Stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 14+ (React, `'use client'` SPA) |
| Styling | Tailwind CSS + custom `glass`/`surface` design tokens |
| Icons | Lucide React |
| Wallet | EIP-6963 + viem |
| Blockchain | zkSync Local (chain 270) |
| IPFS | Kubo RPC API (port 5001) |
| User Mgmt | Python backend (port 8444) |
| IPFS Gateway | `https://maos.dedyn.io/ipfs/<CID>` |
---
## System Architecture
```
BROWSER (React SPA)
│
┌─────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
11 Pages (SPA) WalletProvider PaymentService
┌──────────────┐ (EIP-6963) (viem, chain 270)
│ apiFetch() │ │ │
│ wrapper │ │ │
└──────┬───────┘ │ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Next.js API Route: [...path]/route.ts │
│ │
│ matchRoute(path) → │
│ 'health' → inline JSON │
│ 'users' → proxyUserAPI() → :8444 (Python) │
│ default → proxyIPFS() → :5001 (Kubo) │
│ │
│ mapToKuboAPI(path) → /api/v0/* translation │
└─────────────────────┬───────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Kubo IPFS │ │ Python User API │
│ Daemon │ │ (port 8444) │
│ (port 5001) │ │ │
│ │ │ htpasswd mgmt │
│ /api/v0/id │ │ Auth: X-Admin- │
│ /api/v0/swarm │ │ Key header │
│ /api/v0/pin/* │ │ │
│ /api/v0/add │ └──────────────────┘
│ /api/v0/ls │
│ /api/v0/name/ │
│ /api/v0/repo │ CHAIN 270
│ /api/v0/bw │ ┌──────────────────┐
└─────────────────┘ │ Smart Contract │
│ 0xCBc6... │
│ zkSync Local │
│ RPC :3050 │
│ │
│ payWithETH() │
│ payWithToken() │
│ getPrice() │
│ configureToken()│
└──────────────────┘
```
---
## Pages
| Page | Route | Lines | Backend Dependencies | Description |
|---|---|---|---|---|
| Landing | `/` | ~130 | Kubo (health) | Hero + connect wallet + quick stats |
| Dashboard | `/dashboard` | 253 | Kubo (peers, pins, health, repo, bw) | Node overview, stats, bandwidth |
| Upload | `/upload` | **590** | Kubo + Smart Contract | Quick + crypto file upload |
| Explorer | `/explorer` | 244 | Kubo (ls, cat) | IPFS content browser |
| IPNS | `/ipns` | 285 | Kubo (keys, publish, resolve) | IPNS name management |
| Users | `/users` | 275 | Python API + Smart Contract | htpasswd users + wallet lookup |
| Peers | `/peers` | 63 | Kubo (swarm/peers) | Connected peers list |
| Pins | `/pins` | 167 | Kubo (pin/ls, add, rm) | Pin management |
| Admin/Payment | `/admin/payment` | **536** | Smart Contract | Owner payment dashboard |
| Settings | `/settings` | 319 | localStorage only | App configuration |
| History | `/history` | 393 | localStorage only | Upload history viewer |
**Note**: IPNS page does NOT wrap in `PortalLayout` — appears standalone. All other pages use the shared layout.
---
## Data Flow
### 1. API Flow (all pages except Settings, History)
```
Page Component
→ apiFetch('/api/endpoint')
→ fetch('/api/endpoint', { credentials: 'include' })
→ Next.js [...path]/route.ts
→ matchRoute(path)
→ proxyIPFS() → Kubo RPC :5001
→ proxyUserAPI() → Python :8444
```
### 2. Upload Flow (Quick)
```
User drags file
→ UploadPage state: FileEntry[]
→ startUpload() — sequential per file
→ uploadFile(file) → apiFetch POST /api/files/upload (FormData)
→ proxyIPFS → Kubo /api/v0/add
→ addToHistory({ cid, name, size }) → localStorage
→ Show results (CID, gateway link, copy)
```
### 3. Upload Flow (Crypto)
```
User selects file
→ PaymentPanel shown
→ WalletProvider.connectWallet() → get address
→ PaymentService.getPrice(bytes, addr) → { finalWei, isFree }
→ PaymentService.getSupportedTokens() → [ETH, USDC, MAOS]
→ PaymentService.getMAOSDiscountTiers() → discount %
→ User pays
→ Step 1: uploadFile(file) → Kubo → get CID
→ Step 2a (free): onPaid({ method: 'free' })
→ Step 2b (ETH): payWithETH(provider, cid, size, wei)
→ sendTransaction → contract.payWithETH(cid, bytesSize)
→ Step 2c (token): approveAndPayWithToken(...)
→ check allowance → approve if needed → contract.payWithToken(...)
→ addToHistory({ method: 'eth' }) → localStorage
```
### 4. Payment Admin Flow
```
Admin connects wallet
→ Check isOwner (compare connected address to contract.owner)
→ Load: getOwner, getBasePricePerMB, getFreeTierBytes,
getSupportedTokens, getMAOSDiscountTiers, getTotalUploads
→ Per token: getTotalRevenue, getBalance (ETH) / balanceOf (ERC20)
→ Owner can: setPrice, setFreeTier, configureToken,
add/remove discount tiers, withdraw funds
```
---
## Key Modules
### `src/lib/api.ts` (217 lines)
Central API layer. Exports:
| Function | HTTP | Endpoint | Category |
|---|---|---|---|
| `getNodeInfo()` | GET | `/api/node/info` | Node |
| `getPeers()` | GET | `/api/peers` | Peers |
| `listPins()` / `addPin()` / `removePin()` | GET/POST/DELETE | `/api/pins` | Pins |
| `uploadFile(file)` | POST (FormData) | `/api/files/upload` | Files |
| `listUsers()` / `createUser()` / `deleteUser()` | GET/POST/DELETE | `/api/users` | Users |
| `explorerLs()` / `explorerCat()` / `explorerStat()` | POST | `/api/explorer/*` | Explorer |
| `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | GET/POST | `/api/ipns/*` | IPNS |
| `getRepoStats()` / `getBWStats()` | GET | `/api/repo` / `/api/bw` | Stats |
| `checkHealth()` | GET | `/api/health` | Health |
### `src/lib/wallet.ts` (196 lines)
Wallet connection layer.
- **EIP-6963** event-based provider discovery (`discoverWallets`)
- Legacy `window.ethereum` fallback
- MAOS Wallet preferred (`window.maosv6`)
- Chain 270 (zkSync Local) switch + add
- Helpers: `formatWeiToETH`, `formatBytes`, `signMessage`, `getBalance`
### `src/lib/payment.ts` (539 lines)
Smart contract interaction via viem.
- 28 methods wrapping ABI functions
- Supports ETH and ERC20 token payments
- Read: `getPrice`, `getUserStats`, `getSupportedTokens`, `getMAOSDiscountTiers`
- Write: `payWithETH`, `approveAndPayWithToken`
- Admin: `adminSetPricePerMB`, `adminConfigureToken`, `adminWithdrawETH`, etc.
- Contract: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe` on chain 270
### `src/lib/storage.ts` (98 lines)
localStorage persistence.
- `PortalSettings`: gateway URL, API endpoint, storage max, refresh, theme
- `UploadRecord`: history with dedup by CID+name, capped at 500 entries
### `src/app/api/[...path]/route.ts` (~320 lines)
Catch-all API proxy. Routes:
| Path | Handler | Backend |
|---|---|---|
| `/api/health` | `handleHealth()` | Inline JSON |
| `/api/users*` | `proxyUserAPI()` | Python :8444 |
| `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 |
**`mapToKuboAPI(path)`** translates Portal API paths to Kubo RPC endpoints:
- `/node/info` → `/api/v0/id`
- `/peers` → `/api/v0/swarm/peers`
- `/pins` → `/api/v0/pin/ls|add|rm`
- `/files/upload` → `/api/v0/add`
- `/explorer/ls` → `/api/v0/ls`
- `/ipns/publish` → `/api/v0/name/publish`
- `/repo/stats` → `/api/v0/repo/stat`
- `/bw/stats` → `/api/v0/bw`
---
## Write authentication
Muterende `/api/*`-endpoints eisen een token; lees-endpoints blijven publiek.
Ingang: `authorizeWrite()` in `src/app/api/[...path]/write-auth.ts`, aangeroepen
vanuit `POST` en `DELETE` in `route.ts`. De waarde komt uit
`ipfsWriteToken()` in `src/lib/config.ts` (env `IPFS_WRITE_TOKEN`).
| Endpoint | Methode | Write-gate |
|---|---|---|
| `/api/add` | POST | ✅ |
| `/api/files/upload` | POST | ✅ |
| `/api/files/<cid>` | DELETE | ✅ |
| `/api/pins` | POST | ✅ |
| `/api/pins/<cid>` | DELETE | ✅ |
| `/api/ipns/publish`, `/api/ipns/keys/gen/<naam>` | POST | ✅ |
| `/api/users/<naam>` | DELETE | ✅ |
| `/api/explorer/{ls,cat,stat,resolve}/<cid>` | POST | ❌ lees-only |
| `/api/pins`, `/api/files`, `/api/explorer/*`, `/api/health` | GET | ❌ publiek |
| `/ipfs/<cid>` (gateway) | GET | ❌ publiek |
| `/api/users` (registratie) | POST | ❌ open — eigen auth van Python-backend |
**Toegang tot een mutatie — één van beide volstaat:**
1. `Authorization: Bearer $IPFS_WRITE_TOKEN` (MAOS → portal, server-to-server).
Vergelijking is constant-time via SHA-256 digests (`crypto.timingSafeEqual`).
2. De bestaande portalsessie-JWT cookie `ipfs-portal-session` — identieke
verificatie als `src/middleware.ts`. Zónder dit pad zouden de eigen
upload-/pin-pagina's van de portal kapotgaan.
Geen van beide → `401` (geen `Authorization`-header) of `403` (header aanwezig,
verkeerde waarde), met `WWW-Authenticate: Bearer`.
**`IPFS_WRITE_TOKEN` niet geconfigureerd** → writes blijven **open** met een
eenmalige luide waarschuwing in de log. `src/lib/config.ts` heeft geen
"required in production"-patroon (alleen defaults, in tegenstelling tot
`JWT_SECRET` in `auth-server.ts`), en een harde fail zou elke nog niet
geprovisioneerde deploy direct breken — vandaar bewust fail-open + waarschuwing.
**Besluit — publieke lees-endpoints**: `GET /api/pins` en `GET /api/files`
blijven anoniem toegankelijk. Ze lekken alleen metadata (CID's, namen, maten);
content is sowieso publiek via de gateway. Ze achter de sessie zetten zou de
publieke explorer (pin-badges) én MAOS' pin-status/inventory-reads breken — die
sturen vandaag geen `Authorization`-header op GET. Zodra MAOS ook GET's met
Bearer verstuurt kan dit alsnog achter de gate.
**Rest-risico**: `POST /api/users` (registratie én admin-aanmaak, zelfde
endpoint) blijft open — een registrant heeft noch token noch sessie. De
Python-backend is daar de autoriteit (`X-Admin-Key` + doorgestuurd
`X-Session-Token`).
---
## Smart Contract (Chain 270 — zkSync Local)
**Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe`
**RPC**: `http://tom1687.no-ip.org:3050`
### Functions
| Category | Functions |
|---|---|
| **Payment** | `payWithETH(cid, bytesSize)` (payable), `payWithToken(cid, bytes, symbol)` |
| **Pricing** | `getPrice(bytes, user)` → `(totalWei, discountBps, finalWei, isFree)` |
| **Free Tier** | `freeTierBytes()`, `userTotalBytes(user)` |
| **Token Mgmt** | `configureToken(symbol, addr, decimals, weiPerToken, enabled)` |
| **Discounts** | `getMAOSDiscountTiers()`, `setMAOSDiscountTier(minBal, bps)`, `removeMAOSDiscountTier(index)` |
| **Admin** | `setPricePerMB(wei)`, `setFreeTierBytes(bytes)`, `withdrawETH(to)`, `withdrawToken(symbol, to)`, `transferOwnership(to)` |
| **Stats** | `totalRevenue(symbol)`, `totalUploads()`, `getUserStats(user)` |
| **Events** | `UploadPaidETH`, `UploadPaidToken`, `FreeUploadUsed` |
### Known Bug
Contract returns duplicate token entries — admin page deduplicates on symbol.
---
## Graph Analysis
### Static (AST) View
- **251 nodes**, **401 edges**, **17 communities**
- Graphify knowledge graph at `graphify-out/graph.json`
- **79 isolated nodes** (type definitions, interfaces, scripts)
### Bridge Nodes
| Node | Edges | Role |
|---|---|---|
| `PaymentService` | 31 | Bridges Smart Contract ↔ UI (Payments) |
| `WalletProvider` | 15 | Bridges Wallet ↔ PaymentService |
### Graph Gap
The AST parser cannot trace URL-routed connections. The runtime flow:
```
apiFetch('/api/node/info') → fetch('/api/node/info')
→ [...path]/route.ts → proxyIPFS() → Kubo :5001
```
Appears as 3 disconnected communities in the graph but is one continuous flow at runtime.
---
## Code Quality Summary
### Issues Found (24 total)
| Severity | Count | Key Items |
|---|---|---|
| 🔴 Critical | 4 | XSS, unhandled promises, silent catch blocks, oversized files |
| 🟡 Medium | 5 | 15× `@ts-expect-error`, 8× `as any`, duplicate helpers, precision loss |
| 🟢 Low | 3 | Unused imports, console.* logging, IPNS layout inconsistency |
### Priority Fixes
1. **Window type augmentation** — Declare `Window` interface in `wallet.ts` to eliminate 15 `@ts-expect-error`
2. **Extract duplicate patterns** — Consolidate `formatBytes`, `gatewayLink`, `truncateCid` into `lib/helpers.ts`
3. **Add error logging** — Every empty catch block gets `console.error` or structured logging
4. **Split oversized files** — `upload/page.tsx` (590→2 components), `admin/payment/page.tsx` (536→4 views), `payment.ts` (539→abi/types/service)
5. **Fix XSS** — Add DOMPurify or replace `dangerouslySetInnerHTML` in `FilePreview.tsx`
6. **Fix unhandled promises** — Add `.catch()` to `isDeployed().then()`
7. **Replace `as any` casts** — Use proper viem `parseAbi` types instead of `as unknown as X`
---
## Configuration
- **API_BASE**: `''` (empty — same origin; nginx routes `/api/*` to backend)
- **Kubo RPC**: `http://127.0.0.1:5001`
- **Python API**: `http://192.168.1.176:8444` (htpasswd + admin key)
- **Smart Contract**: `0xCBc6...` on chain 270 (zkSync Local)
- **IPFS Gateway**: `https://maos.dedyn.io/ipfs/`
- **Refresh Interval**: 15s (Dashboard)
- **Max Uploads**: 20 files, 100MB each
- **Storage Max**: 30 GB (localStorage setting)
- **Admin Key**: `maos-admin-2024` (X-Admin-Key header)