Compare commits

...

5 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
11 changed files with 652 additions and 70 deletions
+93 -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) |
| IPFS | Kubo RPC API (port 5001) |
| 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 │
│ 'users' → proxyUserAPI() → :8444 (Python) │
│ default → proxyIPFS() → :5001 (Kubo) │
│ pins/files → lokale .data-opslag (nooit Kubo) │
│ │
│ 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 |
|---|---|---|---|---|
| Landing | `/` | ~130 | Kubo (health) | Hero + connect wallet + quick stats |
| Dashboard | `/dashboard` | 253 | Kubo (peers, pins, health, repo, bw) | Node overview, stats, bandwidth |
| Upload | `/upload` | **590** | Kubo + Smart Contract | Quick + crypto file upload |
| Explorer | `/explorer` | 244 | Kubo (ls, cat) | IPFS content browser |
| Dashboard | `/dashboard` | 253 | Kubo (peers, repo/stats), inline health, lokale pins | Node overview, stats, bandwidth |
| Upload | `/upload` | **590** | Lokale `.data`-opslag + Smart Contract | Quick + crypto file upload |
| Explorer | `/explorer` | 244 | Kubo (ls, cat) + lokale pins | IPFS content browser |
| IPNS | `/ipns` | 285 | Kubo (keys, publish, resolve) | IPNS name management |
| Users | `/users` | 275 | Python API + Smart Contract | htpasswd users + wallet lookup |
| Peers | `/peers` | 63 | Kubo (swarm/peers) | Connected peers list |
| Pins | `/pins` | 167 | 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 |
| Settings | `/settings` | 319 | localStorage only | App configuration |
| History | `/history` | 393 | localStorage only | Upload history viewer |
@@ -105,7 +106,8 @@ Page Component
→ fetch('/api/endpoint', { credentials: 'include' })
→ Next.js [...path]/route.ts
→ matchRoute(path)
→ proxyIPFS() → Kubo RPC :5001
→ proxyIPFS() → Kubo RPC :5001 (explorer/*, ipns/*, …)
→ proxyIPFS() → lokale .data-opslag (pins, files)
→ proxyUserAPI() → Python :8444
```
@@ -116,7 +118,8 @@ User drags file
→ UploadPage state: FileEntry[]
→ startUpload() — sequential per file
→ 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
→ Show results (CID, gateway link, copy)
```
@@ -131,7 +134,7 @@ User selects file
→ PaymentService.getSupportedTokens() → [ETH, USDC, MAOS]
→ PaymentService.getMAOSDiscountTiers() → discount %
→ 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 2b (ETH): payWithETH(provider, cid, size, wei)
→ sendTransaction → contract.payWithETH(cid, bytesSize)
@@ -156,21 +159,24 @@ Admin connects wallet
## 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 |
|---|---|---|---|
| `getNodeInfo()` | GET | `/api/node/info` | Node |
| `getPeers()` | GET | `/api/peers` | Peers |
| `listPins()` / `addPin()` / `removePin()` | GET/POST/DELETE | `/api/pins` | Pins |
| `uploadFile(file)` | POST (FormData) | `/api/files/upload` | Files |
| `listUsers()` / `createUser()` / `deleteUser()` | GET/POST/DELETE | `/api/users` | Users |
| `explorerLs()` / `explorerCat()` / `explorerStat()` | POST | `/api/explorer/*` | Explorer |
| `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | GET/POST | `/api/ipns/*` | IPNS |
| `getRepoStats()` / `getBWStats()` | GET | `/api/repo` / `/api/bw` | Stats |
| `checkHealth()` | GET | `/api/health` | Health |
| Module | Exporteert | Endpoint |
|---|---|---|
| `client.ts` | `apiFetch()` + gedeelde types | — |
| `gateway.ts` | `getNodeInfo()`, `getPeers()`, `getRepoStats()`, `getBWStats()`, `checkHealth()` | `/api/node/info`, `/api/peers`, `/api/repo/stats`, `/api/bw/stats`, `/api/health` |
| `pins.ts` | `listPins()` / `addPin()` / `removePin()` | `/api/pins` (lokaal) |
| `files.ts` | `uploadFile()`, `listFiles()` | `/api/files/upload`, `/api/files` (lokaal) |
| `users.ts` | `listUsers()` / `createUser()` / `deleteUser()` | `/api/users` (Python) |
| `explorer.ts` | `explorerLs()` / `explorerCat()` / `explorerStat()` | `/api/explorer/*` (Kubo) |
| `ipns.ts` | `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | `/api/ipns/*` (Kubo) |
`src/lib/api.ts.bak` is een achtergebleven backup van het oude monolitische
`api.ts` en wordt nergens geïmporteerd.
### `src/lib/wallet.ts` (196 lines)
@@ -200,7 +206,7 @@ localStorage persistence.
- `PortalSettings`: gateway URL, API endpoint, storage max, refresh, theme
- `UploadRecord`: history with dedup by CID+name, capped at 500 entries
### `src/app/api/[...path]/route.ts` (~320 lines)
### `src/app/api/[...path]/route.ts` (121 lines)
Catch-all API proxy. Routes:
@@ -208,18 +214,79 @@ Catch-all API proxy. Routes:
|---|---|---|
| `/api/health` | `handleHealth()` | Inline JSON |
| `/api/users*` | `proxyUserAPI()` | Python :8444 |
| `/api/pins*`, `/api/files*` | `proxyIPFS()` → lokale handlers | `.data` op schijf |
| `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 |
**`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`
- `/peers` → `/api/v0/swarm/peers`
- `/pins` → `/api/v0/pin/ls|add|rm`
- `/files/upload` → `/api/v0/add`
- `/explorer/ls` → `/api/v0/ls`
- `/ipns/publish` → `/api/v0/name/publish`
- `/repo/stats` → `/api/v0/repo/stat`
- `/bw/stats` → `/api/v0/bw`
`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
@@ -351,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`
- **Python API**: `http://192.168.1.176:8444` (htpasswd + admin key)
- **Smart Contract**: `0xCBc6...` on chain 270 (zkSync Local)
- **IPFS Gateway**: `https://maos.dedyn.io/ipfs/`
- **IPFS Gateway**: `https://ipfs.maos.dedyn.io/ipfs/`
- **Refresh Interval**: 15s (Dashboard)
- **Max Uploads**: 20 files, 100MB each
- **Storage Max**: 30 GB (localStorage setting)
@@ -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');
});
it('maps pins GET to /api/v0/pin/ls', () => {
expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls');
it('heeft GEEN tak voor pins — die worden lokaal afgehandeld', () => {
// `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', () => {
expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add');
it('heeft GEEN specifieke tak voor files/upload — die wordt lokaal afgehandeld', () => {
// 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', () => {
expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123');
});
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('mapt files/<subpad> naar ls (alleen bereikbaar zonder GET/DELETE)', () => {
// 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 explorer/ls/<cid>', () => {
+25 -9
View File
@@ -8,11 +8,20 @@ export async function proxyIPFS(path: string, method: string, req: NextRequest):
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);
}
@@ -205,6 +214,20 @@ async function handleLocalDeleteFile(cid: string): Promise<Response> {
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 {
// Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -213,16 +236,9 @@ export function mapToKuboAPI(path: string, method: string): string {
return '/api/v0/id';
case 'peers':
return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default:
if (p.startsWith('pins/')) {
const cid = p.slice(5);
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
// Alleen niet-`pins`/niet-`files/upload`-paden komen hier terecht; `files/*`
// voor niet-GET/DELETE-mutaties valt nog door naar Kubo.
if (p.startsWith('files/')) {
return '/api/v0/ls';
}
+7 -3
View File
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Copy, CheckCircle } from 'lucide-react';
import { gatewayLink } from '@/lib/helpers';
interface GatewayLinkProps {
cid: string;
@@ -10,11 +11,14 @@ interface GatewayLinkProps {
export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
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() {
try {
await navigator.clipboard.writeText(gatewayUrl);
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
@@ -25,7 +29,7 @@ export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
return (
<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">
{gatewayUrl}
{link}
</code>
<button
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=');
});
});
+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);
});
});
+20 -7
View File
@@ -50,8 +50,9 @@ export async function GET(
);
}
// Bepaal content type op basis van CID prefix
const contentType = detectContentType(cid);
// 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');
@@ -73,11 +74,23 @@ export async function GET(
}
}
/** Content-type als Kubo `cat` er geen bruikbaar type voor terugstuurt. */
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
/**
* Detecteer content type op basis van CID prefix.
* IPFS content is meestal JSON, tenzij het een bekend formaat is.
* 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.
*/
function detectContentType(cid: string): string {
// Voor nu: standaard octet-stream, KUBO bepaalt het echte type
return 'application/octet-stream';
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;
}
+38 -6
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';
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', () => {
it('formats 0 as "0 B"', () => {
expect(formatBytes(0)).toBe('0 B');
@@ -68,18 +75,43 @@ describe('formatBytes', () => {
});
describe('gatewayLink', () => {
it('returns correct gateway URL for valid CID', () => {
it('zet het CID achter het /ipfs/-segment van de gateway', () => {
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', () => {
const cid = 'bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q';
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q');
const cid = 'bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q';
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/ipfs/bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q');
});
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('?');
});
});
+3 -2
View File
@@ -2,6 +2,8 @@
* Utility om IPFS content te downloaden als bestand.
*/
import { gatewayLink } from './helpers';
/**
* Download een bestand van IPFS via de explorer/cat proxy.
* 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).
*/
export function downloadViaGateway(cid: string, filename?: string) {
const gateway = process.env.NEXT_PUBLIC_GATEWAY_URL || 'https://maos.dedyn.io';
const a = document.createElement('a');
a.href = `${gateway}/ipfs/${cid}`;
a.href = gatewayLink(cid);
a.download = filename || cid;
a.target = '_blank';
a.rel = 'noopener noreferrer';
+24 -3
View File
@@ -1,3 +1,5 @@
import { gatewayUrl as configuredGatewayUrl } from './config';
/* ── Format bytes to human-readable size ── */
export function formatBytes(bytes: number | bigint): string {
const b = typeof bytes === 'bigint' ? Number(bytes) : bytes;
@@ -8,9 +10,28 @@ export function formatBytes(bytes: number | bigint): string {
return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i];
}
/* ── Gateway link for CID ── */
export function gatewayLink(cid: string): string {
return `https://ipfs.maos.dedyn.io/${cid}`;
/* ── Gateway link voor 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 */