# 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://ipfs.maos.dedyn.io/ipfs/` | --- ## 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) │ │ pins/files → lokale .data-opslag (nooit 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, repo/stats), inline health, lokale pins | Node overview, stats, bandwidth | | Upload | `/upload` | **590** | Lokale `.data`-opslag + Smart Contract | Quick + crypto file upload | | Explorer | `/explorer` | 244 | Kubo (ls, cat) + lokale pins | 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 | Lokale `.data/pins.json` | 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 (explorer/*, ipns/*, …) → proxyIPFS() → lokale .data-opslag (pins, files) → 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 → handleLocalUpload() → .data/files/- → daarna auto-pin in .data/pins.json → 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) → lokale .data-opslag → 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/` (8 modules, barrel via `index.ts`) Central API layer, opgesplitst per domein. `apiFetch()` (in `client.ts`) doet de fetch met `credentials: 'include'`; alle modules daaromheen zijn één regel per endpoint. | Module | Exporteert | Endpoint | |---|---|---| | `client.ts` | `apiFetch()` + gedeelde types | — | | `gateway.ts` | `getNodeInfo()`, `getPeers()`, `getRepoStats()`, `getBWStats()`, `checkHealth()` | `/api/node/info`, `/api/peers`, `/api/repo/stats`, `/api/bw/stats`, `/api/health` | | `pins.ts` | `listPins()` / `addPin()` / `removePin()` | `/api/pins` (lokaal) | | `files.ts` | `uploadFile()`, `listFiles()` | `/api/files/upload`, `/api/files` (lokaal) | | `users.ts` | `listUsers()` / `createUser()` / `deleteUser()` | `/api/users` (Python) | | `explorer.ts` | `explorerLs()` / `explorerCat()` / `explorerStat()` | `/api/explorer/*` (Kubo) | | `ipns.ts` | `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | `/api/ipns/*` (Kubo) | `src/lib/api.ts.bak` is een achtergebleven backup van het oude monolitische `api.ts` en wordt nergens geïmporteerd. ### `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` (121 lines) Catch-all API proxy. Routes: | Path | Handler | Backend | |---|---|---| | `/api/health` | `handleHealth()` | Inline JSON | | `/api/users*` | `proxyUserAPI()` | Python :8444 | | `/api/pins*`, `/api/files*` | `proxyIPFS()` → lokale handlers | `.data` op schijf | | `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 | De proxy zelf is 121 regels; de Kubo-vertaling staat in `proxy-ipfs.ts` en de write-gate in `write-auth.ts`. **`mapToKuboAPI(path, method)`** in `proxy-ipfs.ts` translates Portal API paths to Kubo RPC endpoints: - `/node/info` → `/api/v0/id` - `/peers` → `/api/v0/swarm/peers` - `/explorer/ls` → `/api/v0/ls` - `/ipns/publish` → `/api/v0/name/publish` - `/repo/stats` → `/api/v0/repo/stat` - `/bw/stats` → `/api/v0/bw` `pins`, `pins/` en `files/upload` hebben **geen** mapping: `proxyIPFS()` onderschept die paden vóór de Kubo-vertaling en serveert ze lokaal (zie onder). Voeg hier dus geen `pin/*`- of `add`-tak toe zonder `proxyIPFS()` en `isMutatingRequest()` mee te wijzigen. --- ## Lokale opslag (`.data/`) en de gateway-route ### Pins en files gaan niet naar Kubo `proxyIPFS()` (`src/app/api/[...path]/proxy-ipfs.ts`) behandelt `pins`, `pins/` en `files/*` volledig zelf. Deze paden bereiken de Kubo RPC nooit, ongeacht of `KUBO_API_URL` wel of niet gezet is — de lokale opslag werkt dus ook zonder Kubo. | Portal-endpoint | Handler | Effect | |---|---|---| | `GET /api/pins` | `handleLocalPins` | Leest `.data/pins.json` → Kubo-vorm `{ Keys: { : { Type } } }` | | `POST /api/pins` | `handleLocalPins` | `localAddPin(cid, name, size)` | | `DELETE /api/pins/` | `handleLocalPins` | `localRemovePin(cid)` | | `POST /api/files/upload` | `handleLocalUpload` | `localSaveFile()` + auto-pin | | `GET /api/files` | `handleLocalListFiles` | `localListFiles()` | | `GET /api/files/` | `handleLocalGetFile` | Serveert `.data/files/-` als attachment | | `DELETE /api/files/` | `handleLocalDeleteFile` | `localDeleteFile(cid)` | ### Opslaglayout | Pad | Inhoud | |---|---| | `.data/pins.json` | `Record` — `src/lib/local-pins.ts` | | `.data/files/-` | De ruwe bestandsbytes — `src/lib/local-files.ts` | | `.data/files.json` | `Record` | De "CID" van een lokaal opgeslagen bestand is geen echte IPFS-CID maar `"Qm" + sha256(inhoud).slice(0, 44)` (`generateCid()` in `local-files.ts`). Content is daarmee adresseerbaar binnen deze portal, maar niet op een ander IPFS-netwerk. `.data/` is runtime-state: het ontstaat bij het eerst uploaden en moet in een deploy een volume zijn, want een rebuild wist hem anders leeg. ⚠️ `.data/` staat op dit moment **niet** in `.gitignore` — voeg die regel toe voordat er per ongeluk runtime-state gecommit wordt. ### `GET /ipfs/` — de gateway-route `src/app/ipfs/[cid]/route.ts` is de gateway die deze share-links bedient. `gatewayLink()` in `src/lib/helpers.ts` bouwt alle deel-links als `/ipfs/`, wat precies dit pad volgt. | Request | Gedrag | |---|---| | `GET /ipfs/` | Valideert het CID-prefix (`Qm`/`bafy`/`bafk`), daarna `POST /api/v0/cat?arg=`, met Basic auth als `KUBO_BASIC_AUTH` gezet is | | `GET /ipfs/?filename=…` | Zet `Content-Disposition: inline; filename="…"` | De response draagt het `Content-Type` dat Kubo terugstuurt, met `application/octet-stream` als fallback als upstream niets bruikbaars stuurt. De body wordt volledig in geheugen geladen (`arrayBuffer()`) — voor grote bestanden is dat nog geen streaming. Foutpaden: `400` (ongeldig CID), `501` (zonder `KUBO_API_URL`), de Kubo-status zelf, of `502` bij een netwerkfout. --- ## 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/` | DELETE | ✅ | | `/api/pins` | POST | ✅ | | `/api/pins/` | DELETE | ✅ | | `/api/ipns/publish`, `/api/ipns/keys/gen/` | POST | ✅ | | `/api/users/` | DELETE | ✅ | | `/api/explorer/{ls,cat,stat,resolve}/` | POST | ❌ lees-only | | `/api/pins`, `/api/files`, `/api/explorer/*`, `/api/health` | GET | ❌ publiek | | `/ipfs/` (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://ipfs.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)