fix: /ipfs/<cid> served alles als application/octet-stream

- detectContentType(cid) ignoreerde zijn input en gaf altijd octet-stream terug,
  terwijl de commentaar claimde dat Kubo het echte type bepaalt
- De response draagt nu het Content-Type dat Kubo cat terugstuurt
- resolveContentType() valt terug op application/octet-stream bij ontbrekend,
  leeg of ongeldig type; het patroon weigert CR/LF en quotes zodat upstream-
  headers niet in de response gespiegeld kunnen worden
- Streaming ongewijzigd (body nog volledig in geheugen), headers idem
- Nieuwe route-tests: content-type-passthrough, fallbackgevallen, Kubo-aanroep,
  body, overige headers en de ongewijzigde foutpaden (400/404/501/502)
This commit is contained in:
maikrolf
2026-10-01 19:36:54 +02:00
parent 0279282f7e
commit fa7de62707
2 changed files with 200 additions and 7 deletions
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from '../route';
/* ── GET /ipfs/<cid> ──
*
* Audit: `detectContentType(cid)` gaf onafhankelijk van de input altijd
* `application/octet-stream` terug, terwijl de commentaar claimde dat Kubo het
* echte type bepaalt. Content die als octet-stream geserved wordt, kan de
* browser niet als image/audio/video of tekst tonen.
*
* Deze tests dwingen af dat de response het content-type van de upstream
* (`cat`) doorgeeft, met `application/octet-stream` als fallback.
*/
const CID = 'QmTestCID000';
function makeReq(query = ''): NextRequest {
return new NextRequest(`http://localhost/ipfs/${CID}${query}`);
}
function call(query = '') {
return GET(makeReq(query), { params: Promise.resolve({ cid: CID }) });
}
/** Upstream-stand-in: `cat` levert bytes + een Content-Type. */
function kuboReturning(contentType: string | null, body = 'hallo wereld'): Response {
const headers: Record<string, string> = {};
if (contentType !== null) headers['Content-Type'] = contentType;
// Uint8Array-body zodat Response zelf geen Content-Type zet.
const bytes = new TextEncoder().encode(body);
return new Response(bytes, { status: 200, headers });
}
const kuboFetch = vi.fn(async (_url: string, _init: RequestInit) =>
kuboReturning('text/plain; charset=utf-8'),
);
beforeEach(() => {
vi.clearAllMocks();
process.env.KUBO_API_URL = 'http://kubo.test:5001';
vi.stubGlobal('fetch', kuboFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.KUBO_API_URL;
});
describe('content-type van de gateway', () => {
it('geeft het content-type van de upstream door', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('text/html; charset=utf-8'));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
});
it('geeft text/plain door, inclusief charset', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('text/plain; charset=utf-8'));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
});
it('geef application/json door zonder charset-params', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('application/json'));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('application/json');
});
it('valt terug op application/octet-stream als upstream geen type stuurt', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning(null));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('valt terug op application/octet-stream bij een lege content-type', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning(''));
const res = await call();
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('valt terug op application/octet-stream bij een ongeldige content-type', async () => {
kuboFetch.mockResolvedValueOnce(kuboReturning('geen-media-type'));
const res = await call();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('application/octet-stream');
});
it('raakt Kubo via POST /api/v0/cat met de CID als arg', async () => {
await call();
expect(kuboFetch).toHaveBeenCalledTimes(1);
const [url, init] = kuboFetch.mock.calls[0];
expect(url).toBe(`http://kubo.test:5001/api/v0/cat?arg=${CID}`);
expect(init.method).toBe('POST');
});
it('geeft de body byte-voor-byte door', async () => {
const payload = '{"hallo":"wereld"}';
kuboFetch.mockResolvedValueOnce(kuboReturning('application/json', payload));
const res = await call();
expect(await res.text()).toBe(payload);
});
});
describe('overige gateway-headers', () => {
it('zet Content-Disposition inline zonder filename', async () => {
const res = await call();
expect(res.headers.get('Content-Disposition')).toBe('inline');
});
it('zet Content-Disposition met de gevraagde filename', async () => {
const res = await call('?filename=rap.txt');
expect(res.headers.get('Content-Disposition')).toBe('inline; filename="rap.txt"');
});
it('sluit het response aan op immutable caching', async () => {
const res = await call();
expect(res.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
});
describe('foutpaden blijven ongewijzigd', () => {
it('weigert een ongeldig CID formaat zonder Kubo te raken', async () => {
const res = await GET(new NextRequest('http://localhost/ipfs/niet-een-cid'), {
params: Promise.resolve({ cid: 'niet-een-cid' }),
});
expect(res.status).toBe(400);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('geeft een Kubo-foutstatus door', async () => {
kuboFetch.mockResolvedValueOnce(
new Response('not found', { status: 404, headers: { 'Content-Type': 'text/plain' } }),
);
const res = await call();
expect(res.status).toBe(404);
});
it('geeft 501 zonder KUBO_API_URL', async () => {
delete process.env.KUBO_API_URL;
const res = await call();
expect(res.status).toBe(501);
expect(kuboFetch).not.toHaveBeenCalled();
});
it('geeft 502 wanneer de fetch faalt', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('connect ECONNREFUSED');
}),
);
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await call();
expect(res.status).toBe(502);
});
});
+20 -7
View File
@@ -50,8 +50,9 @@ export async function GET(
);
}
// Bepaal content type op basis van CID prefix
const contentType = detectContentType(cid);
// Content-type van de upstream overnemen: `cat` stuurt het echte media type
// mee. Zonder dat zou de browser tekst/images/audio niet kunnen tonen.
const contentType = resolveContentType(response.headers.get('Content-Type'));
// Check of filename is opgegeven
const filename = req.nextUrl.searchParams.get('filename');
@@ -73,11 +74,23 @@ export async function GET(
}
}
/** Content-type als Kubo `cat` er geen bruikbaar type voor terugstuurt. */
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
/**
* Detecteer content type op basis van CID prefix.
* IPFS content is meestal JSON, tenzij het een bekend formaat is.
* Voldoet aan RFC 9110 §5.6: `type "/" subtype *( ";" parameter )`, met tokens
* uit de tmfh-token-characters. Alles daarbuiten (CR/LF, quotes, spaties)
* wordt geweigerd zodat upstream-headers nooit in onze response worden gespiegeld.
*/
function detectContentType(cid: string): string {
// Voor nu: standaard octet-stream, KUBO bepaalt het echte type
return 'application/octet-stream';
const CONTENT_TYPE_PATTERN =
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+\/[A-Za-z0-9!#$%&'*+.^_`|~-]+(\s*;\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+=(?:[A-Za-z0-9!#$%&'*+.^_`|~-]+|"(?:[A-Za-z0-9!#$%&'*+.^_`|~-]|\s)*"))*$/;
/**
* Neem het content-type van de upstream over, met `application/octet-stream`
* als fallback wanneer upstream niets bruikbaars stuurt.
*/
function resolveContentType(upstream: string | null): string {
const value = upstream?.trim();
if (!value) return DEFAULT_CONTENT_TYPE;
return CONTENT_TYPE_PATTERN.test(value) ? value : DEFAULT_CONTENT_TYPE;
}