Compare commits

..

12 Commits

Author SHA1 Message Date
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
40 changed files with 2275 additions and 185 deletions
+7
View File
@@ -22,3 +22,10 @@
# IPFS Gateway (for share links)
# 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"
}
}
}
+52
View File
@@ -222,6 +222,58 @@ Catch-all API proxy. Routes:
---
## Write authentication
Muterende `/api/*`-endpoints eisen een token; lees-endpoints blijven publiek.
Ingang: `authorizeWrite()` in `src/app/api/[...path]/write-auth.ts`, aangeroepen
vanuit `POST` en `DELETE` in `route.ts`. De waarde komt uit
`ipfsWriteToken()` in `src/lib/config.ts` (env `IPFS_WRITE_TOKEN`).
| Endpoint | Methode | Write-gate |
|---|---|---|
| `/api/add` | POST | ✅ |
| `/api/files/upload` | POST | ✅ |
| `/api/files/<cid>` | DELETE | ✅ |
| `/api/pins` | POST | ✅ |
| `/api/pins/<cid>` | DELETE | ✅ |
| `/api/ipns/publish`, `/api/ipns/keys/gen/<naam>` | POST | ✅ |
| `/api/users/<naam>` | DELETE | ✅ |
| `/api/explorer/{ls,cat,stat,resolve}/<cid>` | POST | ❌ lees-only |
| `/api/pins`, `/api/files`, `/api/explorer/*`, `/api/health` | GET | ❌ publiek |
| `/ipfs/<cid>` (gateway) | GET | ❌ publiek |
| `/api/users` (registratie) | POST | ❌ open — eigen auth van Python-backend |
**Toegang tot een mutatie — één van beide volstaat:**
1. `Authorization: Bearer $IPFS_WRITE_TOKEN` (MAOS → portal, server-to-server).
Vergelijking is constant-time via SHA-256 digests (`crypto.timingSafeEqual`).
2. De bestaande portalsessie-JWT cookie `ipfs-portal-session` — identieke
verificatie als `src/middleware.ts`. Zónder dit pad zouden de eigen
upload-/pin-pagina's van de portal kapotgaan.
Geen van beide → `401` (geen `Authorization`-header) of `403` (header aanwezig,
verkeerde waarde), met `WWW-Authenticate: Bearer`.
**`IPFS_WRITE_TOKEN` niet geconfigureerd** → writes blijven **open** met een
eenmalige luide waarschuwing in de log. `src/lib/config.ts` heeft geen
"required in production"-patroon (alleen defaults, in tegenstelling tot
`JWT_SECRET` in `auth-server.ts`), en een harde fail zou elke nog niet
geprovisioneerde deploy direct breken — vandaar bewust fail-open + waarschuwing.
**Besluit — publieke lees-endpoints**: `GET /api/pins` en `GET /api/files`
blijven anoniem toegankelijk. Ze lekken alleen metadata (CID's, namen, maten);
content is sowieso publiek via de gateway. Ze achter de sessie zetten zou de
publieke explorer (pin-badges) én MAOS' pin-status/inventory-reads breken — die
sturen vandaag geen `Authorization`-header op GET. Zodra MAOS ook GET's met
Bearer verstuurt kan dit alsnog achter de gate.
**Rest-risico**: `POST /api/users` (registratie én admin-aanmaak, zelfde
endpoint) blijft open — een registrant heeft noch token noch sessie. De
Python-backend is daar de autoriteit (`X-Admin-Key` + doorgestuurd
`X-Session-Token`).
---
## Smart Contract (Chain 270 — zkSync Local)
**Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe`
+5 -2
View File
@@ -1,12 +1,15 @@
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 = [
"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'",
"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",
"connect-src 'self' ws: wss: ${process.env.NEXT_PUBLIC_ZKSYNC_RPC_URL || 'http://localhost:3050'}",
`connect-src 'self' ws: wss: ${zkSyncRpcUrl}`,
"frame-src 'self'",
"base-uri 'self'",
"form-action 'self'",
+169
View File
@@ -16,6 +16,7 @@
"postcss": "^8.5.15",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"siwe": "^3.0.0",
"swr": "^2.4.2",
"viem": "^2.29.0"
},
@@ -1106,6 +1107,47 @@
"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": {
"version": "1.1.0",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1706,6 +1761,107 @@
"@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": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -2416,6 +2572,19 @@
"dev": true,
"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": {
"version": "1.2.1",
"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",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"siwe": "^3.0.0",
"swr": "^2.4.2",
"viem": "^2.29.0"
},
+1 -2
View File
@@ -136,8 +136,7 @@ export default function AdminPaymentPage() {
const switched = await switchToChain270(prov || undefined);
if (switched) setWrongChain(false);
}
if (window.maosv6) setWalletType('MAOS Wallet');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet');
} catch (e: unknown) {
@@ -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);
});
});
+133 -3
View File
@@ -1,13 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
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> {
// In dev, the Kubo API is behind nginx with Basic Auth.
// If no KUBO_API_URL is configured, return a clear error.
// Normalize path — verwijder leading/trailing slashes
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
// ── Lokale pin opslag (altijd) ──
if (p === 'pins' || p.startsWith('pins/')) {
return handleLocalPins(p, method, req);
}
// ── Lokale file opslag (altijd) ──
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();
if (!kuboSvc) {
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 }
);
}
@@ -75,6 +100,111 @@ 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 });
}
export function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
+12
View File
@@ -8,6 +8,9 @@
* /api/* → IPFS Kubo API
*
* 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';
@@ -15,6 +18,7 @@ import type { RouteMatch } from './types';
import { proxyUserAPI } from './proxy-user';
import { proxyIPFS } from './proxy-ipfs';
import { handleHealth, proxyResult } from './handlers';
import { authorizeWrite } from './write-auth';
export { mapToKuboAPI } from './proxy-ipfs';
@@ -80,6 +84,10 @@ export async function POST(
const route = matchRoute(path, 'POST');
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) {
case 'user':
return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req));
@@ -98,6 +106,10 @@ export async function DELETE(
const route = matchRoute(path, 'DELETE');
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) {
case 'user':
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 ──
*
* Proxied naar Python User API /auth/challenge.
* Geeft nonce + message terug voor SIWE signing.
* Genereert SIWE challenge volledig server-side.
* Geen Python backend nodig — nonce + message worden hier aangemaakt.
*/
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';
// 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) {
try {
const body = await req.json();
@@ -18,15 +31,38 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Address required' }, { status: 400 });
}
const res = await fetch(`${userApiBase()}/auth/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address.toLowerCase() }),
signal: AbortSignal.timeout(5000),
// Genereer cryptografisch veilige nonce
const nonce = randomBytes(16).toString('hex');
// Gebruik het juiste domein — in productie: zksync.maos.dedyn.io
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();
return NextResponse.json(data, { status: res.status });
const message = siweMessage.prepareMessage();
// 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) {
const msg = e instanceof Error ? e.message : String(e);
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 ──
*
* SIWE wallet login: verifieert signed message via Python backend,
* maakt vervolgens JWT aan met session_token voor Next.js middleware.
* SIWE wallet login: verifieert signed message volledig server-side.
* Geen Python backend nodig — siwe + viem worden gebruikt voor verificatie.
*/
import { NextRequest, NextResponse } from 'next/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';
@@ -22,53 +23,56 @@ export async function POST(req: NextRequest) {
);
}
// Proxy login to Python User API
const res = await fetch(`${userApiBase()}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: address.toLowerCase(),
signature,
nonce,
}),
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
// 1. Verifieer nonce en haal origineel message op
const nonceResult = verifyNonce(address, nonce);
if (!nonceResult.valid || !nonceResult.message) {
return NextResponse.json(
{ error: 'Ongeldige of verlopen nonce. Vraag een nieuwe challenge aan.' },
{ status: 401 },
);
}
// Python backend returns { token, address, message, expires_in }
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';
// 2. Parse het SIWE message en verifieer handtekening
try {
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
headers: { 'X-Session-Token': pythonSessionToken },
signal: AbortSignal.timeout(3000),
});
if (verifyRes.ok) {
const verifyData = await verifyRes.json();
role = verifyData.role === 'admin' ? 'admin' : 'user';
const siweMessage = new SiweMessage(nonceResult.message);
const result = await siweMessage.verify({ signature });
if (!result.success) {
return NextResponse.json(
{ error: 'Handtekening verificatie mislukt' },
{ 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 {
// 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({
address: userAddress,
address: address.toLowerCase(),
role,
sessionToken: pythonSessionToken,
sessionToken,
});
const response = NextResponse.json({
authenticated: true,
address: userAddress,
address: address.toLowerCase(),
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="w-4 shrink-0" />
<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-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>
+273 -11
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
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 DirectoryListing from './components/DirectoryListing';
import FilePreview from './components/FilePreview';
@@ -13,12 +13,13 @@ import GatewayLink from './components/GatewayLink';
import SearchBar from '@/components/search';
import type { SearchResult } 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 ErrorBoundary from '@/components/ErrorBoundary';
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
import { gatewayLink } from '@/lib/helpers';
export default function ExplorerPage() {
const [cid, setCid] = useState('');
@@ -33,6 +34,75 @@ export default function ExplorerPage() {
const [recentCids] = useState<string[]>(loadRecentCids);
const [indexStats, setIndexStats] = useState(getIndexStats);
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)
function handlePinToggle(cid: string) {
@@ -69,15 +139,35 @@ export default function ExplorerPage() {
await navigateTo(hash);
}
async function handleBreadcrumbNavigate(cid: string) {
// Find the index of the clicked segment
const idx = path.findIndex((s) => s.cid === cid);
async function handleBreadcrumbNavigate(targetCid: string) {
// Als op de huidige CID geklikt wordt, doe niets
if (targetCid === cid) return;
// Zoek de index in het pad
const idx = path.findIndex((s) => s.cid === targetCid);
if (idx >= 0) {
// Navigeer naar een bestaand segment in het pad
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 {
// Terug naar root (geen pad gevonden)
setPath([]);
setState('idle');
setCid('');
setEntries([]);
}
await navigateTo(cid);
}
async function handlePreview(hash: string) {
@@ -104,6 +194,21 @@ export default function ExplorerPage() {
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 ── */
function handleSearchResult(result: SearchResult) {
@@ -134,7 +239,13 @@ export default function ExplorerPage() {
indexed++;
}
} 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++;
}
}
@@ -147,6 +258,29 @@ export default function ExplorerPage() {
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 (
<PortalLayout>
<ErrorBoundary label="Verkenner">
@@ -201,6 +335,122 @@ export default function ExplorerPage() {
</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 */}
{state === 'idle' && (
<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">
{/* Breadcrumbs + Pin + Gateway row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<Breadcrumbs
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
onNavigate={handleBreadcrumbNavigate}
/>
<div className="flex items-center gap-2">
{path.length > 0 && (
<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">
<GatewayLink cid={cid} />
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
+83
View File
@@ -0,0 +1,83 @@
/**
* 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 },
);
}
// Bepaal content type op basis van CID prefix
const contentType = detectContentType(cid);
// 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 });
}
}
/**
* Detecteer content type op basis van CID prefix.
* IPFS content is meestal JSON, tenzij het een bekend formaat is.
*/
function detectContentType(cid: string): string {
// Voor nu: standaard octet-stream, KUBO bepaalt het echte type
return 'application/octet-stream';
}
+1 -1
View File
@@ -4,7 +4,7 @@ import Providers from '@/components/Providers';
import SWRegister from '@/components/SWRegister';
export const metadata: Metadata = {
title: 'MAOS IPFS Portal',
title: 'IPFS Portal',
description: 'Decentralized storage management for your IPFS node',
manifest: '/manifest.json',
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)}` });
router.push('/dashboard');
} 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 {
setBusy(false);
setBusyWallet(null);
@@ -100,11 +108,6 @@ export default function LoginPage() {
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-surface-200">
{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 className="text-xs text-surface-500 mt-0.5">
Klik om te verbinden
@@ -135,7 +138,7 @@ export default function LoginPage() {
{/* Footer */}
<p className="text-center text-[10px] text-surface-600 mt-6">
<Shield className="w-3 h-3 inline-block mr-1" />
Verbonden met MAOS IPFS node
Verbonden met IPFS node
</p>
</div>
</div>
+7 -8
View File
@@ -1,14 +1,13 @@
'use client';
import { useState } from 'react';
import { connectWallet, signMessage } from '@/lib/wallet';
import { connectWallet } from '@/lib/wallet';
import { checkHealth } from '@/lib/api/gateway';
import { useRouter } from 'next/navigation';
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
export default function LandingPage() {
const router = useRouter();
const [address, setAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -16,13 +15,13 @@ export default function LandingPage() {
setLoading(true);
setError(null);
try {
const wallet = await connectWallet();
setAddress(wallet.address);
// Non-blocking health check — portal UI werkt ook zonder backend
await connectWallet(); // Geen provider → probeert alle wallets自动
checkHealth().catch(() => {});
router.push('/dashboard');
} 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 {
setLoading(false);
}
@@ -39,7 +38,7 @@ export default function LandingPage() {
{/* Badge */}
<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" />
<span>MAOSv6 · IPFS Gateway</span>
<span>IPFS Gateway</span>
</div>
{/* Title */}
@@ -109,7 +108,7 @@ export default function LandingPage() {
{/* Footer */}
<div className="absolute bottom-6 text-xs text-surface-600">
MAOSv6 · Chain 270
Chain 270
</div>
</div>
);
+14 -11
View File
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import {
User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
} from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function ProfilePage() {
const { user, logout, isAdmin } = useAuth();
@@ -68,18 +69,19 @@ export default function ProfilePage() {
return (
<PortalLayout>
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Profiel</h1>
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
<ErrorBoundary label="Profiel">
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
<div>
<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>
<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">
{/* ── Account info ── */}
@@ -180,6 +182,7 @@ export default function ProfilePage() {
</div>
</div>
</div>
</ErrorBoundary>
</PortalLayout>
);
}
+41 -29
View File
@@ -3,6 +3,7 @@
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
import { uploadFile } from '@/lib/api/files';
import { addToHistory, getSettings } from '@/lib/storage';
import { addPin } from '@/lib/api/pins';
import PortalLayout from '@/app/layout-portal';
import { checkUploadAllowed } from '@/lib/limits';
import { useNotify } from '@/lib/notifications';
@@ -12,6 +13,7 @@ import QuickUpload, { type FileEntry } from './components/QuickUpload';
import { PaymentProvider } from '@/lib/payment';
import CryptoUpload from './components/CryptoUpload';
import type { TabMode } from './helpers';
import ErrorBoundary from '@/components/ErrorBoundary';
/* ════════════════════════════ Page ════════════════════════════ */
@@ -143,6 +145,10 @@ export default function UploadPage() {
const result = await uploadFile(entry.file);
const date = new Date().toISOString();
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 =>
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);
setCryptoResult(r);
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;
}
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
@@ -201,36 +211,37 @@ export default function UploadPage() {
return (
<PortalLayout>
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Upload</h1>
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p>
</div>
<ErrorBoundary label="Upload">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Upload</h1>
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p>
</div>
{/* ── Tab switcher ── */}
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
<button
onClick={() => setTab('quick')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'quick'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<Zap className="w-4 h-4" />
Quick Upload
</button>
<button
onClick={() => setTab('crypto')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'crypto'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<ArrowUp className="w-4 h-4" />
Crypto Payment
</button>
</div>
{/* ── Tab switcher ── */}
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
<button
onClick={() => setTab('quick')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'quick'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<Zap className="w-4 h-4" />
Quick Upload
</button>
<button
onClick={() => setTab('crypto')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'crypto'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<ArrowUp className="w-4 h-4" />
Crypto Payment
</button>
</div>
{/* Quick Upload tab */}
{tab === 'quick' && (
@@ -275,6 +286,7 @@ export default function UploadPage() {
/>
</PaymentProvider>
)}
</ErrorBoundary>
</PortalLayout>
);
}
+10 -7
View File
@@ -13,6 +13,7 @@ import {
HardDrive, Upload, Wifi, Database, Pin, Activity,
ArrowUpRight, ArrowDownLeft,
} from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function UsagePage() {
const { user } = useAuth();
@@ -47,13 +48,14 @@ export default function UsagePage() {
return (
<PortalLayout>
{/* ── Header ── */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Usage</h1>
<p className="text-sm text-surface-400 mt-1">
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
</p>
</div>
<ErrorBoundary label="Usage">
{/* ── Header ── */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Usage</h1>
<p className="text-sm text-surface-400 mt-1">
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
@@ -164,6 +166,7 @@ export default function UsagePage() {
</div>
</div>
)}
</ErrorBoundary>
</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);
// Detect wallet type
if (window.maosv6) setWalletType('MAOS Wallet');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet');
@@ -104,7 +103,11 @@ export default function PaymentPanel({
await refreshPricing(result.address, fileSize);
} 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 {
setConnecting(false);
}
+1 -1
View File
@@ -118,6 +118,6 @@ describe('truncateCid', () => {
});
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;
}
/** 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[]> {
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> {
+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",
params: [message, address],
});
} catch (err) {
} catch (err: unknown) {
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
+10
View File
@@ -47,6 +47,16 @@ export const paymentContractAddress = (): string =>
export const gatewayUrl = (): string =>
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 ──
/** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */
+2 -1
View File
@@ -17,7 +17,8 @@ export function gatewayLink(cid: string): string {
export const gatewayUrl = gatewayLink;
/* ── 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;
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 { 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 */
export async function connectWallet(
provider?: WalletProvider,
): Promise<{ address: string; chainId: number }> {
const p = provider || getInjectedProvider();
if (!p)
throw new Error(
'No wallet found. Install MetaMask, Rabby, or MAOS extension.',
);
// Als een specifieke provider is meegegeven, gebruik die
if (provider) {
return connectToProvider(provider);
}
// 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 chainIdHex: string = await p.request({ method: 'eth_chainId' });
+6 -17
View File
@@ -2,18 +2,16 @@
import type { WalletProvider, DetectedWallet } from './types';
/** Get best injected provider — MAOS preferred if available */
/** Get best injected provider */
export function getInjectedProvider(): WalletProvider | null {
if (typeof window === 'undefined') return null;
if (window.maosv6) return window.maosv6 as WalletProvider;
if (window.ethereum) return window.ethereum as WalletProvider;
return null;
}
/** 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 (window.maosv6) return 'maos';
if (window.ethereum?.isRabby) return 'rabby';
if (window.ethereum?.isMetaMask) return 'metamask';
if (window.ethereum) return 'other';
@@ -34,15 +32,11 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
const detail = e.detail;
if (detail?.provider) {
const info = detail.info || {};
const isMAOS = !!(
info.name?.toLowerCase().includes('maos') || detail.provider.isMAOSv6
);
if (!wallets.some((w) => w.provider === detail.provider)) {
wallets.push({
name: info.name || (isMAOS ? 'MAOS Wallet' : 'Unknown Wallet'),
name: info.name || 'Unknown Wallet',
provider: detail.provider,
icon: info.icon,
isMAOS,
});
}
}
@@ -59,16 +53,11 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
const legacy = window.ethereum as WalletProvider | undefined;
if (legacy && !wallets.some((w) => w.provider === legacy)) {
const isMetaMask = window.ethereum?.isMetaMask === true;
const isMAOS =
window.maosv6 !== undefined || window.ethereum?.isMAOSv6 === true;
wallets.push({
name: isMAOS
? 'MAOS Wallet'
: isMetaMask
? 'MetaMask'
: 'Wallet',
name: isMetaMask
? 'MetaMask'
: 'Wallet',
provider: legacy,
isMAOS: !!isMAOS,
});
}
clearTimeout(timeout);
+1 -4
View File
@@ -5,12 +5,10 @@ declare global {
ethereum?: {
isMetaMask?: boolean;
isRabby?: boolean;
isMAOSv6?: boolean;
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
on?: (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;
isConnected: boolean;
isConnecting: boolean;
walletType: 'metamask' | 'maos' | 'rabby' | 'other' | null;
walletType: 'metamask' | 'rabby' | 'other' | null;
}
export interface DetectedWallet {
name: string;
provider: WalletProvider;
icon?: string;
isMAOS: boolean;
}