Compare commits

..

17 Commits

Author SHA1 Message Date
maikrolf bac719723d docs: ARCHITECTURE.md bijwerken naar de huidige generatie
- Gateway-host op regel 18 en in Configuration: maos.dedyn.io -> ipfs.maos.dedyn.io
  (config.ts gatewayUrl() en de CSP in next.config.ts)
- Upload-flow (quick + crypto): proxyIPFS -> handleLocalUpload() -> .data/files/
  met auto-pin, i.p.v. Kubo /api/v0/add
- Nieuwe sectie 'Lokale opslag (.data/) en de gateway-route': endpoint-tabel voor
  pins/files, opslaglayout, het feit dat een lokale CID geen echte IPFS-CID is,
  en GET /ipfs/<cid> inclusief content-type, Content-Disposition en foutpaden
- src/lib/api.ts (bestaat niet meer) -> src/lib/api/ als module-tabel; het
  achtergebleven api.ts.bak benoemd
- route.ts: ~320 -> 121 regels, met verwijzing naar proxy-ipfs.ts en write-auth.ts
- mapToKuboAPI-lijst: dode mappings /pins en /files/upload weg, met de reden
- Pages-tabel en API-flow noemden pins/files nog als Kubo-afhankelijk
- Schema en het bestaande write-auth-gedeelte ongemoeid
- Warnt expliciet dat .data/ nog niet in .gitignore staat
2026-10-01 21:53:08 +02:00
maikrolf 8e1231a7af refactor: verwijder dode Kubo-takken voor pins en files/upload
- mapToKuboAPI had pin/ls|add|rm- en add?pin=true-takken die onbereikbaar zijn:
  proxyIPFS onderschept pins/pins/<cid>/files/upload eerst en serveert ze uit
  de lokale .data-opslag
- Dode code weggehaald; comment boven mapToKuboAPI en bij de interceptie
  documenteert dat pins/files bewust lokaal blijven, zodat de volgende lezer
  het niet 'fixt'
- files/<subpad> -> /api/v0/ls blijft: alleen bereikbaar voor methoden die
  proxyIPFS niet lokaal afhandelt (GET/DELETE worden wel afgehandeld)
- De 4 assertions die de dode takken vastlegden zijn vervangen door tests die
  de generieke doorval beschrijven, plus een comment dat een teruggezette
  Kubo-tak de test rood maakt
- Nieuwe proxy-ipfs.test.ts test het werkelijke runtime-gedrag: pins en
  files/* raken Kubo nooit (ook niet bij een geldige KUBO_API_URL), terwijl
  explorer/* en peers wél doorvallen
2026-10-01 20:55:09 +02:00
maikrolf fa7de62707 fix: /ipfs/<cid> served alles als application/octet-stream
- detectContentType(cid) ignoreerde zijn input en gaf altijd octet-stream terug,
  terwijl de commentaar claimde dat Kubo het echte type bepaalt
- De response draagt nu het Content-Type dat Kubo cat terugstuurt
- resolveContentType() valt terug op application/octet-stream bij ontbrekend,
  leeg of ongeldig type; het patroon weigert CR/LF en quotes zodat upstream-
  headers niet in de response gespiegeld kunnen worden
- Streaming ongewijzigd (body nog volledig in geheugen), headers idem
- Nieuwe route-tests: content-type-passthrough, fallbackgevallen, Kubo-aanroep,
  body, overige headers en de ongewijzigde foutpaden (400/404/501/502)
2026-10-01 19:36:54 +02:00
maikrolf 0279282f7e fix: GatewayLink wijst naar ipfs.io in plaats van de eigen gateway
- Component hardcodede https://ipfs.io/ipfs/<cid>; de portal heeft zijn eigen
  gateway (NEXT_PUBLIC_GATEWAY_URL, fallback ipfs.maos.dedyn.io)
- Bouwt de link nu via de gedeelde gatewayLink() uit lib/helpers, dus zelfde
  basis-URL en /ipfs/-pad als alle andere share-links
- ?filename= gaat dezelfde route in, encoding ongewijzigd
- Nieuwe render-test (react-dom/server) dekt gateway-keuze, config-override
  en filename-query
2026-10-01 19:21:01 +02:00
maikrolf 4a5b146713 fix: share-links missen het /ipfs/-segment
- gatewayLink() bouwde <gateway>/<cid>; dat pad bestaat niet in deze app
- Nu <gateway>/ipfs/<cid>, wat overeenkomt met de route GET /ipfs/<cid>
- Gateway-basis komt uit gatewayUrl() in lib/config.ts (NEXT_PUBLIC_GATEWAY_URL)
- Optionele filename -> ?filename=, uitgeconsumed door dezelfde route
- downloadViaGateway() hergebruikt de helper i.p.v. eigen gateway-constant
  (valideerde fallback-host was de oude maos.dedyn.io)
- helpers.test.ts legde het foute pad vast; asserties vervangen door de
  werkelijke vorm + een test op de config-override
2026-10-01 19:13:43 +02:00
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
maikrolf d0632d4dcc feat: add IPFS gateway route /ipfs/:cid 2026-09-20 03:30:13 +02:00
maikrolf 841f89b1d5 feat: lokale file opslag zonder Kubo
- Bestanden uploaden naar .data/files/
- Auto-pin na upload
- Lijst, download en verwijder bestanden
- CID hash generatie via SHA-256
- 191 tests passing
2026-09-19 19:58:28 +02:00
maikrolf d3004506a4 cleanup: verwijder debug logging uit proxy-ipfs
- Lokale pin opslag stabiel
- Geen console.log meer
- 191 tests passing
2026-09-19 19:48:05 +02:00
maikrolf b7fb7daa4d debug: clone req body voor POST pins + verbose logging 2026-09-19 17:39:06 +02:00
maikrolf 5a454a2ddc debug: voeg console.log toe aan proxyIPFS voor lokale pins 2026-09-19 17:35:28 +02:00
maikrolf 729b052d08 fix: normalize path voor lokale pins - verwijder leading slash 2026-09-19 17:30:04 +02:00
maikrolf bdc6805b89 fix: altijd lokale pins gebruiken, ongeacht Kubo config 2026-09-19 17:20:21 +02:00
maikrolf b67e735e04 fix: local pin POST body lezing - lees als text voordat JSON parse 2026-09-19 17:15:02 +02:00
maikrolf c64ca2b8c2 feat: lokale pin opslag zonder Kubo
- JSON-file opslag in .data/pins.json
- Gebruikt wanneer KUBO_API_URL niet beschikbaar is
- Lokale pins voor development, Kubo voor productie
- 191 tests passing
2026-09-19 17:04:44 +02:00
maikrolf 361f11033d refactor: verwijder MAOSv6 wallet referenties
- Verwijder window.maosv6 provider uit wallet discovery
- Verwijder isMAOS veld uit DetectedWallet type
- Verwijder MAOS badge uit login page
- Update titel naar 'IPFS Portal' (geen MAOS meer)
- Update foutmeldingen naar wallet-agnostisch
- Update tests naar standaard wallet fallback
- 191 tests passing
2026-09-19 14:28:22 +02:00
maikrolf 7c7854e08f feat: SIWE auth, auto-pin, pin search/share, explorer improvements
- SIWE server-side auth (siwe + jose JWT)
- Wallet provider fallback (MAOS -> MetaMask)
- Auto-pin after upload (quick + crypto)
- Pin search/filter in explorer
- Share button (gateway link) per pin
- Copy CID button per pin
- Breadcrumb root navigation fix
- Hydration mismatch fix (mounted state)
- Defensive truncateCid for empty values
- CSP fix (template literal)
- Error boundaries (upload, profile, usage)
- 191 tests passing
2026-09-19 12:25:36 +02:00
46 changed files with 2920 additions and 248 deletions
+7
View File
@@ -22,3 +22,10 @@
# IPFS Gateway (for share links) # IPFS Gateway (for share links)
# NEXT_PUBLIC_GATEWAY_URL=https://ipfs.maos.dedyn.io # NEXT_PUBLIC_GATEWAY_URL=https://ipfs.maos.dedyn.io
# Write-authentication voor muterende /api/* endpoints
# (POST /api/add, /api/files/upload, /api/pins; DELETE /api/files/<cid>, /api/pins/<cid>).
# Zonder deze waarde blijven writes OPEN met een luide waarschuwing in de log.
# Zelfde waarde als IPFS_WRITE_TOKEN aan de MAOS-kant (anders pint MAOS niet mee).
# Genereer met: openssl rand -hex 32
# IPFS_WRITE_TOKEN=
@@ -1,10 +0,0 @@
{
"sessionID": "ses_0dccaec9affeokDqxzoESEknqc",
"updatedAt": "2026-07-02T15:56:27.603Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-02T15:56:27.603Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_0f6f91d66ffexLf0P0aWM1urQ2",
"updatedAt": "2026-06-27T20:06:35.253Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-27T20:06:35.253Z"
}
}
}
+145 -26
View File
@@ -15,7 +15,7 @@ IPFS Portal is a **Next.js SPA** (Single Page Application) that provides a brows
| Blockchain | zkSync Local (chain 270) | | Blockchain | zkSync Local (chain 270) |
| IPFS | Kubo RPC API (port 5001) | | IPFS | Kubo RPC API (port 5001) |
| User Mgmt | Python backend (port 8444) | | User Mgmt | Python backend (port 8444) |
| IPFS Gateway | `https://maos.dedyn.io/ipfs/<CID>` | | IPFS Gateway | `https://ipfs.maos.dedyn.io/ipfs/<CID>` |
--- ---
@@ -41,6 +41,7 @@ IPFS Portal is a **Next.js SPA** (Single Page Application) that provides a brows
│ 'health' → inline JSON │ │ 'health' → inline JSON │
│ 'users' → proxyUserAPI() → :8444 (Python) │ │ 'users' → proxyUserAPI() → :8444 (Python) │
│ default → proxyIPFS() → :5001 (Kubo) │ │ default → proxyIPFS() → :5001 (Kubo) │
│ pins/files → lokale .data-opslag (nooit Kubo) │
│ │ │ │
│ mapToKuboAPI(path) → /api/v0/* translation │ │ mapToKuboAPI(path) → /api/v0/* translation │
└─────────────────────┬───────────────────────────────┘ └─────────────────────┬───────────────────────────────┘
@@ -80,13 +81,13 @@ IPFS Portal is a **Next.js SPA** (Single Page Application) that provides a brows
| Page | Route | Lines | Backend Dependencies | Description | | Page | Route | Lines | Backend Dependencies | Description |
|---|---|---|---|---| |---|---|---|---|---|
| Landing | `/` | ~130 | Kubo (health) | Hero + connect wallet + quick stats | | Landing | `/` | ~130 | Kubo (health) | Hero + connect wallet + quick stats |
| Dashboard | `/dashboard` | 253 | Kubo (peers, pins, health, repo, bw) | Node overview, stats, bandwidth | | Dashboard | `/dashboard` | 253 | Kubo (peers, repo/stats), inline health, lokale pins | Node overview, stats, bandwidth |
| Upload | `/upload` | **590** | Kubo + Smart Contract | Quick + crypto file upload | | Upload | `/upload` | **590** | Lokale `.data`-opslag + Smart Contract | Quick + crypto file upload |
| Explorer | `/explorer` | 244 | Kubo (ls, cat) | IPFS content browser | | Explorer | `/explorer` | 244 | Kubo (ls, cat) + lokale pins | IPFS content browser |
| IPNS | `/ipns` | 285 | Kubo (keys, publish, resolve) | IPNS name management | | IPNS | `/ipns` | 285 | Kubo (keys, publish, resolve) | IPNS name management |
| Users | `/users` | 275 | Python API + Smart Contract | htpasswd users + wallet lookup | | Users | `/users` | 275 | Python API + Smart Contract | htpasswd users + wallet lookup |
| Peers | `/peers` | 63 | Kubo (swarm/peers) | Connected peers list | | Peers | `/peers` | 63 | Kubo (swarm/peers) | Connected peers list |
| Pins | `/pins` | 167 | Kubo (pin/ls, add, rm) | Pin management | | Pins | `/pins` | 167 | Lokale `.data/pins.json` | Pin management |
| Admin/Payment | `/admin/payment` | **536** | Smart Contract | Owner payment dashboard | | Admin/Payment | `/admin/payment` | **536** | Smart Contract | Owner payment dashboard |
| Settings | `/settings` | 319 | localStorage only | App configuration | | Settings | `/settings` | 319 | localStorage only | App configuration |
| History | `/history` | 393 | localStorage only | Upload history viewer | | History | `/history` | 393 | localStorage only | Upload history viewer |
@@ -105,7 +106,8 @@ Page Component
→ fetch('/api/endpoint', { credentials: 'include' }) → fetch('/api/endpoint', { credentials: 'include' })
→ Next.js [...path]/route.ts → Next.js [...path]/route.ts
→ matchRoute(path) → matchRoute(path)
→ proxyIPFS() → Kubo RPC :5001 → proxyIPFS() → Kubo RPC :5001 (explorer/*, ipns/*, …)
→ proxyIPFS() → lokale .data-opslag (pins, files)
→ proxyUserAPI() → Python :8444 → proxyUserAPI() → Python :8444
``` ```
@@ -116,7 +118,8 @@ User drags file
→ UploadPage state: FileEntry[] → UploadPage state: FileEntry[]
→ startUpload() — sequential per file → startUpload() — sequential per file
→ uploadFile(file) → apiFetch POST /api/files/upload (FormData) → uploadFile(file) → apiFetch POST /api/files/upload (FormData)
→ proxyIPFS → Kubo /api/v0/add → proxyIPFS → handleLocalUpload() → .data/files/<CID>-<naam>
→ daarna auto-pin in .data/pins.json
→ addToHistory({ cid, name, size }) → localStorage → addToHistory({ cid, name, size }) → localStorage
→ Show results (CID, gateway link, copy) → Show results (CID, gateway link, copy)
``` ```
@@ -131,7 +134,7 @@ User selects file
→ PaymentService.getSupportedTokens() → [ETH, USDC, MAOS] → PaymentService.getSupportedTokens() → [ETH, USDC, MAOS]
→ PaymentService.getMAOSDiscountTiers() → discount % → PaymentService.getMAOSDiscountTiers() → discount %
→ User pays → User pays
→ Step 1: uploadFile(file) → Kubo → get CID → Step 1: uploadFile(file) → lokale .data-opslag → get CID
→ Step 2a (free): onPaid({ method: 'free' }) → Step 2a (free): onPaid({ method: 'free' })
→ Step 2b (ETH): payWithETH(provider, cid, size, wei) → Step 2b (ETH): payWithETH(provider, cid, size, wei)
→ sendTransaction → contract.payWithETH(cid, bytesSize) → sendTransaction → contract.payWithETH(cid, bytesSize)
@@ -156,21 +159,24 @@ Admin connects wallet
## Key Modules ## Key Modules
### `src/lib/api.ts` (217 lines) ### `src/lib/api/` (8 modules, barrel via `index.ts`)
Central API layer. Exports: Central API layer, opgesplitst per domein. `apiFetch()` (in `client.ts`) doet de
fetch met `credentials: 'include'`; alle modules daaromheen zijn één regel per
endpoint.
| Function | HTTP | Endpoint | Category | | Module | Exporteert | Endpoint |
|---|---|---|---| |---|---|---|
| `getNodeInfo()` | GET | `/api/node/info` | Node | | `client.ts` | `apiFetch()` + gedeelde types | — |
| `getPeers()` | GET | `/api/peers` | Peers | | `gateway.ts` | `getNodeInfo()`, `getPeers()`, `getRepoStats()`, `getBWStats()`, `checkHealth()` | `/api/node/info`, `/api/peers`, `/api/repo/stats`, `/api/bw/stats`, `/api/health` |
| `listPins()` / `addPin()` / `removePin()` | GET/POST/DELETE | `/api/pins` | Pins | | `pins.ts` | `listPins()` / `addPin()` / `removePin()` | `/api/pins` (lokaal) |
| `uploadFile(file)` | POST (FormData) | `/api/files/upload` | Files | | `files.ts` | `uploadFile()`, `listFiles()` | `/api/files/upload`, `/api/files` (lokaal) |
| `listUsers()` / `createUser()` / `deleteUser()` | GET/POST/DELETE | `/api/users` | Users | | `users.ts` | `listUsers()` / `createUser()` / `deleteUser()` | `/api/users` (Python) |
| `explorerLs()` / `explorerCat()` / `explorerStat()` | POST | `/api/explorer/*` | Explorer | | `explorer.ts` | `explorerLs()` / `explorerCat()` / `explorerStat()` | `/api/explorer/*` (Kubo) |
| `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | GET/POST | `/api/ipns/*` | IPNS | | `ipns.ts` | `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | `/api/ipns/*` (Kubo) |
| `getRepoStats()` / `getBWStats()` | GET | `/api/repo` / `/api/bw` | Stats |
| `checkHealth()` | GET | `/api/health` | Health | `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) ### `src/lib/wallet.ts` (196 lines)
@@ -200,7 +206,7 @@ localStorage persistence.
- `PortalSettings`: gateway URL, API endpoint, storage max, refresh, theme - `PortalSettings`: gateway URL, API endpoint, storage max, refresh, theme
- `UploadRecord`: history with dedup by CID+name, capped at 500 entries - `UploadRecord`: history with dedup by CID+name, capped at 500 entries
### `src/app/api/[...path]/route.ts` (~320 lines) ### `src/app/api/[...path]/route.ts` (121 lines)
Catch-all API proxy. Routes: Catch-all API proxy. Routes:
@@ -208,18 +214,131 @@ Catch-all API proxy. Routes:
|---|---|---| |---|---|---|
| `/api/health` | `handleHealth()` | Inline JSON | | `/api/health` | `handleHealth()` | Inline JSON |
| `/api/users*` | `proxyUserAPI()` | Python :8444 | | `/api/users*` | `proxyUserAPI()` | Python :8444 |
| `/api/pins*`, `/api/files*` | `proxyIPFS()` → lokale handlers | `.data` op schijf |
| `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 | | `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 |
**`mapToKuboAPI(path)`** translates Portal API paths to Kubo RPC endpoints: 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` - `/node/info` → `/api/v0/id`
- `/peers` → `/api/v0/swarm/peers` - `/peers` → `/api/v0/swarm/peers`
- `/pins` → `/api/v0/pin/ls|add|rm`
- `/files/upload` → `/api/v0/add`
- `/explorer/ls` → `/api/v0/ls` - `/explorer/ls` → `/api/v0/ls`
- `/ipns/publish` → `/api/v0/name/publish` - `/ipns/publish` → `/api/v0/name/publish`
- `/repo/stats` → `/api/v0/repo/stat` - `/repo/stats` → `/api/v0/repo/stat`
- `/bw/stats` → `/api/v0/bw` - `/bw/stats` → `/api/v0/bw`
`pins`, `pins/<cid>` 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/<cid>` 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: { <cid>: { Type } } }` |
| `POST /api/pins` | `handleLocalPins` | `localAddPin(cid, name, size)` |
| `DELETE /api/pins/<cid>` | `handleLocalPins` | `localRemovePin(cid)` |
| `POST /api/files/upload` | `handleLocalUpload` | `localSaveFile()` + auto-pin |
| `GET /api/files` | `handleLocalListFiles` | `localListFiles()` |
| `GET /api/files/<cid>` | `handleLocalGetFile` | Serveert `.data/files/<cid>-<naam>` als attachment |
| `DELETE /api/files/<cid>` | `handleLocalDeleteFile` | `localDeleteFile(cid)` |
### Opslaglayout
| Pad | Inhoud |
|---|---|
| `.data/pins.json` | `Record<cid, { cid, name, size, created }>` — `src/lib/local-pins.ts` |
| `.data/files/<cid>-<naam>` | De ruwe bestandsbytes — `src/lib/local-files.ts` |
| `.data/files.json` | `Record<cid, { cid, name, size, modified, path }>` |
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/<cid>` — 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
`<NEXT_PUBLIC_GATEWAY_URL>/ipfs/<cid>`, wat precies dit pad volgt.
| Request | Gedrag |
|---|---|
| `GET /ipfs/<cid>` | Valideert het CID-prefix (`Qm`/`bafy`/`bafk`), daarna `POST <KUBO_API_URL>/api/v0/cat?arg=<cid>`, met Basic auth als `KUBO_BASIC_AUTH` gezet is |
| `GET /ipfs/<cid>?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/<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) ## Smart Contract (Chain 270 — zkSync Local)
@@ -299,7 +418,7 @@ Appears as 3 disconnected communities in the graph but is one continuous flow at
- **Kubo RPC**: `http://127.0.0.1:5001` - **Kubo RPC**: `http://127.0.0.1:5001`
- **Python API**: `http://192.168.1.176:8444` (htpasswd + admin key) - **Python API**: `http://192.168.1.176:8444` (htpasswd + admin key)
- **Smart Contract**: `0xCBc6...` on chain 270 (zkSync Local) - **Smart Contract**: `0xCBc6...` on chain 270 (zkSync Local)
- **IPFS Gateway**: `https://maos.dedyn.io/ipfs/` - **IPFS Gateway**: `https://ipfs.maos.dedyn.io/ipfs/`
- **Refresh Interval**: 15s (Dashboard) - **Refresh Interval**: 15s (Dashboard)
- **Max Uploads**: 20 files, 100MB each - **Max Uploads**: 20 files, 100MB each
- **Storage Max**: 30 GB (localStorage setting) - **Storage Max**: 30 GB (localStorage setting)
+5 -2
View File
@@ -1,12 +1,15 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
// Haal de RPC URL op uit environment variabelen
const zkSyncRpcUrl = process.env.NEXT_PUBLIC_ZKSYNC_RPC_URL || 'http://localhost:3050';
const csp = [ const csp = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // nodig voor Next.js "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://ipfs.maos.dedyn.io https://*.maos.dedyn.io", "img-src 'self' data: blob: https://ipfs.maos.dedyn.io https://*.maos.dedyn.io",
"media-src 'self' https://ipfs.maos.dedyn.io https://*.maos.dedyn.io", "media-src 'self' https://ipfs.maos.dedyn.io https://*.maos.dedyn.io",
"connect-src 'self' ws: wss: ${process.env.NEXT_PUBLIC_ZKSYNC_RPC_URL || 'http://localhost:3050'}", `connect-src 'self' ws: wss: ${zkSyncRpcUrl}`,
"frame-src 'self'", "frame-src 'self'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
+169
View File
@@ -16,6 +16,7 @@
"postcss": "^8.5.15", "postcss": "^8.5.15",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"siwe": "^3.0.0",
"swr": "^2.4.2", "swr": "^2.4.2",
"viem": "^2.29.0" "viem": "^2.29.0"
}, },
@@ -1106,6 +1107,47 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@spruceid/siwe-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@spruceid/siwe-parser/-/siwe-parser-3.0.0.tgz",
"integrity": "sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==",
"license": "Apache-2.0",
"dependencies": {
"@noble/hashes": "^1.1.2",
"apg-js": "^4.4.0"
}
},
"node_modules/@stablelib/binary": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz",
"integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==",
"license": "MIT",
"dependencies": {
"@stablelib/int": "^1.0.1"
}
},
"node_modules/@stablelib/int": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz",
"integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==",
"license": "MIT"
},
"node_modules/@stablelib/random": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz",
"integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==",
"license": "MIT",
"dependencies": {
"@stablelib/binary": "^1.0.1",
"@stablelib/wipe": "^1.0.1"
}
},
"node_modules/@stablelib/wipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz",
"integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1577,6 +1619,19 @@
} }
} }
}, },
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT",
"peer": true
},
"node_modules/apg-js": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz",
"integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==",
"license": "BSD-2-Clause"
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1706,6 +1761,107 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/ethers": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz",
"integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@adraffy/ens-normalize": "1.11.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.21.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ethers/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ethers/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ethers/node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/ethers/node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD",
"peer": true
},
"node_modules/ethers/node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT",
"peer": true
},
"node_modules/ethers/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -2416,6 +2572,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/siwe": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/siwe/-/siwe-3.0.0.tgz",
"integrity": "sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==",
"license": "Apache-2.0",
"dependencies": {
"@spruceid/siwe-parser": "^3.0.0",
"@stablelib/random": "^1.0.1"
},
"peerDependencies": {
"ethers": "^5.6.8 || ^6.0.8"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"postcss": "^8.5.15", "postcss": "^8.5.15",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"siwe": "^3.0.0",
"swr": "^2.4.2", "swr": "^2.4.2",
"viem": "^2.29.0" "viem": "^2.29.0"
}, },
+1 -2
View File
@@ -136,8 +136,7 @@ export default function AdminPaymentPage() {
const switched = await switchToChain270(prov || undefined); const switched = await switchToChain270(prov || undefined);
if (switched) setWrongChain(false); if (switched) setWrongChain(false);
} }
if (window.maosv6) setWalletType('MAOS Wallet'); if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask'); else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet'); else setWalletType('Wallet');
} catch (e: unknown) { } catch (e: unknown) {
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
/* ── Runtime-routing van proxyIPFS ──
*
* Audit: `mapToKuboAPI()` bevatte takken voor `pins` en `files/upload` die in
* runtime nooit bereikt worden — `proxyIPFS()` onderschept die paden eerder
* en serveert ze uit de lokale `.data`-opslag. De route-tests assertten die
* dode takken en gaven zo valse zekerheid.
*
* Deze tests beschrijven het WERKELIJKE gedrag: `pins` en `files/*` worden
* lokaal afgehandeld en bereiken Kubo nooit. Kubo-pass-through wordt alleen
* getest voor paden die daadwerkelijk doorvallen (explorer/*, ipns/*, …).
*/
vi.mock('@/lib/local-pins', () => ({
localListPins: vi.fn(async () => [{ cid: 'QmTest', name: 'a.txt', size: 3, created: 'indirect' }]),
localAddPin: vi.fn(async () => ({ cid: 'QmTest' })),
localRemovePin: vi.fn(async () => {}),
}));
vi.mock('@/lib/local-files', () => ({
localSaveFile: vi.fn(async () => ({ cid: 'QmSaved', size: 5 })),
localGetFile: vi.fn(async () => ({ buffer: Buffer.from('hallo'), name: 'a.txt' })),
localListFiles: vi.fn(async () => [{ cid: 'QmSaved', name: 'a.txt', size: 5, modified: 'x', path: 'a.txt' }]),
localDeleteFile: vi.fn(async () => {}),
}));
import { proxyIPFS } from '../proxy-ipfs';
import { localListPins, localAddPin, localRemovePin } from '@/lib/local-pins';
import { localSaveFile, localGetFile, localListFiles, localDeleteFile } from '@/lib/local-files';
/** Kubo-stand-in: als er een Kubo-call komt, ziet de test dat aan de URL. */
const kuboFetch = vi.fn(async (_url: string, _init: RequestInit) =>
new Response(JSON.stringify({ Id: 'kubo' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
function req(path: string, method = 'GET', body?: BodyInit | null): NextRequest {
return new NextRequest(`http://localhost/api/${path.replace(/^\/+/, '')}`, {
method,
...(body !== undefined && body !== null ? { body } : {}),
});
}
function call(path: string, method = 'GET', body?: BodyInit | null) {
return proxyIPFS('/' + path.replace(/^\/+/, ''), method, req(path, method, body));
}
beforeEach(() => {
vi.clearAllMocks();
process.env.KUBO_API_URL = 'http://kubo.test:5001';
vi.stubGlobal('fetch', kuboFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.KUBO_API_URL;
});
/* ═════════════════════ pins — altijd lokaal ═════════════════════ */
describe('pins worden lokaal behandeld, niet naar Kubo gestuurd', () => {
it('GET /api/pins leest de lokale pin-opslag', async () => {
const res = await call('pins');
expect(res.status).toBe(200);
expect(localListPins).toHaveBeenCalledTimes(1);
expect(await res.json()).toEqual({ Keys: { QmTest: { Type: 'indirect' } } });
});
it('GET /api/pins bereikt Kubo niet', async () => {
await call('pins');
expect(kuboFetch).not.toHaveBeenCalled();
});
it('POST /api/pins schrijft naar de lokale pin-opslag', async () => {
const res = await call(
'pins',
'POST',
JSON.stringify({ cid: 'QmNieuw', name: 'b.txt', size: 7 }),
);
expect(res.status).toBe(200);
expect(localAddPin).toHaveBeenCalledWith('QmNieuw', 'b.txt', 7);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('POST /api/pins zonder cid weigert met 400', async () => {
const res = await call('pins', 'POST', JSON.stringify({ name: 'b.txt' }));
expect(res.status).toBe(400);
expect(localAddPin).not.toHaveBeenCalled();
});
it('DELETE /api/pins/<cid> verwijdert uit de lokale pin-opslag', async () => {
const res = await call('pins/QmTest', 'DELETE');
expect(res.status).toBe(200);
expect(localRemovePin).toHaveBeenCalledWith('QmTest');
expect(kuboFetch).not.toHaveBeenCalled();
});
});
/* ═════════════════════ files — altijd lokaal ═════════════════════ */
describe('files worden lokaal behandeld, niet naar Kubo gestuurd', () => {
it('POST /api/files/upload slaat op in .data/files en pint lokaal', async () => {
const form = new FormData();
form.set('file', new File(['hallo'], 'a.txt', { type: 'text/plain' }));
const res = await call('files/upload', 'POST', form);
expect(res.status).toBe(200);
expect(localSaveFile).toHaveBeenCalledTimes(1);
expect(vi.mocked(localSaveFile).mock.calls[0][1]).toBeInstanceOf(Buffer);
expect(localAddPin).toHaveBeenCalledWith('QmSaved', 'a.txt', 5);
// Kern van de fix: geen Kubo /api/v0/add.
expect(kuboFetch).not.toHaveBeenCalled();
});
it('POST /api/files/upload zonder bestand weigert met 400', async () => {
const res = await call('files/upload', 'POST', new FormData());
expect(res.status).toBe(400);
expect(localSaveFile).not.toHaveBeenCalled();
expect(kuboFetch).not.toHaveBeenCalled();
});
it('GET /api/files leest de lokale file-opslag', async () => {
const res = await call('files');
expect(res.status).toBe(200);
expect(localListFiles).toHaveBeenCalledTimes(1);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('GET /api/files/<cid> leest lokaal, ook voor onbekende CID', async () => {
const res = await call('files/QmTest');
expect(localGetFile).toHaveBeenCalledWith('QmTest');
expect(kuboFetch).not.toHaveBeenCalled();
});
it('DELETE /api/files/<cid> verwijdert lokaal', async () => {
const res = await call('files/QmTest', 'DELETE');
expect(res.status).toBe(200);
expect(localDeleteFile).toHaveBeenCalledWith('QmTest');
expect(kuboFetch).not.toHaveBeenCalled();
});
});
/* ═════════════════════ overige paden gaan wél naar Kubo ═════════════════════ */
describe('overige paden vallen door naar Kubo', () => {
it('GET /api/explorer/ls/<cid> gaat naar /api/v0/ls', async () => {
await call('explorer/ls/QmTest');
expect(kuboFetch).toHaveBeenCalledTimes(1);
expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/ls?arg=QmTest');
});
it('POST /api/explorer/cat/<cid> gaat naar /api/v0/cat', async () => {
await call('explorer/cat/QmTest', 'POST');
expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/cat?arg=QmTest');
});
it('GET /api/peers gaat naar /api/v0/swarm/peers', async () => {
await call('peers');
expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/swarm/peers');
});
it('geeft 501 zonder KUBO_API_URL', async () => {
delete process.env.KUBO_API_URL;
const res = await call('peers');
expect(res.status).toBe(501);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('lokale opslag blijft werken zonder KUBO_API_URL', async () => {
delete process.env.KUBO_API_URL;
const res = await call('pins');
expect(res.status).toBe(200);
expect(localListPins).toHaveBeenCalledTimes(1);
});
});
+17 -14
View File
@@ -66,24 +66,27 @@ describe('mapToKuboAPI', () => {
expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers'); expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers');
}); });
it('maps pins GET to /api/v0/pin/ls', () => { it('heeft GEEN tak voor pins — die worden lokaal afgehandeld', () => {
expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls'); // `proxyIPFS()` onderschept `pins`/`pins/<cid>` vóór de Kubo-mapping, dus
// `mapToKuboAPI` valt door naar de generieke /api/v0/<pad>. Zou er ooit weer
// een `case 'pins'` bij komen, dan faalt deze test.
expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pins');
expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pins');
expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pins/QmTest123');
}); });
it('maps pins POST to /api/v0/pin/add', () => { it('heeft GEEN specifieke tak voor files/upload — die wordt lokaal afgehandeld', () => {
expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add'); // De `files/upload`-tak naar Kubo /api/v0/add is weg; het pad valt door op de
// generieke `files/`-tak. Onbereikbaar in runtime (proxyIPFS onderscheept
// POST /api/files/upload), dus de concrete waarde doet er niet toe — zolang
// er geen specifieke `files/upload`-tak terugkomt.
expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/ls');
}); });
it('maps pins/rm to rm with CID', () => { it('mapt files/<subpad> naar ls (alleen bereikbaar zonder GET/DELETE)', () => {
expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123'); // GET en DELETE op `files/*` worden lokaal afgehandeld; deze tak is dus
}); // alleen relevant voor overige methoden.
expect(mapToKuboAPI('files/QmTest123', 'POST')).toBe('/api/v0/ls');
it('maps files/upload to add with pin', () => {
expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/add?pin=true');
});
it('maps files/list to ls', () => {
expect(mapToKuboAPI('files/list', 'GET')).toBe('/api/v0/ls');
}); });
it('maps explorer/ls/<cid>', () => { it('maps explorer/ls/<cid>', () => {
@@ -0,0 +1,275 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
/* ── Write-token enforcement tests ──
*
* Audit (2026-09-28): POST /api/add was reachable without any token.
* Deze tests locken het gedrag van de catch-all /api/* route:
* - muterende endpoints → 401/403 zonder token (en zonder sessie)
* - muterende endpoints met correct Bearer-token → handler wél bereikt
* - lees-endpoints → ook zonder token nog steeds 200
*
* De Kubo/local-afhankelijkheden zijn gemockt zodat tests geen netwerk
* of schijf nodig hebben; alleen de route-handler + gate zelf draait echt.
*/
vi.mock('@/lib/local-pins', () => ({
localListPins: vi.fn(async () => []),
localAddPin: vi.fn(async () => ({ cid: 'QmTest' })),
localRemovePin: vi.fn(async () => {}),
}));
vi.mock('@/lib/local-files', () => ({
localSaveFile: vi.fn(async () => ({ cid: 'QmTest', size: 1, name: 'a.txt' })),
localGetFile: vi.fn(async () => null),
localListFiles: vi.fn(async () => []),
localDeleteFile: vi.fn(async () => {}),
}));
import { GET, POST, DELETE } from '../route';
import { GET as gatewayGET } from '@/app/ipfs/[cid]/route';
import { createSessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
import { localAddPin } from '@/lib/local-pins';
const WRITE_TOKEN = 'test-write-token-123';
/** Kubo-stand-in: elke proxied Kubo-call krijgt een 200 terug. */
const kuboFetch = vi.fn(
async () => new Response(JSON.stringify({ Hash: 'QmTest', Size: '1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
/** Minimale init-shape voor tests — vermijdt DOM/undici RequestInit-conflict. */
interface TestInit {
method?: string;
headers?: Record<string, string>;
body?: string;
sessionJwt?: string;
}
function makeReq(apiPath: string, init: TestInit = {}): NextRequest {
const { sessionJwt, headers = {}, body, method = 'GET' } = init;
const h = new Headers(headers);
if (sessionJwt) h.set('cookie', `${SESSION_COOKIE}=${sessionJwt}`);
return new NextRequest(`http://localhost/api/${apiPath}`, { method, headers: h, body });
}
function call(
handler: typeof POST,
apiPath: string,
init: TestInit = {},
) {
return handler(makeReq(apiPath, init), {
params: Promise.resolve({ path: apiPath.split('/').filter(Boolean) }),
});
}
beforeEach(() => {
vi.clearAllMocks();
process.env.IPFS_WRITE_TOKEN = WRITE_TOKEN;
process.env.JWT_SECRET = 'test-jwt-secret';
process.env.KUBO_API_URL = 'http://kubo.test:5001';
vi.stubGlobal('fetch', kuboFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.IPFS_WRITE_TOKEN;
});
/* ════════════════════════ Niet-geconfigureerde token ════════════════════════ */
describe('IPFS_WRITE_TOKEN niet geconfigureerd', () => {
// Moet als eerste: het warning-log is once-per-process.
it('houdt muterende endpoints open en logt een luide waarschuwing', async () => {
delete process.env.IPFS_WRITE_TOKEN;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = await call(POST, 'pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cid: 'QmTest' }),
});
const second = await call(POST, 'pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cid: 'QmTest' }),
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0] ?? '')).toMatch(/IPFS_WRITE_TOKEN/);
warn.mockRestore();
});
});
/* ════════════════════════ Muterende endpoints — geen token ════════════════════════ */
describe('muterende endpoints zonder token', () => {
it('POST /api/pins → 401 en handler niet bereikt', async () => {
const res = await call(POST, 'pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cid: 'QmTest' }),
});
expect(res.status).toBe(401);
expect(localAddPin).not.toHaveBeenCalled();
});
it('POST /api/add → 401 (audit-endpoint) en Kubo niet aangeroepen', async () => {
const res = await call(POST, 'add', { method: 'POST' });
expect(res.status).toBe(401);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('POST /api/files/upload → 401', async () => {
const res = await call(POST, 'files/upload', { method: 'POST' });
expect(res.status).toBe(401);
});
it('DELETE /api/pins/<cid> → 401', async () => {
const res = await call(DELETE, 'pins/QmTest', { method: 'DELETE' });
expect(res.status).toBe(401);
});
it('DELETE /api/files/<cid> → 401', async () => {
const res = await call(DELETE, 'files/QmTest', { method: 'DELETE' });
expect(res.status).toBe(401);
});
it('POST /api/ipns/publish → 401', async () => {
const res = await call(POST, 'ipns/publish', { method: 'POST' });
expect(res.status).toBe(401);
});
it('POST /api/users/<name>... gebruikersverwijdering via DELETE → 401', async () => {
const res = await call(DELETE, 'users/bob', { method: 'DELETE' });
expect(res.status).toBe(401);
});
});
/* ════════════════════════ Muterende endpoints — token ════════════════════════ */
describe('muterende endpoints met token', () => {
it('POST /api/pins met correct Bearer-token → handler bereikt', async () => {
const res = await call(POST, 'pins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${WRITE_TOKEN}`,
},
body: JSON.stringify({ cid: 'QmTest' }),
});
expect(res.status).toBe(200);
expect(localAddPin).toHaveBeenCalledTimes(1);
});
it('POST /api/add met correct Bearer-token → handler bereikt (Kubo-aanroep)', async () => {
const res = await call(POST, 'add', {
method: 'POST',
headers: { Authorization: `Bearer ${WRITE_TOKEN}` },
});
expect(res.status).toBe(200);
expect(kuboFetch).toHaveBeenCalledTimes(1);
});
it('POST /api/pins met verkeerd token → 403 en handler niet bereikt', async () => {
const res = await call(POST, 'pins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer wrong-token',
},
body: JSON.stringify({ cid: 'QmTest' }),
});
expect(res.status).toBe(403);
expect(localAddPin).not.toHaveBeenCalled();
});
it('POST /api/pins met verkeerd token (zelfde lengte) → 403', async () => {
const res = await call(POST, 'pins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${'x'.repeat(WRITE_TOKEN.length)}`,
},
body: JSON.stringify({ cid: 'QmTest' }),
});
expect(res.status).toBe(403);
});
it('POST /api/pins met bestaande sessie-cookie (geen bearer) → handler bereikt', async () => {
const jwt = await createSessionJWT({
address: '0xabc',
role: 'user',
sessionToken: 'sess-1',
});
const res = await call(POST, 'pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cid: 'QmTest' }),
sessionJwt: jwt,
});
expect(res.status).toBe(200);
expect(localAddPin).toHaveBeenCalledTimes(1);
});
});
/* ════════════════════════ Lees-endpoints — publiek ════════════════════════ */
describe('lees-endpoints blijven publiek', () => {
it('GET /api/pins zonder token → 200', async () => {
const res = await call(GET, 'pins', { method: 'GET' });
expect(res.status).toBe(200);
});
it('GET /api/files zonder token → 200', async () => {
const res = await call(GET, 'files', { method: 'GET' });
expect(res.status).toBe(200);
});
it('GET /api/explorer/ls/<cid> zonder token → 200', async () => {
const res = await call(GET, 'explorer/ls/QmTest', { method: 'GET' });
expect(res.status).toBe(200);
});
it('POST /api/explorer/cat/<cid> (read-only POST) zonder token → handler bereikt', async () => {
const res = await call(POST, 'explorer/cat/QmTest', { method: 'POST' });
expect(res.status).toBe(200);
expect(kuboFetch).toHaveBeenCalledTimes(1);
});
it('GET /ipfs/<cid> gateway zonder token → 200', async () => {
const res = await gatewayGET(
new NextRequest('http://localhost/ipfs/QmTestCID000'),
{ params: Promise.resolve({ cid: 'QmTestCID000' }) },
);
expect(res.status).toBe(200);
});
});
/* ════════════════════════ Uitzondering: registratie ════════════════════════ */
describe('registratie blijft bereikbaar', () => {
it('POST /api/users (aanmaken account) zonder token → handler bereikt', async () => {
const res = await call(POST, 'users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'nieuw', password: 'secret1' }),
});
expect(res.status).toBe(200);
});
});
+158 -12
View File
@@ -1,13 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config'; import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
import { localListPins, localAddPin, localRemovePin } from '@/lib/local-pins';
import { localSaveFile, localGetFile, localListFiles, localDeleteFile } from '@/lib/local-files';
export async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> { export async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
// In dev, the Kubo API is behind nginx with Basic Auth. // Normalize path — verwijder leading/trailing slashes
// If no KUBO_API_URL is configured, return a clear error. const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
// ── Lokale pin opslag (altijd) ──
// Pins gaan NIET naar Kubo: `pins` en `pins/<cid>` worden uit `.data/pins.json`
// gelezen en daarin geschreven. Deze interceptie gebeurt VÓÓR de Kubo-mapping,
// dus `mapToKuboAPI()` hoeft (en mag) geen `pins`-tak te hebben. Zie de
// doc-comment boven `mapToKuboAPI` — of dit bewust zo blijft, hangt samen met
// de lopende vraag of pinning via `?pin=true` moet.
if (p === 'pins' || p.startsWith('pins/')) {
return handleLocalPins(p, method, req);
}
// ── Lokale file opslag (altijd) ──
// Ook uploads lopen niet via Kubo `/api/v0/add`: het bestand gaat naar
// `.data/files/<cid>-<naam>` met metadata in `.data/files.json`, en wordt
// daarna lokaal gepind. Idem voor lijst/get/delete. Vóór de Kubo-mapping,
// dus `mapToKuboAPI()` hoeft geen `files/upload`-tak te hebben.
if (p === 'files/upload' && method === 'POST') {
return handleLocalUpload(req);
}
if (p === 'files' && method === 'GET') {
return handleLocalListFiles();
}
if (p.startsWith('files/') && method === 'GET') {
const cid = p.slice(6);
return handleLocalGetFile(cid);
}
if (p.startsWith('files/') && method === 'DELETE') {
const cid = p.slice(6);
return handleLocalDeleteFile(cid);
}
// IPFS routes gaan naar Kubo (wanneer beschikbaar)
const kuboSvc = kuboApiUrl(); const kuboSvc = kuboApiUrl();
if (!kuboSvc) { if (!kuboSvc) {
return NextResponse.json( return NextResponse.json(
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' }, { error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env' },
{ status: 501 } { status: 501 }
); );
} }
@@ -75,6 +109,125 @@ export async function proxyIPFS(path: string, method: string, req: NextRequest):
}); });
} }
/** Lokale pin handler — werkt zonder Kubo API */
async function handleLocalPins(path: string, method: string, req: NextRequest): Promise<Response> {
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
// GET /api/pins — lijst alle pins
if (p === 'pins' && method === 'GET') {
const pins = await localListPins();
// Retourneer in Kubo-formaat voor backwards compatibiliteit
const Keys: Record<string, { Type: string }> = {};
for (const pin of pins) {
Keys[pin.cid] = { Type: pin.created || 'indirect' };
}
return NextResponse.json({ Keys });
}
// POST /api/pins — pin een CID
if (p === 'pins' && method === 'POST') {
try {
const clonedReq = req.clone();
const text = await clonedReq.text();
if (!text) {
return NextResponse.json({ error: 'Empty body' }, { status: 400 });
}
const body = JSON.parse(text);
const { cid, name, size } = body;
if (!cid) {
return NextResponse.json({ error: 'cid required' }, { status: 400 });
}
await localAddPin(cid, name, size || 0);
return NextResponse.json({ cid });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
// DELETE /api/pins/<cid> — verwijder pin
if (p.startsWith('pins/') && method === 'DELETE') {
const cid = p.slice(5);
await localRemovePin(cid);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
/** Lokale file upload handler */
async function handleLocalUpload(req: NextRequest): Promise<Response> {
try {
const clonedReq = req.clone();
const formData = await clonedReq.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Lees bestand als buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Sla lokaal op
const result = await localSaveFile(file.name, buffer, file.size);
// Auto-pin na upload
const { localAddPin } = await import('@/lib/local-pins');
await localAddPin(result.cid, file.name, result.size);
return NextResponse.json({ cid: result.cid, size: result.size, name: file.name });
} catch (e) {
console.error('[handleLocalUpload] error:', e);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
/** Lokale file list handler */
async function handleLocalListFiles(): Promise<Response> {
const files = await localListFiles();
return NextResponse.json(files);
}
/** Lokale file download handler */
async function handleLocalGetFile(cid: string): Promise<Response> {
const file = await localGetFile(cid);
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
return new NextResponse(new Uint8Array(file.buffer), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${file.name}"`,
},
});
}
/** Lokale file delete handler */
async function handleLocalDeleteFile(cid: string): Promise<Response> {
await localDeleteFile(cid);
return NextResponse.json({ ok: true });
}
/**
* Vertaalt een Portal-API-pad naar een Kubo RPC-endpoint.
*
* `pins`, `pins/<cid>` en `files/upload` hebben hier GEEN tak, en dat is
* bewust: `proxyIPFS()` onderschept die paden hierboven al en serveert ze uit
* de lokale `.data`-opslag (`.data/pins.json`, `.data/files/`). Kubo-takken
* voor die paden zouden dode code zijn die suggereert dat pins/uploads naar
* Kubo gaan — voeg ze dus niet terug zonder eerst `proxyIPFS()` te wijzigen
* én `isMutatingRequest()` in `write-auth.ts` opnieuw te controleren.
*
* `method` blijft onderdeel van de signature (schrijvers in `route.ts`,
* `write-auth.ts` en de tests geven hem door) maar wordt niet meer voor de
* vertaling gebruikt: geen enkele Kubo-mapping is nog methode-afhankelijk.
*/
export function mapToKuboAPI(path: string, method: string): string { export function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc. // Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -83,16 +236,9 @@ export function mapToKuboAPI(path: string, method: string): string {
return '/api/v0/id'; return '/api/v0/id';
case 'peers': case 'peers':
return '/api/v0/swarm/peers'; return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default: default:
if (p.startsWith('pins/')) { // Alleen niet-`pins`/niet-`files/upload`-paden komen hier terecht; `files/*`
const cid = p.slice(5); // voor niet-GET/DELETE-mutaties valt nog door naar Kubo.
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) { if (p.startsWith('files/')) {
return '/api/v0/ls'; return '/api/v0/ls';
} }
+12
View File
@@ -8,6 +8,9 @@
* /api/* → IPFS Kubo API * /api/* → IPFS Kubo API
* *
* Leest X-Session-Token uit JWT cookie voor authenticated requests. * Leest X-Session-Token uit JWT cookie voor authenticated requests.
*
* Write-auth: muterende methodes (POST/DELETE) lopen eerst door
* authorizeWrite() — zie write-auth.ts. GET blijft publiek (gateway-reads).
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
@@ -15,6 +18,7 @@ import type { RouteMatch } from './types';
import { proxyUserAPI } from './proxy-user'; import { proxyUserAPI } from './proxy-user';
import { proxyIPFS } from './proxy-ipfs'; import { proxyIPFS } from './proxy-ipfs';
import { handleHealth, proxyResult } from './handlers'; import { handleHealth, proxyResult } from './handlers';
import { authorizeWrite } from './write-auth';
export { mapToKuboAPI } from './proxy-ipfs'; export { mapToKuboAPI } from './proxy-ipfs';
@@ -80,6 +84,10 @@ export async function POST(
const route = matchRoute(path, 'POST'); const route = matchRoute(path, 'POST');
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// Write-gate: mutaties eisen IPFS_WRITE_TOKEN of portalsessie (zie write-auth.ts)
const denied = await authorizeWrite(req, route);
if (denied) return denied;
switch (route.target) { switch (route.target) {
case 'user': case 'user':
return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req)); return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req));
@@ -98,6 +106,10 @@ export async function DELETE(
const route = matchRoute(path, 'DELETE'); const route = matchRoute(path, 'DELETE');
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// Write-gate: mutaties eisen IPFS_WRITE_TOKEN of portalsessie (zie write-auth.ts)
const denied = await authorizeWrite(req, route);
if (denied) return denied;
switch (route.target) { switch (route.target) {
case 'user': case 'user':
return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req)); return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req));
+164
View File
@@ -0,0 +1,164 @@
/* ── IPFS Portal Write Auth ──
*
* Audit-fix (2026-09-28): POST /api/add was zonder token bereikbaar.
* Deze module bepaalt of een catch-all /api/* request een MUTATIE is en
* of die zonder credentielegitimatie doorgaat.
*
* Regels:
* 1. Safe methods (GET/HEAD/OPTIONS) → lees, altijd open.
* 2. Lokale mutaties (pins POST/DELETE, files/upload, files DELETE) → mutatie.
* 3. User-API (Python backend) → eigen auth; uitzondering: account-aanmaak
* (POST …/users) want een registrant heeft noch token noch sessie.
* 4. Kubo-pass-through → alleen expliciet lees-only Kubo-endpoints blijven
* open (explorer/cat, ls, stat, resolve, …); de rest is default-deny.
*
* Authorisatie voor een mutatie (één van beide volstaat):
* - `Authorization: Bearer <IPFS_WRITE_TOKEN>` — constant-time vergeleken
* (MAOS-server → server-to-server), óf
* - de bestaande portal sessie-JWT cookie (browser, zelfde verificatie als
* src/middleware.ts — géén nieuw auth-systeem).
*
* IPFS_WRITE_TOKEN niet geconfigureerd → toegang OPEN + luide once-per-process
* waarschuwing. `src/lib/config.ts` heeft geen "required in production"-patroon
* (alleen defaults); hard falen zou elke nog niet-geprovisioneerde deploy
* direct breken. Zie ARCHITECTURE.md § Write authentication.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { ipfsWriteToken } from '@/lib/config';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
import { mapToKuboAPI } from './proxy-ipfs';
import type { RouteMatch } from './types';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Kubo-endpoints die uitsluitend lezen. Alles wat hier niet in valt is een
* mutatie en krijgt de write-gate (default-deny).
*/
const READ_ONLY_KUBO_ENDPOINTS = [
'/api/v0/id',
'/api/v0/version',
'/api/v0/ls',
'/api/v0/cat',
'/api/v0/get',
'/api/v0/refs',
'/api/v0/resolve',
'/api/v0/dns',
'/api/v0/pin/ls',
'/api/v0/key/list',
'/api/v0/name/resolve',
'/api/v0/object/stat',
'/api/v0/object/get',
'/api/v0/repo/stat',
'/api/v0/bw',
'/api/v0/swarm/peers',
'/api/v0/files/ls',
'/api/v0/files/stat',
'/api/v0/block/get',
'/api/v0/dag/get',
'/api/v0/dag/resolve',
];
/* ══════════════════════ Classificatie ══════════════════════ */
/** True = het request muteert staat en valt onder de write-gate. */
export function isMutatingRequest(match: RouteMatch): boolean {
if (SAFE_METHODS.has(match.method)) return false;
const p = match.path.replace(/^\/+/, '');
// Lokale pin-opslag (proxy-ipfs handleLocalPins): GET leest, rest muteert.
if (p === 'pins' || p.startsWith('pins/')) return match.method !== 'GET';
// Lokale file-opslag (proxy-ipfs): upload + delete muteren.
if (p === 'files/upload') return true;
if (p.startsWith('files/') && match.method === 'DELETE') return true;
// User-API (Python backend, X-Admin-Key + X-Session-Token) is een eigen
// auth-domein. Account-aanmaak blijft open: registranten hebben noch token
// noch sessie. Alles anders (bv. DELETE /api/users/<name>) valt wel hier.
if (match.target === 'user') {
return !(match.method === 'POST' && p === 'users');
}
// Health (inline) muteert niets.
if (match.target !== 'ipfs') return false;
// Kubo-pass-through: alleen aantoonbaar lees-only operaties blijven open.
const endpoint = mapToKuboAPI(match.path, match.method).split('?')[0];
return !READ_ONLY_KUBO_ENDPOINTS.some(
(ep) => endpoint === ep || endpoint.startsWith(`${ep}/`),
);
}
/* ══════════════════════ Token check ══════════════════════ */
/**
* Constant-time vergelijking via SHA-256 digests: gelijke uitvoerduur ongeacht
* inhoud én geen lengte-lek (digests zijn altijd 32 bytes).
*/
function tokensMatch(presented: string, expected: string): boolean {
const a = createHash('sha256').update(presented, 'utf8').digest();
const b = createHash('sha256').update(expected, 'utf8').digest();
return timingSafeEqual(a, b);
}
function bearerMatches(header: string | null, expected: string): boolean {
if (!header) return false;
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
if (!match) return false;
return tokensMatch(match[1], expected);
}
async function hasPortalSession(req: NextRequest): Promise<boolean> {
const raw = req.cookies.get(SESSION_COOKIE)?.value;
if (!raw) return false;
const session = await verifySessionJWT(raw);
return session !== null;
}
let warnedMissingToken = false;
function warnMissingWriteToken(): void {
if (warnedMissingToken) return;
warnedMissingToken = true;
console.warn(
'[write-auth] ⚠️ IPFS_WRITE_TOKEN niet ingesteld — muterende /api/* endpoints staan OPEN. ' +
'Zet IPFS_WRITE_TOKEN in .env (zie .env.example) en herdeploy om writes af te dwingen.',
);
}
/* ══════════════════════ Gate ══════════════════════ */
/**
* Controleert een catch-all request. Retourneert een NextResponse bij weigering,
* `null` als het request doorgaat naar de handler.
*/
export async function authorizeWrite(
req: NextRequest,
match: RouteMatch,
): Promise<NextResponse | null> {
if (!isMutatingRequest(match)) return null;
const configured = ipfsWriteToken();
if (!configured) {
// Geen token geconfigureerd → open + luide waarschuwing (zie module-doc).
warnMissingWriteToken();
return null;
}
if (bearerMatches(req.headers.get('authorization'), configured)) return null;
if (await hasPortalSession(req)) return null;
const presented = req.headers.get('authorization');
const status = presented ? 403 : 401;
return NextResponse.json(
{
error: presented ? 'Forbidden' : 'Unauthorized',
detail: 'IPFS_WRITE_TOKEN of geldige portalsessie vereist voor writes',
},
{ status, headers: { 'WWW-Authenticate': 'Bearer' } },
);
}
+65 -10
View File
@@ -1,14 +1,27 @@
/* ── POST /api/auth/challenge ── /* ── POST /api/auth/challenge ──
* *
* Proxied naar Python User API /auth/challenge. * Genereert SIWE challenge volledig server-side.
* Geeft nonce + message terug voor SIWE signing. * Geen Python backend nodig — nonce + message worden hier aangemaakt.
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { userApiBase } from '@/lib/config'; import { randomBytes } from 'crypto';
import { SiweMessage } from 'siwe';
import { getAddress } from 'viem';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// In-memory nonce store (in productie: gebruik Redis of database)
const nonceStore = new Map<string, { nonce: string; message: string; expires: number }>();
// Cleanup oude nonces elke 5 minuten
setInterval(() => {
const now = Date.now();
for (const [key, value] of nonceStore) {
if (value.expires < now) nonceStore.delete(key);
}
}, 5 * 60 * 1000);
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
@@ -18,15 +31,38 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Address required' }, { status: 400 }); return NextResponse.json({ error: 'Address required' }, { status: 400 });
} }
const res = await fetch(`${userApiBase()}/auth/challenge`, { // Genereer cryptografisch veilige nonce
method: 'POST', const nonce = randomBytes(16).toString('hex');
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address.toLowerCase() }), // Gebruik het juiste domein — in productie: zksync.maos.dedyn.io
signal: AbortSignal.timeout(5000), const domain = process.env.NEXT_PUBLIC_SITE_DOMAIN ||
req.headers.get('host') ||
'localhost:3445';
// Checksum het adres (EIP-55) — SIWE vereist dit
const checksummedAddress = getAddress(address);
// Maak SIWE message met de officiële siwe library
const siweMessage = new SiweMessage({
domain,
address: checksummedAddress,
statement: 'Sign in to IPFS Portal',
uri: `https://${domain}`,
version: '1',
chainId: 270,
nonce,
}); });
const data = await res.json(); const message = siweMessage.prepareMessage();
return NextResponse.json(data, { status: res.status });
// Sla nonce + volledig message op (10 minuten geldig)
nonceStore.set(address.toLowerCase(), {
nonce,
message,
expires: Date.now() + 10 * 60 * 1000,
});
return NextResponse.json({ message, nonce });
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json( return NextResponse.json(
@@ -35,3 +71,22 @@ export async function POST(req: NextRequest) {
); );
} }
} }
/** Verifieer nonce en return het originele message — wordt gebruikt door login route */
export function verifyNonce(address: string, nonce: string): { valid: boolean; message?: string } {
const stored = nonceStore.get(address.toLowerCase());
if (!stored) return { valid: false };
if (stored.expires < Date.now()) {
nonceStore.delete(address.toLowerCase());
return { valid: false };
}
if (stored.nonce !== nonce) return { valid: false };
// Haal het originele bericht op
const message = stored.message;
// Nonce is eenmalig — verwijder na gebruik
nonceStore.delete(address.toLowerCase());
return { valid: true, message };
}
+41 -37
View File
@@ -1,12 +1,13 @@
/* ── POST /api/auth/login ── /* ── POST /api/auth/login ──
* *
* SIWE wallet login: verifieert signed message via Python backend, * SIWE wallet login: verifieert signed message volledig server-side.
* maakt vervolgens JWT aan met session_token voor Next.js middleware. * Geen Python backend nodig — siwe + viem worden gebruikt voor verificatie.
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createSessionJWT, cookieOptions } from '@/lib/auth-server'; import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
import { userApiBase } from '@/lib/config'; import { SiweMessage } from 'siwe';
import { verifyNonce } from '../challenge/route';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -22,53 +23,56 @@ export async function POST(req: NextRequest) {
); );
} }
// Proxy login to Python User API // 1. Verifieer nonce en haal origineel message op
const res = await fetch(`${userApiBase()}/auth/login`, { const nonceResult = verifyNonce(address, nonce);
method: 'POST', if (!nonceResult.valid || !nonceResult.message) {
headers: { 'Content-Type': 'application/json' }, return NextResponse.json(
body: JSON.stringify({ { error: 'Ongeldige of verlopen nonce. Vraag een nieuwe challenge aan.' },
address: address.toLowerCase(), { status: 401 },
signature, );
nonce,
}),
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
} }
// Python backend returns { token, address, message, expires_in } // 2. Parse het SIWE message en verifieer handtekening
const pythonSessionToken: string = data.token;
const userAddress: string = data.address;
// Determine role — Python backend returns role info via /auth/verify
let role: 'admin' | 'user' = 'user';
try { try {
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, { const siweMessage = new SiweMessage(nonceResult.message);
headers: { 'X-Session-Token': pythonSessionToken }, const result = await siweMessage.verify({ signature });
signal: AbortSignal.timeout(3000),
}); if (!result.success) {
if (verifyRes.ok) { return NextResponse.json(
const verifyData = await verifyRes.json(); { error: 'Handtekening verificatie mislukt' },
role = verifyData.role === 'admin' ? 'admin' : 'user'; { status: 401 },
);
}
// 3. Controleer of het adres overeenkomt
if (siweMessage.address.toLowerCase() !== address.toLowerCase()) {
return NextResponse.json(
{ error: 'Handtekening komt niet overeen met opgegeven adres' },
{ status: 401 },
);
} }
} catch { } catch {
// Non-critical — default to 'user' return NextResponse.json(
{ error: 'Ongeldige handtekening' },
{ status: 401 },
);
} }
// Create JWT containing the Python session token // 4. Bepaal rol (eenvoudig: eerste adres = admin, rest = user)
// In productie: check een database
const role: 'admin' | 'user' = 'user';
// 5. Maak JWT aan
const sessionToken = crypto.randomUUID();
const jwt = await createSessionJWT({ const jwt = await createSessionJWT({
address: userAddress, address: address.toLowerCase(),
role, role,
sessionToken: pythonSessionToken, sessionToken,
}); });
const response = NextResponse.json({ const response = NextResponse.json({
authenticated: true, authenticated: true,
address: userAddress, address: address.toLowerCase(),
role, role,
}); });
@@ -266,9 +266,9 @@ export default function DirectoryListing({
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50"> <div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
<div className="w-4 shrink-0" /> <div className="w-4 shrink-0" />
<div className="flex-1">Name</div> <div className="flex-1">Name</div>
<div className="w-12 text-center">Type</div> <div className="w-12 text-center hidden sm:block">Type</div>
<div className="w-16 text-right hidden md:block">Size</div> <div className="w-16 text-right hidden md:block">Size</div>
<div className="w-20 text-right hidden lg:block">Hash</div> <div className="w-24 text-right hidden lg:block">Hash</div>
<div className="w-20 text-center" /> <div className="w-20 text-center" />
</div> </div>
+7 -3
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Copy, CheckCircle } from 'lucide-react'; import { Copy, CheckCircle } from 'lucide-react';
import { gatewayLink } from '@/lib/helpers';
interface GatewayLinkProps { interface GatewayLinkProps {
cid: string; cid: string;
@@ -10,11 +11,14 @@ interface GatewayLinkProps {
export default function GatewayLink({ cid, filename }: GatewayLinkProps) { export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const gatewayUrl = `https://ipfs.io/ipfs/${cid}${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`; // Gedeelde helper: gateway-basis uit lib/config.ts + het /ipfs/-pad van de
// route GET /ipfs/<cid>. Nooit een externe gateway (ipfs.io) hardcoden —
// dan wijst de link content van een andere node aan.
const link = gatewayLink(cid, filename);
async function handleCopy() { async function handleCopy() {
try { try {
await navigator.clipboard.writeText(gatewayUrl); await navigator.clipboard.writeText(link);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (err) { } catch (err) {
@@ -25,7 +29,7 @@ export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
return ( return (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<code className="flex-1 truncate text-surface-400 bg-surface-900 px-2 py-1 rounded"> <code className="flex-1 truncate text-surface-400 bg-surface-900 px-2 py-1 rounded">
{gatewayUrl} {link}
</code> </code>
<button <button
onClick={handleCopy} onClick={handleCopy}
@@ -0,0 +1,50 @@
import { describe, it, expect, afterEach } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import GatewayLink from '../GatewayLink';
/* ── GatewayLink ──
*
* Audit: de component hardcodede `https://ipfs.io/ipfs/<cid>`, terwijl de
* portal zijn EIGEN gateway heeft (NEXT_PUBLIC_GATEWAY_URL, fallback
* https://ipfs.maos.dedyn.io). De gedeelde helper `gatewayLink()` uit
* `@/lib/helpers` bouwt de link — deze tests bewaken dat de component die
* ook echt gebruikt in plaats van een eigen URL te bouwen.
*/
const ORIGINAL_GATEWAY = process.env.NEXT_PUBLIC_GATEWAY_URL;
afterEach(() => {
if (ORIGINAL_GATEWAY === undefined) delete process.env.NEXT_PUBLIC_GATEWAY_URL;
else process.env.NEXT_PUBLIC_GATEWAY_URL = ORIGINAL_GATEWAY;
});
function render(cid: string, filename?: string): string {
return renderToStaticMarkup(<GatewayLink cid={cid} filename={filename} />);
}
describe('GatewayLink', () => {
it('wijst naar de eigen gateway van de portal, niet naar ipfs.io', () => {
const html = render('QmTest123');
expect(html).toContain('https://ipfs.maos.dedyn.io/ipfs/QmTest123');
expect(html).not.toContain('ipfs.io');
});
it('volgt NEXT_PUBLIC_GATEWAY_URL', () => {
process.env.NEXT_PUBLIC_GATEWAY_URL = 'https://gateway.example.org';
expect(render('QmTest123')).toContain('https://gateway.example.org/ipfs/QmTest123');
});
it('toont de CID op het /ipfs/-pad', () => {
expect(render('QmTest123')).toContain('/ipfs/QmTest123');
});
it('zet filename als query-parameter, url-encoded', () => {
expect(render('QmTest123', 'rap portaal.txt')).toContain(
'?filename=rap%20portaal.txt',
);
});
it('laat de query-string weg als er geen filename is', () => {
expect(render('QmTest123')).not.toContain('?filename=');
});
});
+273 -11
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer'; import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
import { listPins } from '@/lib/api/pins'; import { listPins, removePin } from '@/lib/api/pins';
import CIDInput from './components/CIDInput'; import CIDInput from './components/CIDInput';
import DirectoryListing from './components/DirectoryListing'; import DirectoryListing from './components/DirectoryListing';
import FilePreview from './components/FilePreview'; import FilePreview from './components/FilePreview';
@@ -13,12 +13,13 @@ import GatewayLink from './components/GatewayLink';
import SearchBar from '@/components/search'; import SearchBar from '@/components/search';
import type { SearchResult } from '@/lib/search-index'; import type { SearchResult } from '@/lib/search-index';
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index'; import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react'; import { Search, AlertCircle, RefreshCw, Database, Loader2, Trash2, CheckSquare, Square, ArrowLeft, FolderOpen, Share2, Copy } from 'lucide-react';
import Skeleton from '@/components/Skeleton'; import Skeleton from '@/components/Skeleton';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers'; import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
import { gatewayLink } from '@/lib/helpers';
export default function ExplorerPage() { export default function ExplorerPage() {
const [cid, setCid] = useState(''); const [cid, setCid] = useState('');
@@ -33,6 +34,75 @@ export default function ExplorerPage() {
const [recentCids] = useState<string[]>(loadRecentCids); const [recentCids] = useState<string[]>(loadRecentCids);
const [indexStats, setIndexStats] = useState(getIndexStats); const [indexStats, setIndexStats] = useState(getIndexStats);
const [rebuilding, setRebuilding] = useState(false); const [rebuilding, setRebuilding] = useState(false);
const [pinnedCids, setPinnedCids] = useState<{ cid: string; name: string }[]>([]);
const [pinsLoading, setPinsLoading] = useState(true);
const [selectedPins, setSelectedPins] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [mounted, setMounted] = useState(false);
const [pinSearch, setPinSearch] = useState('');
const [copiedPin, setCopiedPin] = useState<string | null>(null);
// Laad pins bij opening (alleen client-side)
useEffect(() => {
setMounted(true);
loadPins();
}, []);
async function loadPins() {
setPinsLoading(true);
try {
const pinsList = await listPins();
setPinnedCids(pinsList.map(p => ({ cid: p.cid, name: p.name || p.cid.slice(0, 16) + '…' })));
} catch {
// Stil falen
} finally {
setPinsLoading(false);
}
}
// Selectie
function toggleSelectPin(cid: string) {
setSelectedPins(prev => {
const next = new Set(prev);
if (next.has(cid)) next.delete(cid);
else next.add(cid);
return next;
});
}
function selectAllPins() {
if (selectedPins.size === pinnedCids.length) {
setSelectedPins(new Set());
} else {
setSelectedPins(new Set(pinnedCids.map(p => p.cid)));
}
}
// Verwijder enkele pin
async function handleDeletePin(cid: string) {
try {
await removePin(cid);
setPinnedCids(prev => prev.filter(p => p.cid !== cid));
setSelectedPins(prev => { const n = new Set(prev); n.delete(cid); return n; });
} catch (err) {
console.error('[Explorer] Failed to unpin:', err);
}
}
// Verwijder geselecteerde pins
async function handleDeleteSelected() {
if (selectedPins.size === 0) return;
setDeleting(true);
try {
await Promise.all([...selectedPins].map(cid => removePin(cid)));
setPinnedCids(prev => prev.filter(p => !selectedPins.has(p.cid)));
setSelectedPins(new Set());
} catch (err) {
console.error('[Explorer] Failed to unpin selected:', err);
} finally {
setDeleting(false);
}
}
// Load pins from the pins page (shared localStorage or just track in-memory) // Load pins from the pins page (shared localStorage or just track in-memory)
function handlePinToggle(cid: string) { function handlePinToggle(cid: string) {
@@ -69,15 +139,35 @@ export default function ExplorerPage() {
await navigateTo(hash); await navigateTo(hash);
} }
async function handleBreadcrumbNavigate(cid: string) { async function handleBreadcrumbNavigate(targetCid: string) {
// Find the index of the clicked segment // Als op de huidige CID geklikt wordt, doe niets
const idx = path.findIndex((s) => s.cid === cid); if (targetCid === cid) return;
// Zoek de index in het pad
const idx = path.findIndex((s) => s.cid === targetCid);
if (idx >= 0) { if (idx >= 0) {
// Navigeer naar een bestaand segment in het pad
setPath(path.slice(0, idx)); setPath(path.slice(0, idx));
await navigateTo(targetCid);
} else if (targetCid === cid && path.length > 0) {
// Terug naar het bovenliggende segment
const newPath = path.slice(0, -1);
setPath(newPath);
if (newPath.length > 0) {
await navigateTo(newPath[newPath.length - 1].cid);
} else {
// Terug naar root
setState('idle');
setCid('');
setEntries([]);
}
} else { } else {
// Terug naar root (geen pad gevonden)
setPath([]); setPath([]);
setState('idle');
setCid('');
setEntries([]);
} }
await navigateTo(cid);
} }
async function handlePreview(hash: string) { async function handlePreview(hash: string) {
@@ -104,6 +194,21 @@ export default function ExplorerPage() {
if (cid) navigateTo(cid); if (cid) navigateTo(cid);
} }
/* ── Navigatie terug (omhoog) ── */
async function navigateUp() {
if (path.length === 0) return;
const newPath = path.slice(0, -1);
const parentCid = newPath.length > 0 ? newPath[newPath.length - 1].cid : '';
setPath(newPath);
if (parentCid) {
await navigateTo(parentCid);
} else {
setState('idle');
setCid('');
setEntries([]);
}
}
/* ── Search ── */ /* ── Search ── */
function handleSearchResult(result: SearchResult) { function handleSearchResult(result: SearchResult) {
@@ -134,7 +239,13 @@ export default function ExplorerPage() {
indexed++; indexed++;
} }
} catch (err) { } catch (err) {
console.error('[ExplorerPage] Failed to index pin:', err); // Skip directories en niet-leesbare bestanden — geen probleem
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('directory') || msg.includes('dag node')) {
// Directory — overslaan, geen error
} else {
console.error('[ExplorerPage] Failed to index pin:', err);
}
failed++; failed++;
} }
} }
@@ -147,6 +258,29 @@ export default function ExplorerPage() {
const isPinned = previewCid ? pins.includes(previewCid) : false; const isPinned = previewCid ? pins.includes(previewCid) : false;
// Gefilterde pins op basis van zoekterm
const filteredPins = pinSearch.trim()
? pinnedCids.filter(p =>
p.cid.toLowerCase().includes(pinSearch.toLowerCase()) ||
p.name.toLowerCase().includes(pinSearch.toLowerCase())
)
: pinnedCids;
// Kopieer CID naar klembord
function copyPinCid(cid: string) {
navigator.clipboard.writeText(cid).catch(() => {});
setCopiedPin(cid);
setTimeout(() => setCopiedPin(null), 2000);
}
// Kopieer gateway link naar klembord
function sharePinLink(cid: string) {
const link = gatewayLink(cid);
navigator.clipboard.writeText(link).catch(() => {});
setCopiedPin(`gw-${cid}`);
setTimeout(() => setCopiedPin(null), 2000);
}
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Verkenner"> <ErrorBoundary label="Verkenner">
@@ -201,6 +335,122 @@ export default function ExplorerPage() {
</div> </div>
)} )}
{/* Pinned CIDs — alleen client-side renderen (voorkomt hydration mismatch) */}
{mounted && state === 'idle' && pinnedCids.length > 0 && (
<div className="mb-6 animate-fade-in">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-surface-500">Gepinde bestanden ({pinnedCids.length})</p>
<div className="flex items-center gap-2">
<button
onClick={selectAllPins}
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
>
{selectedPins.size === pinnedCids.length ? (
<CheckSquare className="w-3.5 h-3.5" />
) : (
<Square className="w-3.5 h-3.5" />
)}
{selectedPins.size === pinnedCids.length ? 'Deselecteer alles' : 'Selecteer alles'}
</button>
{selectedPins.size > 0 && (
<button
onClick={handleDeleteSelected}
disabled={deleting}
className="flex items-center gap-1 text-xs text-accent-rose hover:text-accent-rose/80 transition-colors disabled:opacity-50"
>
{deleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
Verwijder ({selectedPins.size})
</button>
)}
</div>
</div>
{/* Zoekbalk voor pins */}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-surface-500 pointer-events-none" />
<input
type="text"
value={pinSearch}
onChange={(e) => setPinSearch(e.target.value)}
placeholder="Zoek op CID of naam…"
className="w-full pl-8 pr-3 py-1.5 text-xs rounded-lg bg-surface-800 border border-surface-700 text-surface-300 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 transition-colors"
/>
</div>
<div className="glass rounded-xl overflow-hidden">
{filteredPins.slice(0, 10).map((pin) => (
<div
key={pin.cid}
className={`flex items-center gap-3 px-4 py-2.5 transition-colors border-b border-surface-800/50 last:border-b-0 ${
selectedPins.has(pin.cid) ? 'bg-brand-500/10' : 'hover:bg-surface-800/50'
}`}
>
<button
onClick={() => toggleSelectPin(pin.cid)}
className="shrink-0 p-0.5"
title={selectedPins.has(pin.cid) ? 'Deselecteer' : 'Selecteer'}
>
{selectedPins.has(pin.cid) ? (
<CheckSquare className="w-4 h-4 text-brand-400" />
) : (
<Square className="w-4 h-4 text-surface-600" />
)}
</button>
<button
onClick={() => navigateTo(pin.cid)}
className="flex-1 flex items-center gap-2 min-w-0 text-left"
>
<FolderOpen className="w-3.5 h-3.5 text-accent-cyan shrink-0" />
<span className="text-xs font-mono text-surface-300 truncate">{pin.cid}</span>
{pin.name && pin.name !== pin.cid.slice(0, 16) + '…' && (
<span className="text-xs text-surface-500 truncate ml-auto">{pin.name}</span>
)}
</button>
{/* Deel-knop */}
<button
onClick={() => sharePinLink(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Kopieer gateway link"
>
{copiedPin === `gw-${pin.cid}` ? (
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
) : (
<Share2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-cyan" />
)}
</button>
{/* Kopieer CID */}
<button
onClick={() => copyPinCid(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Kopieer CID"
>
{copiedPin === pin.cid ? (
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-600 group-hover:text-surface-300" />
)}
</button>
<button
onClick={() => handleDeletePin(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Verwijder pin"
>
<Trash2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-rose" />
</button>
</div>
))}
{filteredPins.length === 0 && pinSearch.trim() && (
<div className="px-4 py-4 text-xs text-surface-500 text-center">
Geen pins gevonden voor "{pinSearch}"
</div>
)}
{filteredPins.length > 10 && (
<div className="px-4 py-2 text-xs text-surface-500 text-center">
+{filteredPins.length - 10} meer…
</div>
)}
</div>
</div>
)}
{/* Idle state */} {/* Idle state */}
{state === 'idle' && ( {state === 'idle' && (
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in"> <div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in">
@@ -259,10 +509,22 @@ export default function ExplorerPage() {
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
{/* Breadcrumbs + Pin + Gateway row */} {/* Breadcrumbs + Pin + Gateway row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<Breadcrumbs <div className="flex items-center gap-2">
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]} {path.length > 0 && (
onNavigate={handleBreadcrumbNavigate} <button
/> onClick={navigateUp}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-800 text-xs text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors shrink-0"
title="Ga terug"
>
<ArrowLeft className="w-3.5 h-3.5" />
Terug
</button>
)}
<Breadcrumbs
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
onNavigate={handleBreadcrumbNavigate}
/>
</div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<GatewayLink cid={cid} /> <GatewayLink cid={cid} />
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} /> <PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from '../route';
/* ── GET /ipfs/<cid> ──
*
* Audit: `detectContentType(cid)` gaf onafhankelijk van de input altijd
* `application/octet-stream` terug, terwijl de commentaar claimde dat Kubo het
* echte type bepaalt. Content die als octet-stream geserved wordt, kan de
* browser niet als image/audio/video of tekst tonen.
*
* Deze tests dwingen af dat de response het content-type van de upstream
* (`cat`) doorgeeft, met `application/octet-stream` als fallback.
*/
const CID = 'QmTestCID000';
function makeReq(query = ''): NextRequest {
return new NextRequest(`http://localhost/ipfs/${CID}${query}`);
}
function call(query = '') {
return GET(makeReq(query), { params: Promise.resolve({ cid: CID }) });
}
/** Upstream-stand-in: `cat` levert bytes + een Content-Type. */
function kuboReturning(contentType: string | null, body = 'hallo wereld'): Response {
const headers: Record<string, string> = {};
if (contentType !== null) headers['Content-Type'] = contentType;
// Uint8Array-body zodat Response zelf geen Content-Type zet.
const bytes = new TextEncoder().encode(body);
return new Response(bytes, { status: 200, headers });
}
const kuboFetch = vi.fn(async (_url: string, _init: RequestInit) =>
kuboReturning('text/plain; charset=utf-8'),
);
beforeEach(() => {
vi.clearAllMocks();
process.env.KUBO_API_URL = 'http://kubo.test:5001';
vi.stubGlobal('fetch', kuboFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.KUBO_API_URL;
});
describe('content-type van de gateway', () => {
it('geeft het content-type van de upstream door', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('text/html; charset=utf-8'));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
});
it('geeft text/plain door, inclusief charset', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('text/plain; charset=utf-8'));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
});
it('geef application/json door zonder charset-params', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('application/json'));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('application/json');
});
it('valt terug op application/octet-stream als upstream geen type stuurt', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning(null));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('valt terug op application/octet-stream bij een lege content-type', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning(''));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('valt terug op application/octet-stream bij een ongeldige content-type', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('geen-media-type'));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('raakt Kubo via POST /api/v0/cat met de CID als arg', async () => {
await call();
expect(kuboFetch).toHaveBeenCalledTimes(1);
const [url, init] = kuboFetch.mock.calls[0];
expect(url).toBe(`http://kubo.test:5001/api/v0/cat?arg=${CID}`);
expect(init.method).toBe('POST');
});
it('geeft de body byte-voor-byte door', async () => {
const payload = '{"hallo":"wereld"}';
kuboFetch.mockResolvedValueOnce(kuboReturning('application/json', payload));
const res = await call();
expect(await res.text()).toBe(payload);
});
});
describe('overige gateway-headers', () => {
it('zet Content-Disposition inline zonder filename', async () => {
const res = await call();
expect(res.headers.get('Content-Disposition')).toBe('inline');
});
it('zet Content-Disposition met de gevraagde filename', async () => {
const res = await call('?filename=rap.txt');
expect(res.headers.get('Content-Disposition')).toBe('inline; filename="rap.txt"');
});
it('sluit het response aan op immutable caching', async () => {
const res = await call();
expect(res.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
});
describe('foutpaden blijven ongewijzigd', () => {
it('weigert een ongeldig CID formaat zonder Kubo te raken', async () => {
const res = await GET(new NextRequest('http://localhost/ipfs/niet-een-cid'), {
params: Promise.resolve({ cid: 'niet-een-cid' }),
});
expect(res.status).toBe(400);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('geeft een Kubo-foutstatus door', async () => {
kuboFetch.mockResolvedValueOnce(
new Response('not found', { status: 404, headers: { 'Content-Type': 'text/plain' } }),
);
const res = await call();
expect(res.status).toBe(404);
});
it('geeft 501 zonder KUBO_API_URL', async () => {
delete process.env.KUBO_API_URL;
const res = await call();
expect(res.status).toBe(501);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('geeft 502 wanneer de fetch faalt', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('connect ECONNREFUSED');
}),
);
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await call();
expect(res.status).toBe(502);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* IPFS Gateway Route — /ipfs/[cid]
*
* Haalt content op van KUBO via /api/v0/cat en served het als gateway.
* Ondersteunt:
* GET /ipfs/:cid — content ophalen
* GET /ipfs/:cid?filename= — met content-disposition header
*/
import { NextRequest, NextResponse } from 'next/server';
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> },
) {
const { cid } = await params;
// Valideer CID formaat (Qm... of bafy...)
if (!cid || (!cid.startsWith('Qm') && !cid.startsWith('bafy') && !cid.startsWith('bafk'))) {
return NextResponse.json({ error: 'Ongeldig CID formaat' }, { status: 400 });
}
const kuboSvc = kuboApiUrl();
if (!kuboSvc) {
return NextResponse.json({ error: 'KUBO niet geconfigureerd' }, { status: 501 });
}
try {
const headers: Record<string, string> = {};
const auth = kuboBasicAuth();
if (auth) {
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
}
// Fetch content via KUBO /api/v0/cat
const response = await fetch(`${kuboSvc}/api/v0/cat?arg=${encodeURIComponent(cid)}`, {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return NextResponse.json(
{ error: `KUBO cat gefaald: ${response.status}`, detail: text.slice(0, 200) },
{ status: response.status },
);
}
// Content-type van de upstream overnemen: `cat` stuurt het echte media type
// mee. Zonder dat zou de browser tekst/images/audio niet kunnen tonen.
const contentType = resolveContentType(response.headers.get('Content-Type'));
// Check of filename is opgegeven
const filename = req.nextUrl.searchParams.get('filename');
const headersOut: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': filename
? `inline; filename="${filename}"`
: 'inline',
'Cache-Control': 'public, max-age=31536000, immutable',
};
const body = await response.arrayBuffer();
return new NextResponse(body, { headers: headersOut });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Gateway fetch failed';
console.error(`[ipfs-gateway] Error fetching ${cid}:`, msg);
return NextResponse.json({ error: msg }, { status: 502 });
}
}
/** Content-type als Kubo `cat` er geen bruikbaar type voor terugstuurt. */
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
/**
* Voldoet aan RFC 9110 §5.6: `type "/" subtype *( ";" parameter )`, met tokens
* uit de tmfh-token-characters. Alles daarbuiten (CR/LF, quotes, spaties)
* wordt geweigerd zodat upstream-headers nooit in onze response worden gespiegeld.
*/
const CONTENT_TYPE_PATTERN =
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+\/[A-Za-z0-9!#$%&'*+.^_`|~-]+(\s*;\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+=(?:[A-Za-z0-9!#$%&'*+.^_`|~-]+|"(?:[A-Za-z0-9!#$%&'*+.^_`|~-]|\s)*"))*$/;
/**
* Neem het content-type van de upstream over, met `application/octet-stream`
* als fallback wanneer upstream niets bruikbaars stuurt.
*/
function resolveContentType(upstream: string | null): string {
const value = upstream?.trim();
if (!value) return DEFAULT_CONTENT_TYPE;
return CONTENT_TYPE_PATTERN.test(value) ? value : DEFAULT_CONTENT_TYPE;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import Providers from '@/components/Providers';
import SWRegister from '@/components/SWRegister'; import SWRegister from '@/components/SWRegister';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'MAOS IPFS Portal', title: 'IPFS Portal',
description: 'Decentralized storage management for your IPFS node', description: 'Decentralized storage management for your IPFS node',
manifest: '/manifest.json', manifest: '/manifest.json',
icons: { icon: '/favicon.svg' }, icons: { icon: '/favicon.svg' },
+10 -7
View File
@@ -30,7 +30,15 @@ export default function LoginPage() {
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` }); notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` });
router.push('/dashboard'); router.push('/dashboard');
} catch (err: unknown) { } catch (err: unknown) {
notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' }); const message = err instanceof Error ? err.message : 'Onbekende fout';
console.error('[Login] Wallet connection failed:', message);
// Toon de echte foutmelding — geen nep "vault locked" meer
notify({
type: 'error',
title: 'Login mislukt',
message
});
} finally { } finally {
setBusy(false); setBusy(false);
setBusyWallet(null); setBusyWallet(null);
@@ -100,11 +108,6 @@ export default function LoginPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-surface-200"> <div className="text-sm font-medium text-surface-200">
{dw.name} {dw.name}
{dw.isMAOS && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent-purple/20 text-accent-purple">
MAOS
</span>
)}
</div> </div>
<div className="text-xs text-surface-500 mt-0.5"> <div className="text-xs text-surface-500 mt-0.5">
Klik om te verbinden Klik om te verbinden
@@ -135,7 +138,7 @@ export default function LoginPage() {
{/* Footer */} {/* Footer */}
<p className="text-center text-[10px] text-surface-600 mt-6"> <p className="text-center text-[10px] text-surface-600 mt-6">
<Shield className="w-3 h-3 inline-block mr-1" /> <Shield className="w-3 h-3 inline-block mr-1" />
Verbonden met MAOS IPFS node Verbonden met IPFS node
</p> </p>
</div> </div>
</div> </div>
+7 -8
View File
@@ -1,14 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { connectWallet, signMessage } from '@/lib/wallet'; import { connectWallet } from '@/lib/wallet';
import { checkHealth } from '@/lib/api/gateway'; import { checkHealth } from '@/lib/api/gateway';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react'; import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
export default function LandingPage() { export default function LandingPage() {
const router = useRouter(); const router = useRouter();
const [address, setAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -16,13 +15,13 @@ export default function LandingPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const wallet = await connectWallet(); await connectWallet(); // Geen provider → probeert alle wallets自动
setAddress(wallet.address);
// Non-blocking health check — portal UI werkt ook zonder backend
checkHealth().catch(() => {}); checkHealth().catch(() => {});
router.push('/dashboard'); router.push('/dashboard');
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Connection failed'); const message = e instanceof Error ? e.message : 'Connection failed';
console.error('[Landing] Wallet connection failed:', message);
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -39,7 +38,7 @@ export default function LandingPage() {
{/* Badge */} {/* Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full glass text-sm text-surface-400 mb-8"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full glass text-sm text-surface-400 mb-8">
<Server className="w-3.5 h-3.5 text-accent-cyan" /> <Server className="w-3.5 h-3.5 text-accent-cyan" />
<span>MAOSv6 · IPFS Gateway</span> <span>IPFS Gateway</span>
</div> </div>
{/* Title */} {/* Title */}
@@ -109,7 +108,7 @@ export default function LandingPage() {
{/* Footer */} {/* Footer */}
<div className="absolute bottom-6 text-xs text-surface-600"> <div className="absolute bottom-6 text-xs text-surface-600">
MAOSv6 · Chain 270 Chain 270
</div> </div>
</div> </div>
); );
+14 -11
View File
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import { import {
User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff, User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function ProfilePage() { export default function ProfilePage() {
const { user, logout, isAdmin } = useAuth(); const { user, logout, isAdmin } = useAuth();
@@ -68,18 +69,19 @@ export default function ProfilePage() {
return ( return (
<PortalLayout> <PortalLayout>
{/* ── Header ── */} <ErrorBoundary label="Profiel">
<div className="flex items-center justify-between mb-6"> {/* ── Header ── */}
<div> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-white">Profiel</h1> <div>
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p> <h1 className="text-2xl font-bold text-white">Profiel</h1>
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
</div>
<button onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
<LogOut className="w-4 h-4" />
Uitloggen
</button>
</div> </div>
<button onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
<LogOut className="w-4 h-4" />
Uitloggen
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Account info ── */} {/* ── Account info ── */}
@@ -180,6 +182,7 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
</div> </div>
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
+41 -29
View File
@@ -3,6 +3,7 @@
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react'; import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
import { uploadFile } from '@/lib/api/files'; import { uploadFile } from '@/lib/api/files';
import { addToHistory, getSettings } from '@/lib/storage'; import { addToHistory, getSettings } from '@/lib/storage';
import { addPin } from '@/lib/api/pins';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { checkUploadAllowed } from '@/lib/limits'; import { checkUploadAllowed } from '@/lib/limits';
import { useNotify } from '@/lib/notifications'; import { useNotify } from '@/lib/notifications';
@@ -12,6 +13,7 @@ import QuickUpload, { type FileEntry } from './components/QuickUpload';
import { PaymentProvider } from '@/lib/payment'; import { PaymentProvider } from '@/lib/payment';
import CryptoUpload from './components/CryptoUpload'; import CryptoUpload from './components/CryptoUpload';
import type { TabMode } from './helpers'; import type { TabMode } from './helpers';
import ErrorBoundary from '@/components/ErrorBoundary';
/* ════════════════════════════ Page ════════════════════════════ */ /* ════════════════════════════ Page ════════════════════════════ */
@@ -143,6 +145,10 @@ export default function UploadPage() {
const result = await uploadFile(entry.file); const result = await uploadFile(entry.file);
const date = new Date().toISOString(); const date = new Date().toISOString();
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' }); addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
// Auto-pin na succesvolle upload
addPin(result.cid, entry.file.name).catch((err) => {
console.warn('[UploadPage] Auto-pin mislukt:', err);
});
setFiles(prev => prev.map(f => setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
)); ));
@@ -177,6 +183,10 @@ export default function UploadPage() {
const r = await uploadFile(cryptoFile); const r = await uploadFile(cryptoFile);
setCryptoResult(r); setCryptoResult(r);
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' }); addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
// Auto-pin na succesvolle crypto upload
addPin(r.cid, cryptoFile.name).catch((err) => {
console.warn('[UploadPage] Auto-pin crypto mislukt:', err);
});
return r; return r;
} }
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) { function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
@@ -201,36 +211,37 @@ export default function UploadPage() {
return ( return (
<PortalLayout> <PortalLayout>
<div className="mb-8"> <ErrorBoundary label="Upload">
<h1 className="text-2xl font-bold text-white">Upload</h1> <div className="mb-8">
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p> <h1 className="text-2xl font-bold text-white">Upload</h1>
</div> <p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p>
</div>
{/* ── Tab switcher ── */} {/* ── Tab switcher ── */}
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit"> <div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
<button <button
onClick={() => setTab('quick')} onClick={() => setTab('quick')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'quick' tab === 'quick'
? 'bg-brand-500/15 text-brand-400 shadow-sm' ? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200' : 'text-surface-400 hover:text-surface-200'
}`} }`}
> >
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
Quick Upload Quick Upload
</button> </button>
<button <button
onClick={() => setTab('crypto')} onClick={() => setTab('crypto')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'crypto' tab === 'crypto'
? 'bg-brand-500/15 text-brand-400 shadow-sm' ? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200' : 'text-surface-400 hover:text-surface-200'
}`} }`}
> >
<ArrowUp className="w-4 h-4" /> <ArrowUp className="w-4 h-4" />
Crypto Payment Crypto Payment
</button> </button>
</div> </div>
{/* Quick Upload tab */} {/* Quick Upload tab */}
{tab === 'quick' && ( {tab === 'quick' && (
@@ -275,6 +286,7 @@ export default function UploadPage() {
/> />
</PaymentProvider> </PaymentProvider>
)} )}
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
+10 -7
View File
@@ -13,6 +13,7 @@ import {
HardDrive, Upload, Wifi, Database, Pin, Activity, HardDrive, Upload, Wifi, Database, Pin, Activity,
ArrowUpRight, ArrowDownLeft, ArrowUpRight, ArrowDownLeft,
} from 'lucide-react'; } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function UsagePage() { export default function UsagePage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -47,13 +48,14 @@ export default function UsagePage() {
return ( return (
<PortalLayout> <PortalLayout>
{/* ── Header ── */} <ErrorBoundary label="Usage">
<div className="mb-6"> {/* ── Header ── */}
<h1 className="text-2xl font-bold text-white">Usage</h1> <div className="mb-6">
<p className="text-sm text-surface-400 mt-1"> <h1 className="text-2xl font-bold text-white">Usage</h1>
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'} <p className="text-sm text-surface-400 mt-1">
</p> {user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
</div> </p>
</div>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-40"> <div className="flex items-center justify-center h-40">
@@ -164,6 +166,7 @@ export default function UsagePage() {
</div> </div>
</div> </div>
)} )}
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import ErrorBoundary from '../ErrorBoundary';
// Mock console.error
vi.mock('console', () => ({
error: vi.fn(),
}));
// Simple component that throws
const ThrowingComponent = ({ shouldThrow = true }: { shouldThrow?: boolean }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return React.createElement('div', null, 'No error');
};
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders without crashing when no error', () => {
const component = React.createElement(ErrorBoundary, null,
React.createElement(ThrowingComponent, { shouldThrow: false })
);
// Component should render without throwing
expect(() => {
// This would throw if ErrorBoundary doesn't catch the error
renderToString(component);
}).not.toThrow();
});
it('catches errors and renders without crashing', () => {
const component = React.createElement(ErrorBoundary, null,
React.createElement(ThrowingComponent, { shouldThrow: true })
);
// ErrorBoundary should catch the error
expect(() => {
renderToString(component);
}).not.toThrow();
});
it('has expected structure', () => {
// Test that ErrorBoundary is a class component
expect(typeof ErrorBoundary).toBe('function');
expect(ErrorBoundary.prototype?.render).toBeDefined();
});
});
// Simple renderToString helper
function renderToString(element: React.ReactElement): string {
// This is a simplified version - in real tests you'd use a testing library
// For now, we just verify the component doesn't throw
return JSON.stringify(element);
}
+6 -3
View File
@@ -90,8 +90,7 @@ export default function PaymentPanel({
setProvider(prov); setProvider(prov);
// Detect wallet type // Detect wallet type
if (window.maosv6) setWalletType('MAOS Wallet'); if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask'); else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet'); else setWalletType('Wallet');
@@ -104,7 +103,11 @@ export default function PaymentPanel({
await refreshPricing(result.address, fileSize); await refreshPricing(result.address, fileSize);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to connect wallet'); const message = e instanceof Error ? e.message : 'Failed to connect wallet';
const isVaultLocked = message.toLowerCase().includes('vault locked') ||
message.toLowerCase().includes('user rejected') ||
message.toLowerCase().includes('user denied');
setError(isVaultLocked ? 'Ontgrendel je wallet eerst met je wachtwoord.' : message);
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
+39 -7
View File
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { formatBytes, gatewayLink, gatewayUrl, truncateCid } from '../helpers'; import { formatBytes, gatewayLink, gatewayUrl, truncateCid } from '../helpers';
const ORIGINAL_GATEWAY = process.env.NEXT_PUBLIC_GATEWAY_URL;
afterEach(() => {
if (ORIGINAL_GATEWAY === undefined) delete process.env.NEXT_PUBLIC_GATEWAY_URL;
else process.env.NEXT_PUBLIC_GATEWAY_URL = ORIGINAL_GATEWAY;
});
describe('formatBytes', () => { describe('formatBytes', () => {
it('formats 0 as "0 B"', () => { it('formats 0 as "0 B"', () => {
expect(formatBytes(0)).toBe('0 B'); expect(formatBytes(0)).toBe('0 B');
@@ -68,18 +75,43 @@ describe('formatBytes', () => {
}); });
describe('gatewayLink', () => { describe('gatewayLink', () => {
it('returns correct gateway URL for valid CID', () => { it('zet het CID achter het /ipfs/-segment van de gateway', () => {
const cid = 'QmTest123'; const cid = 'QmTest123';
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/QmTest123'); expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/ipfs/QmTest123');
}); });
it('handles bafy CIDs', () => { it('handles bafy CIDs', () => {
const cid = 'bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q'; const cid = 'bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q';
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q'); expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/ipfs/bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q');
}); });
it('handles empty string', () => { it('handles empty string', () => {
expect(gatewayLink('')).toBe('https://ipfs.maos.dedyn.io/'); expect(gatewayLink('')).toBe('https://ipfs.maos.dedyn.io/ipfs/');
});
it('volgt de geconfigureerde gateway uit config.ts', () => {
process.env.NEXT_PUBLIC_GATEWAY_URL = 'https://gateway.example.org';
expect(gatewayLink('QmTest123')).toBe('https://gateway.example.org/ipfs/QmTest123');
});
it('verwijdert een trailing slash van de geconfigureerde gateway', () => {
process.env.NEXT_PUBLIC_GATEWAY_URL = 'https://gateway.example.org/';
expect(gatewayLink('QmTest123')).toBe('https://gateway.example.org/ipfs/QmTest123');
});
it('laat de gateway nooit dubbel sluiten als de CID leeg is', () => {
process.env.NEXT_PUBLIC_GATEWAY_URL = 'https://gateway.example.org/';
expect(gatewayLink('')).toBe('https://gateway.example.org/ipfs/');
});
it('voegt een ?filename= suffix toe voor de gateway-route', () => {
expect(gatewayLink('QmTest123', 'rap portaal.txt')).toBe(
'https://ipfs.maos.dedyn.io/ipfs/QmTest123?filename=rap%20portaal.txt',
);
});
it('laat de query-string weg als er geen filename is', () => {
expect(gatewayLink('QmTest123')).not.toContain('?');
}); });
}); });
@@ -118,6 +150,6 @@ describe('truncateCid', () => {
}); });
it('handles empty string', () => { it('handles empty string', () => {
expect(truncateCid('')).toBe(''); expect(truncateCid('')).toBe('—');
}); });
}); });
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiFetch } from '../client';
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('apiFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('makes GET request with correct URL', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
});
await apiFetch('/test');
expect(mockFetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
);
});
it('makes POST request with body', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
await apiFetch('/test', {
method: 'POST',
body: JSON.stringify({ name: 'test' }),
});
expect(mockFetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'test' }),
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
);
});
it('returns JSON response', async () => {
const mockData = { id: 1, name: 'test' };
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
});
const result = await apiFetch('/test');
expect(result).toEqual(mockData);
});
it('throws error on non-OK response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Resource not found'),
});
await expect(apiFetch('/nonexistent')).rejects.toThrow('404 Not Found');
});
it('includes error message from response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve('Database connection failed'),
});
await expect(apiFetch('/error')).rejects.toThrow('Database connection failed');
});
it('truncates long error messages', async () => {
const longMessage = 'A'.repeat(300);
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve(longMessage),
});
try {
await apiFetch('/error');
} catch (error: any) {
// De foutmelding zou afgekapt moeten worden op 200 tekens
expect(error.message.length).toBeLessThanOrEqual(250);
}
});
it('handles network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(apiFetch('/test')).rejects.toThrow('Network error');
});
it('merges custom headers', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
await apiFetch('/test', {
headers: { 'Authorization': 'Bearer token123' },
});
// Controleer of fetch werd aangeroepen met de custom header
const callArgs = mockFetch.mock.calls[0];
expect(callArgs[0]).toBe('/api/test');
expect(callArgs[1].headers).toHaveProperty('Authorization', 'Bearer token123');
});
});
+52 -1
View File
@@ -9,8 +9,59 @@ export interface IPFSEntry {
hash: string; hash: string;
} }
/** Raw Kubo API /api/v0/ls response */
interface KuboLink {
Name: string;
Type: number; // 1 = directory, 2 = file
Size: number;
Hash: string;
}
interface KuboObject {
Hash: string;
Links: KuboLink[];
}
interface KuboLsResponse {
Objects: KuboObject[];
}
/** Transform Kubo response to IPFSEntry[] */
function parseKuboLs(raw: unknown): IPFSEntry[] {
// Already an array — return as-is
if (Array.isArray(raw)) {
return raw as IPFSEntry[];
}
// Kubo format: { Objects: [{ Links: [...] }] }
const data = raw as KuboLsResponse;
if (data?.Objects?.[0]?.Links) {
return data.Objects[0].Links.map((link) => ({
name: link.Name,
type: link.Type === 1 ? 'dir' as const : 'file' as const,
size: link.Size,
hash: link.Hash,
}));
}
// Single file — Kubo wraps it in Objects but no Links
if (data?.Objects?.[0]?.Hash) {
const obj = data.Objects[0];
return [{
name: obj.Hash.slice(0, 12) + '…',
type: 'file',
size: 0,
hash: obj.Hash,
}];
}
// Empty or unrecognizable response
return [];
}
export async function explorerLs(cid: string): Promise<IPFSEntry[]> { export async function explorerLs(cid: string): Promise<IPFSEntry[]> {
return apiFetch(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' }); const raw = await apiFetch<unknown>(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' });
return parseKuboLs(raw);
} }
export async function explorerCat(cid: string): Promise<string> { export async function explorerCat(cid: string): Promise<string> {
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from '../hooks';
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('is a function', () => {
expect(typeof useAuth).toBe('function');
});
it('is exported from hooks module', () => {
// Test dat useAuth correct wordt geëxporteerd
expect(useAuth).toBeDefined();
expect(useAuth.name).toBe('useAuth');
});
});
+21 -3
View File
@@ -59,10 +59,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "personal_sign", method: "personal_sign",
params: [message, address], params: [message, address],
}); });
} catch (err) { } catch (err: unknown) {
console.error("[AuthProvider] Signature request failed:", err); console.error("[AuthProvider] Signature request failed:", err);
setConnectError("Signatuur geweigerd in wallet");
throw new Error("User rejected signature"); // Herken specifieke wallet foutmeldingen
const errorMessage = err instanceof Error ? err.message : String(err);
const isVaultLocked = errorMessage.toLowerCase().includes('vault locked') ||
errorMessage.toLowerCase().includes('lock');
const isUserRejected = errorMessage.toLowerCase().includes('user rejected') ||
errorMessage.toLowerCase().includes('user denied') ||
errorMessage.toLowerCase().includes('rejected');
let errorMsg: string;
if (isVaultLocked && !isUserRejected) {
errorMsg = "Wallet is vergrendeld - ontgrendel je wallet eerst";
} else if (isUserRejected) {
errorMsg = "Handtekening geweigerd in wallet";
} else {
errorMsg = "Signatuur geweigerd in wallet";
}
setConnectError(errorMsg);
throw new Error(errorMsg);
} }
// 3. Send signed message to backend for verification // 3. Send signed message to backend for verification
+10
View File
@@ -47,6 +47,16 @@ export const paymentContractAddress = (): string =>
export const gatewayUrl = (): string => export const gatewayUrl = (): string =>
process.env.NEXT_PUBLIC_GATEWAY_URL || "https://ipfs.maos.dedyn.io"; process.env.NEXT_PUBLIC_GATEWAY_URL || "https://ipfs.maos.dedyn.io";
// ── Write authentication ──
/**
* Bearer-token die verplicht is voor muterende /api/* requests
* (upload/add, pin add/rm, file delete, ipns publish, user delete).
* Lege waarde = writes staan open (met luide waarschuwing in write-auth).
* Server-only — nooit exposure naar de browser bundel.
*/
export const ipfsWriteToken = (): string | undefined =>
process.env.IPFS_WRITE_TOKEN;
// ── Composite helpers ── // ── Composite helpers ──
/** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */ /** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */
+3 -2
View File
@@ -2,6 +2,8 @@
* Utility om IPFS content te downloaden als bestand. * Utility om IPFS content te downloaden als bestand.
*/ */
import { gatewayLink } from './helpers';
/** /**
* Download een bestand van IPFS via de explorer/cat proxy. * Download een bestand van IPFS via de explorer/cat proxy.
* Maakt een Blob van de response en simuleert een klik op <a download>. * Maakt een Blob van de response en simuleert een klik op <a download>.
@@ -34,9 +36,8 @@ export async function downloadFile(cid: string, filename?: string): Promise<void
* Download via gateway (alternatief als proxy traag is). * Download via gateway (alternatief als proxy traag is).
*/ */
export function downloadViaGateway(cid: string, filename?: string) { export function downloadViaGateway(cid: string, filename?: string) {
const gateway = process.env.NEXT_PUBLIC_GATEWAY_URL || 'https://maos.dedyn.io';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = `${gateway}/ipfs/${cid}`; a.href = gatewayLink(cid);
a.download = filename || cid; a.download = filename || cid;
a.target = '_blank'; a.target = '_blank';
a.rel = 'noopener noreferrer'; a.rel = 'noopener noreferrer';
+26 -4
View File
@@ -1,3 +1,5 @@
import { gatewayUrl as configuredGatewayUrl } from './config';
/* ── Format bytes to human-readable size ── */ /* ── Format bytes to human-readable size ── */
export function formatBytes(bytes: number | bigint): string { export function formatBytes(bytes: number | bigint): string {
const b = typeof bytes === 'bigint' ? Number(bytes) : bytes; const b = typeof bytes === 'bigint' ? Number(bytes) : bytes;
@@ -8,16 +10,36 @@ export function formatBytes(bytes: number | bigint): string {
return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i]; return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i];
} }
/* ── Gateway link for CID ── */ /* ── Gateway link voor CID ── */
export function gatewayLink(cid: string): string {
return `https://ipfs.maos.dedyn.io/${cid}`; /**
* Basis-URL van de gateway, zonder trailing slash.
* Eén bron van waarheid: `gatewayUrl()` in `src/lib/config.ts`
* (env `NEXT_PUBLIC_GATEWAY_URL`, fallback `https://ipfs.maos.dedyn.io`).
*/
export function gatewayBaseUrl(): string {
return configuredGatewayUrl().replace(/\/+$/, '');
}
/**
* Deelbare gateway-link voor een CID.
*
* Het pad moet het `/ipfs/`-segment bevatten: de portal served content op
* `GET /ipfs/<cid>` (`src/app/ipfs/[cid]/route.ts`), niet op `/<cid>`.
* Een optionele `filename` komt als `?filename=` terecht, wat diezelfde route
* in `Content-Disposition` verwerkt.
*/
export function gatewayLink(cid: string, filename?: string): string {
const suffix = filename ? `?filename=${encodeURIComponent(filename)}` : '';
return `${gatewayBaseUrl()}/ipfs/${cid}${suffix}`;
} }
/** Alias for gatewayLink — used in FilePreview */ /** Alias for gatewayLink — used in FilePreview */
export const gatewayUrl = gatewayLink; export const gatewayUrl = gatewayLink;
/* ── Truncate CID for display ── */ /* ── Truncate CID for display ── */
export function truncateCid(cid: string, chars: number = 12): string { export function truncateCid(cid: string | undefined | null, chars: number = 12): string {
if (!cid) return '—';
if (cid.length <= chars) return cid; if (cid.length <= chars) return cid;
return `${cid.slice(0, chars)}...`; return `${cid.slice(0, chars)}...`;
} }
+121
View File
@@ -0,0 +1,121 @@
/**
* Local file storage — disk-based
* Gebruikt wanneer geen Kubo API beschikbaar is.
* Slaat bestanden op in .data/files/ en houdt metadata bij in .data/files.json
*/
import { readFile, writeFile, mkdir, unlink, readdir, stat } from 'fs/promises';
import { join } from 'path';
import { createHash } from 'crypto';
const DATA_DIR = join(process.cwd(), '.data', 'files');
const FILES_DB = join(process.cwd(), '.data', 'files.json');
interface FileRecord {
cid: string;
name: string;
size: number;
modified: string;
path: string; // relatief pad in .data/files/
}
async function ensureDir() {
await mkdir(DATA_DIR, { recursive: true });
}
async function readDb(): Promise<Record<string, FileRecord>> {
try {
const data = await readFile(FILES_DB, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
async function writeDb(db: Record<string, FileRecord>): Promise<void> {
await ensureDir();
await writeFile(FILES_DB, JSON.stringify(db, null, 2), 'utf-8');
}
/** Genereer een CID-achtige hash van de bestandsinhoud */
function generateCid(buffer: Buffer): string {
const hash = createHash('sha256').update(buffer).digest('hex');
// IPFS-style base32 hash (Qm prefix + base32 hash)
return 'Qm' + hash.slice(0, 44);
}
/** Sla een bestand op en retourneer CID + metadata */
export async function localSaveFile(
name: string,
buffer: Buffer,
size: number
): Promise<{ cid: string; size: number }> {
await ensureDir();
const cid = generateCid(buffer);
const filePath = join(DATA_DIR, `${cid}-${name}`);
// Sla het bestand op
await writeFile(filePath, buffer);
// Update de database
const db = await readDb();
db[cid] = {
cid,
name,
size,
modified: new Date().toISOString(),
path: `${cid}-${name}`,
};
await writeDb(db);
return { cid, size };
}
/** Haal een bestand op */
export async function localGetFile(cid: string): Promise<{ buffer: Buffer; name: string } | null> {
const db = await readDb();
const record = db[cid];
if (!record) return null;
const filePath = join(DATA_DIR, record.path);
try {
const buffer = await readFile(filePath);
return { buffer, name: record.name };
} catch {
return null;
}
}
/** Lijst alle bestanden */
export async function localListFiles(): Promise<FileRecord[]> {
const db = await readDb();
return Object.values(db);
}
/** Verwijder een bestand */
export async function localDeleteFile(cid: string): Promise<void> {
const db = await readDb();
const record = db[cid];
if (!record) return;
// Verwijder het bestand van disk
const filePath = join(DATA_DIR, record.path);
try {
await unlink(filePath);
} catch {
// Bestand bestaat niet meer — niet erg
}
// Verwijder uit de database
delete db[cid];
await writeDb(db);
}
/** Bereken totale opslag */
export async function localGetStorageUsage(): Promise<{ files: number; totalBytes: number }> {
const db = await readDb();
const files = Object.values(db);
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
return { files: files.length, totalBytes };
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Local pin storage — JSON file based
* Gebruikt wanneer KUBO_API_URL niet beschikbaar is (development/local).
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
const PIN_FILE = join(process.cwd(), '.data', 'pins.json');
interface PinRecord {
cid: string;
name: string;
size: number;
created: string;
}
async function ensureDir() {
const dir = join(process.cwd(), '.data');
await mkdir(dir, { recursive: true });
}
async function readPins(): Promise<Record<string, PinRecord>> {
try {
const data = await readFile(PIN_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
async function writePins(pins: Record<string, PinRecord>): Promise<void> {
await ensureDir();
await writeFile(PIN_FILE, JSON.stringify(pins, null, 2), 'utf-8');
}
export async function localListPins(): Promise<PinRecord[]> {
const pins = await readPins();
return Object.values(pins);
}
export async function localAddPin(cid: string, name?: string, size: number = 0): Promise<{ cid: string }> {
const pins = await readPins();
pins[cid] = {
cid,
name: name || '',
size,
created: new Date().toISOString(),
};
await writePins(pins);
return { cid };
}
export async function localRemovePin(cid: string): Promise<void> {
const pins = await readPins();
delete pins[cid];
await writePins(pins);
}
export async function localGetPinCount(): Promise<number> {
const pins = await readPins();
return Object.keys(pins).length;
}
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock viem voordat we de module importeren
const mockReadContract = vi.fn();
const mockGetBytecode = vi.fn();
vi.mock('viem', () => ({
createPublicClient: vi.fn(() => ({
readContract: mockReadContract,
getBytecode: mockGetBytecode,
})),
http: vi.fn(),
}));
// Mock abi
vi.mock('../abi', () => ({
zkSyncLocal: {},
IPFS_PORTAL_PAYMENT_ABI: [],
DEFAULT_CONTRACT_ADDRESS: '0x0000000000000000000000000000000000000000',
}));
// Import na het mocken
import { PaymentReadService } from '../read-service';
describe('PaymentReadService', () => {
let service: PaymentReadService;
beforeEach(() => {
vi.clearAllMocks();
service = new PaymentReadService();
});
describe('constructor', () => {
it('uses default contract address when none provided', () => {
expect(service['contractAddress']).toBe('0x0000000000000000000000000000000000000000');
});
it('uses provided contract address', () => {
const customService = new PaymentReadService('0x123');
expect(customService['contractAddress']).toBe('0x123');
});
});
describe('setContractAddress', () => {
it('updates contract address', () => {
service.setContractAddress('0x456');
expect(service['contractAddress']).toBe('0x456');
});
});
describe('getPrice', () => {
it('returns price info', async () => {
mockReadContract.mockResolvedValue([
1000000000000000000n, // totalWei
500, // discountBps
950000000000000000n, // finalWei
false, // isFree
]);
const result = await service.getPrice(1024, '0x123');
expect(result).toEqual({
totalWei: 1000000000000000000n,
discountBps: 500,
finalWei: 950000000000000000n,
isFree: false,
});
});
});
describe('getUserStats', () => {
it('returns user upload stats', async () => {
mockReadContract.mockResolvedValue([
1024000n, // totalBytes
5, // uploadCount
1048576n, // remainingFree
]);
const result = await service.getUserStats('0x123');
expect(result).toEqual({
totalBytes: 1024000n,
uploadCount: 5,
remainingFree: 1048576n,
});
});
});
describe('getUserUploads', () => {
it('returns full user stats with uploads', async () => {
mockReadContract.mockResolvedValue([
1024000n, // totalBytes
2, // uploadCount
1048576n, // remainingFree
[ // rawRecords
['QmTest1', 512000n, 'ETH', 1000000000000000n, 1234567890n],
['QmTest2', 512000n, 'FREE', 0n, 1234567891n],
],
]);
const result = await service.getUserUploads('0x123');
expect(result.totalBytes).toBe(1024000n);
expect(result.uploadCount).toBe(2);
expect(result.uploads).toHaveLength(2);
expect(result.uploads[0].cid).toBe('QmTest1');
expect(result.uploads[1].tokenSymbol).toBe('FREE');
});
});
describe('getFreeTierBytes', () => {
it('returns free tier bytes', async () => {
mockReadContract.mockResolvedValue(1048576n);
const result = await service.getFreeTierBytes();
expect(result).toBe(1048576n);
});
});
describe('getBasePricePerMB', () => {
it('returns base price per MB', async () => {
mockReadContract.mockResolvedValue(1000000000000000n);
const result = await service.getBasePricePerMB();
expect(result).toBe(1000000000000000n);
});
});
describe('getOwner', () => {
it('returns contract owner', async () => {
mockReadContract.mockResolvedValue('0xOwnerAddress');
const result = await service.getOwner();
expect(result).toBe('0xOwnerAddress');
});
});
describe('getTokenSymbols', () => {
it('returns token symbols', async () => {
mockReadContract.mockResolvedValue(['ETH', 'USDC', 'MAOS']);
const result = await service.getTokenSymbols();
expect(result).toEqual(['ETH', 'USDC', 'MAOS']);
});
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PaymentService } from '../write-service';
// Mock viem
vi.mock('viem', () => ({
createWalletClient: vi.fn(() => ({
getAddresses: vi.fn().mockResolvedValue(['0x1234567890123456789012345678901234567890']),
sendTransaction: vi.fn().mockResolvedValue('0xTxHash'),
writeContract: vi.fn().mockResolvedValue('0xContractHash'),
})),
createPublicClient: vi.fn(() => ({
readContract: vi.fn().mockResolvedValue(1000000n),
getBytecode: vi.fn().mockResolvedValue('0x123'),
})),
encodeFunctionData: vi.fn().mockReturnValue('0xEncodedData'),
custom: vi.fn(),
http: vi.fn(),
}));
// Mock abi
vi.mock('../abi', () => ({
zkSyncLocal: {},
IPFS_PORTAL_PAYMENT_ABI: [],
ERC20_ABI: [],
DEFAULT_CONTRACT_ADDRESS: '0x0000000000000000000000000000000000000000',
}));
describe('PaymentService', () => {
let service: PaymentService;
let mockProvider: { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> };
beforeEach(() => {
vi.clearAllMocks();
service = new PaymentService();
mockProvider = {
request: vi.fn() as (args: { method: string; params?: unknown[] }) => Promise<unknown>,
};
});
describe('payWithETH', () => {
it('sends ETH payment', async () => {
const hash = await service.payWithETH(
mockProvider,
'QmTest123',
1024,
1000000000000000n
);
expect(hash).toBe('0xTxHash');
});
});
describe('approveAndPayWithToken', () => {
it('approves and pays with token', async () => {
const result = await service.approveAndPayWithToken(
mockProvider,
'0xTokenAddress',
'QmTest123',
1024,
1000000n,
'USDC'
);
expect(result).toHaveProperty('payHash');
});
});
describe('adminSetPricePerMB', () => {
it('sets price per MB', async () => {
const hash = await service.adminSetPricePerMB(
mockProvider,
1000000000000000n
);
expect(hash).toBe('0xTxHash');
});
});
describe('adminSetFreeTierBytes', () => {
it('sets free tier bytes', async () => {
const hash = await service.adminSetFreeTierBytes(
mockProvider,
1048576n
);
expect(hash).toBe('0xTxHash');
});
});
describe('adminConfigureToken', () => {
it('configures token', async () => {
const hash = await service.adminConfigureToken(
mockProvider,
'USDC',
'0xTokenAddress',
6,
1000000n,
true
);
expect(hash).toBe('0xTxHash');
});
});
describe('isDeployed', () => {
it('returns false when using default contract address', async () => {
// Default address is 0x0...0, dus zou false moeten retourneren
const result = await service.isDeployed();
expect(result).toBe(false);
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { connectWallet } from '../actions';
// Maak een schone window mock voor elke test
function createWindowMock() {
return {
ethereum: undefined as any,
};
}
describe('connectWallet', () => {
let windowMock: ReturnType<typeof createWindowMock>;
beforeEach(() => {
vi.clearAllMocks();
windowMock = createWindowMock();
// Mock window properties via globalThis
vi.stubGlobal('window', {
get ethereum() { return windowMock.ethereum; },
set ethereum(val) { windowMock.ethereum = val; },
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('throws error when no wallet is found', async () => {
await expect(connectWallet()).rejects.toThrow(
'No wallet found'
);
});
it('connects with injected provider', async () => {
const mockProvider = {
request: vi.fn()
.mockResolvedValueOnce(['0x1234567890123456789012345678901234567890'])
.mockResolvedValueOnce('0x10E'), // chainId 270 in hex
};
windowMock.ethereum = mockProvider;
const result = await connectWallet();
expect(result).toEqual({
address: '0x1234567890123456789012345678901234567890',
chainId: 270,
});
expect(mockProvider.request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' });
expect(mockProvider.request).toHaveBeenCalledWith({ method: 'eth_chainId' });
});
it('uses provided provider when given', async () => {
const mockProvider = {
request: vi.fn()
.mockResolvedValueOnce(['0xabcdef'])
.mockResolvedValueOnce('0x1'),
};
const result = await connectWallet(mockProvider);
expect(result).toEqual({
address: '0xabcdef',
chainId: 1,
});
});
it('handles empty accounts array', async () => {
const mockProvider = {
request: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce('0x1'),
};
windowMock.ethereum = mockProvider;
const result = await connectWallet();
expect(result.address).toBeUndefined();
});
it('converts hex chainId to decimal', async () => {
const mockProvider = {
request: vi.fn()
.mockResolvedValueOnce(['0x123'])
.mockResolvedValueOnce('0x10'), // 16 in hex
};
windowMock.ethereum = mockProvider;
const result = await connectWallet();
expect(result.chainId).toBe(16);
});
it('falls back to next provider when first fails', async () => {
const failingProvider = {
request: vi.fn().mockRejectedValue(new Error('User rejected')),
};
const workingProvider = {
request: vi.fn()
.mockResolvedValueOnce(['0xfallback'])
.mockResolvedValueOnce('0x1'),
};
// Simuleer: geen ethereum provider beschikbaar (alleen failing via specifieke provider)
const result = await connectWallet(failingProvider).catch(() => null);
// Als fallback niet mogelijk is (geen window.ethereum), test via specifieke provider
expect(failingProvider.request).toHaveBeenCalled();
});
});
+53 -5
View File
@@ -4,16 +4,64 @@ import type { WalletProvider } from './types';
import { getInjectedProvider } from './provider'; import { getInjectedProvider } from './provider';
import { ZKSYNC_LOCAL_CHAIN } from './chain'; import { ZKSYNC_LOCAL_CHAIN } from './chain';
/**
* Get all available injected providers in priority order.
* Tries MetaMask/Rabby/etc.
*/
function getAvailableProviders(): WalletProvider[] {
if (typeof window === 'undefined') return [];
const providers: WalletProvider[] = [];
if (window.ethereum && !providers.includes(window.ethereum as WalletProvider)) {
providers.push(window.ethereum as WalletProvider);
}
return providers;
}
/** Connect wallet and return address + chainId */ /** Connect wallet and return address + chainId */
export async function connectWallet( export async function connectWallet(
provider?: WalletProvider, provider?: WalletProvider,
): Promise<{ address: string; chainId: number }> { ): Promise<{ address: string; chainId: number }> {
const p = provider || getInjectedProvider(); // Als een specifieke provider is meegegeven, gebruik die
if (!p) if (provider) {
throw new Error( return connectToProvider(provider);
'No wallet found. Install MetaMask, Rabby, or MAOS extension.', }
);
// Anders: probeer alle beschikbare providers (fallback bij vault locked)
const providers = getAvailableProviders();
if (providers.length === 0) {
throw new Error(
'No wallet found. Install MetaMask or Rabby extension.',
);
}
let lastError: string = 'No wallet providers available';
for (const p of providers) {
try {
return await connectToProvider(p);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[connectWallet] Provider failed, trying next:', message);
lastError = message;
// Bij "user rejected" — stop meteen, gebruiker wil niet verbinden
if (message.toLowerCase().includes('user rejected') ||
message.toLowerCase().includes('user denied') ||
message.toLowerCase().includes('user abort')) {
throw new Error('Verbinding geweigerd. Klik opnieuw en bevestig in je wallet.');
}
// Bij andere fout (vault locked, etc.) — probeer volgende provider
continue;
}
}
// Alle providers gefaald
throw new Error(lastError || 'Alle wallet verbindingen mislukt');
}
/** Connect to a single provider */
async function connectToProvider(
p: WalletProvider,
): Promise<{ address: string; chainId: number }> {
const accounts: string[] = await p.request({ method: 'eth_requestAccounts' }); const accounts: string[] = await p.request({ method: 'eth_requestAccounts' });
const chainIdHex: string = await p.request({ method: 'eth_chainId' }); const chainIdHex: string = await p.request({ method: 'eth_chainId' });
+6 -17
View File
@@ -2,18 +2,16 @@
import type { WalletProvider, DetectedWallet } from './types'; import type { WalletProvider, DetectedWallet } from './types';
/** Get best injected provider — MAOS preferred if available */ /** Get best injected provider */
export function getInjectedProvider(): WalletProvider | null { export function getInjectedProvider(): WalletProvider | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
if (window.maosv6) return window.maosv6 as WalletProvider;
if (window.ethereum) return window.ethereum as WalletProvider; if (window.ethereum) return window.ethereum as WalletProvider;
return null; return null;
} }
/** Detect wallet type from injected provider */ /** Detect wallet type from injected provider */
export function detectWalletType(): 'maos' | 'metamask' | 'rabby' | 'other' | null { export function detectWalletType(): 'metamask' | 'rabby' | 'other' | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
if (window.maosv6) return 'maos';
if (window.ethereum?.isRabby) return 'rabby'; if (window.ethereum?.isRabby) return 'rabby';
if (window.ethereum?.isMetaMask) return 'metamask'; if (window.ethereum?.isMetaMask) return 'metamask';
if (window.ethereum) return 'other'; if (window.ethereum) return 'other';
@@ -34,15 +32,11 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
const detail = e.detail; const detail = e.detail;
if (detail?.provider) { if (detail?.provider) {
const info = detail.info || {}; const info = detail.info || {};
const isMAOS = !!(
info.name?.toLowerCase().includes('maos') || detail.provider.isMAOSv6
);
if (!wallets.some((w) => w.provider === detail.provider)) { if (!wallets.some((w) => w.provider === detail.provider)) {
wallets.push({ wallets.push({
name: info.name || (isMAOS ? 'MAOS Wallet' : 'Unknown Wallet'), name: info.name || 'Unknown Wallet',
provider: detail.provider, provider: detail.provider,
icon: info.icon, icon: info.icon,
isMAOS,
}); });
} }
} }
@@ -59,16 +53,11 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
const legacy = window.ethereum as WalletProvider | undefined; const legacy = window.ethereum as WalletProvider | undefined;
if (legacy && !wallets.some((w) => w.provider === legacy)) { if (legacy && !wallets.some((w) => w.provider === legacy)) {
const isMetaMask = window.ethereum?.isMetaMask === true; const isMetaMask = window.ethereum?.isMetaMask === true;
const isMAOS =
window.maosv6 !== undefined || window.ethereum?.isMAOSv6 === true;
wallets.push({ wallets.push({
name: isMAOS name: isMetaMask
? 'MAOS Wallet' ? 'MetaMask'
: isMetaMask : 'Wallet',
? 'MetaMask'
: 'Wallet',
provider: legacy, provider: legacy,
isMAOS: !!isMAOS,
}); });
} }
clearTimeout(timeout); clearTimeout(timeout);
+1 -4
View File
@@ -5,12 +5,10 @@ declare global {
ethereum?: { ethereum?: {
isMetaMask?: boolean; isMetaMask?: boolean;
isRabby?: boolean; isRabby?: boolean;
isMAOSv6?: boolean;
request: (args: { method: string; params?: unknown[] }) => Promise<any>; request: (args: { method: string; params?: unknown[] }) => Promise<any>;
on?: (event: string, cb: (...args: any[]) => void) => void; on?: (event: string, cb: (...args: any[]) => void) => void;
removeListener?: (event: string, cb: (...args: any[]) => void) => void; removeListener?: (event: string, cb: (...args: any[]) => void) => void;
}; };
maosv6?: WalletProvider;
} }
} }
@@ -25,12 +23,11 @@ export interface WalletState {
chainId: number; chainId: number;
isConnected: boolean; isConnected: boolean;
isConnecting: boolean; isConnecting: boolean;
walletType: 'metamask' | 'maos' | 'rabby' | 'other' | null; walletType: 'metamask' | 'rabby' | 'other' | null;
} }
export interface DetectedWallet { export interface DetectedWallet {
name: string; name: string;
provider: WalletProvider; provider: WalletProvider;
icon?: string; icon?: string;
isMAOS: boolean;
} }