Files
IPFS-portal/src/app/api/[...path]/proxy-ipfs.ts
T
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

184 lines
6.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
import { localListPins, localAddPin, localRemovePin } from '@/lib/local-pins';
export async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
// Normalize path
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
// ── Lokale pin opslag (zonder Kubo) ──
// Wanneer KUBO_API_URL niet beschikbaar is, gebruik lokale JSON file
if (p === 'pins' || p.startsWith('pins/')) {
const kuboSvc = kuboApiUrl();
if (!kuboSvc) {
return handleLocalPins(p, method, req);
}
}
// In dev, the Kubo API is behind nginx with Basic Auth.
// If no KUBO_API_URL is configured, return a clear error.
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' },
{ status: 501 }
);
}
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
const url = new URL(kuboSvc);
const kuboPath = mapToKuboAPI(path, method);
// Split pathname and query string — url.pathname encodes `?` as `%3F`
const [namePart, ...qsParts] = kuboPath.split('?');
url.pathname = namePart;
if (qsParts.length > 0) {
url.search = qsParts.join('?');
} else {
// Forward incoming query params (used by ipns/publish etc.)
const incomingSearch = req.nextUrl.search;
if (incomingSearch) {
url.search = incomingSearch;
}
}
const headers: Record<string, string> = {};
const auth = kuboBasicAuth();
if (auth) {
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
}
// Forward Content-Type (preserves multipart boundary for file uploads)
const reqCt = req.headers.get('content-type');
if (reqCt) {
headers['Content-Type'] = reqCt;
}
// Kubo uses POST for everything
const body = method === 'POST' || method === 'DELETE' ? req.body : null;
const fetchOpts: RequestInit & { duplex?: string } = {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
};
// Node.js fetch requires duplex: 'half' when streaming a body
if (body) {
fetchOpts.duplex = 'half';
fetchOpts.body = body;
}
let res: Response;
try {
res = await fetch(url.toString(), fetchOpts);
} catch (fetchErr: unknown) {
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
console.error('[proxyIPFS] fetch error:', msg);
return NextResponse.json(
{ error: 'IPFS proxy fetch failed', detail: msg.substring(0, 200) },
{ status: 502 },
);
}
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': res.headers.get('Content-Type') || 'application/json',
},
});
}
/** 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 body = await req.json();
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 });
}
export function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
switch (p) {
case 'node/info':
return '/api/v0/id';
case 'peers':
return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default:
if (p.startsWith('pins/')) {
const cid = p.slice(5);
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) {
return '/api/v0/ls';
}
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
if (p.startsWith('explorer/ls/')) {
const cid = p.slice('explorer/ls/'.length);
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/cat/')) {
const cid = p.slice('explorer/cat/'.length);
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/stat/')) {
const cid = p.slice('explorer/stat/'.length);
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/resolve/')) {
const name = p.slice('explorer/resolve/'.length);
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
}
// IPNS routes
if (p === 'ipns/publish') return '/api/v0/name/publish';
if (p === 'ipns/keys') return '/api/v0/key/list';
if (p.startsWith('ipns/resolve/')) {
const name = p.slice('ipns/resolve/'.length);
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
}
if (p.startsWith('ipns/keys/gen/')) {
const name = p.slice('ipns/keys/gen/'.length);
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
}
// Dashboard routes
if (p === 'repo/stats') return '/api/v0/repo/stat';
if (p === 'bw/stats') return '/api/v0/bw';
return '/api/v0/' + p;
}
}