Compare commits
5 Commits
88f2c7e5aa
...
bac719723d
| Author | SHA1 | Date | |
|---|---|---|---|
| bac719723d | |||
| 8e1231a7af | |||
| fa7de62707 | |||
| 0279282f7e | |||
| 4a5b146713 |
+93
-26
@@ -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,79 @@ 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
|
## 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`
|
- **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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>', () => {
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ export async function proxyIPFS(path: string, method: string, req: NextRequest):
|
|||||||
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
|
||||||
// ── Lokale pin opslag (altijd) ──
|
// ── 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/')) {
|
if (p === 'pins' || p.startsWith('pins/')) {
|
||||||
return handleLocalPins(p, method, req);
|
return handleLocalPins(p, method, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lokale file opslag (altijd) ──
|
// ── 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') {
|
if (p === 'files/upload' && method === 'POST') {
|
||||||
return handleLocalUpload(req);
|
return handleLocalUpload(req);
|
||||||
}
|
}
|
||||||
@@ -205,6 +214,20 @@ async function handleLocalDeleteFile(cid: string): Promise<Response> {
|
|||||||
return NextResponse.json({ ok: true });
|
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(/\/+$/, '');
|
||||||
@@ -213,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,8 +50,9 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bepaal content type op basis van CID prefix
|
// Content-type van de upstream overnemen: `cat` stuurt het echte media type
|
||||||
const contentType = detectContentType(cid);
|
// 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
|
// Check of filename is opgegeven
|
||||||
const filename = req.nextUrl.searchParams.get('filename');
|
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.
|
* Voldoet aan RFC 9110 §5.6: `type "/" subtype *( ";" parameter )`, met tokens
|
||||||
* IPFS content is meestal JSON, tenzij het een bekend formaat is.
|
* 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 {
|
const CONTENT_TYPE_PATTERN =
|
||||||
// Voor nu: standaard octet-stream, KUBO bepaalt het echte type
|
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+\/[A-Za-z0-9!#$%&'*+.^_`|~-]+(\s*;\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+=(?:[A-Za-z0-9!#$%&'*+.^_`|~-]+|"(?:[A-Za-z0-9!#$%&'*+.^_`|~-]|\s)*"))*$/;
|
||||||
return 'application/octet-stream';
|
|
||||||
|
/**
|
||||||
|
* 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,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('?');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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';
|
||||||
|
|||||||
+24
-3
@@ -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,9 +10,28 @@ 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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user