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