diff --git a/__tests__/api/validators/milestone/route.test.ts b/__tests__/api/validators/milestone/route.test.ts new file mode 100644 index 00000000..5286a4f4 --- /dev/null +++ b/__tests__/api/validators/milestone/route.test.ts @@ -0,0 +1,343 @@ +/** @jest-environment node */ +import { NextRequest } from 'next/server'; +import axios from 'axios'; +import { POST } from '@/app/api/validators/milestone/route'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +// Also mock isAxiosError used in evidencePinner +(axios.isAxiosError as jest.Mock) = jest.fn((err) => { + return err && err.__isAxiosError === true; +}); + +const SESSION = 'validator-wallet-TEST'; + +function makeRequest( + body: unknown, + session?: string, +): NextRequest { + const headers: Record = { + 'content-type': 'application/json', + }; + if (session) headers['cookie'] = `session=${session}`; + return new NextRequest('http://localhost/api/validators/milestone', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +function makeAxiosDownloadResponse(contentType: string, body: Buffer | Uint8Array = Buffer.from('data')) { + return { + data: body.buffer ?? body, + headers: { 'content-type': contentType }, + status: 200, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + process.env.PINATA_API_KEY = 'test-pinata-key'; + process.env.PINATA_SECRET = 'test-pinata-secret'; + delete process.env.EVIDENCE_MAX_BYTES; +}); + +afterEach(() => { + delete process.env.PINATA_API_KEY; + delete process.env.PINATA_SECRET; + delete process.env.EVIDENCE_MAX_BYTES; +}); + +describe('POST /api/validators/milestone', () => { + describe('auth', () => { + it('returns 401 without a session cookie', async () => { + const res = await POST( + makeRequest({ playerId: 'p1', validatorId: 'v1', description: 'd', evidence_uri: 'QmFakeCid' }), + ); + expect(res.status).toBe(401); + }); + }); + + describe('input validation', () => { + const validBody = { + playerId: 'player-001', + validatorId: 'validator-001', + description: 'Scored 5 goals in Local Cup', + evidence_uri: 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }; + + it.each([ + ['playerId', { ...validBody, playerId: '' }], + ['validatorId', { ...validBody, validatorId: '' }], + ['description', { ...validBody, description: '' }], + ['evidence_uri', { ...validBody, evidence_uri: '' }], + ])('returns 400 when %s is missing or empty', async (field, body) => { + const res = await POST(makeRequest(body, SESSION)); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(new RegExp(field, 'i')); + }); + + it('returns 400 when evidence_uri is not a CID or HTTPS URL', async () => { + const res = await POST(makeRequest({ ...validBody, evidence_uri: 'ftp://invalid' }, SESSION)); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/IPFS CID or an HTTPS URL/i); + }); + }); + + describe('CID passthrough', () => { + it('stores a CIDv0 as-is without calling Pinata', async () => { + const cid = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; + const res = await POST( + makeRequest( + { playerId: 'p1', validatorId: 'v1', description: 'Goal', evidence_uri: cid }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe(cid); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('stores a CIDv1 as-is without calling Pinata', async () => { + const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'; + const res = await POST( + makeRequest( + { playerId: 'p1', validatorId: 'v1', description: 'Sprint', evidence_uri: cid }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe(cid); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + }); + + describe('HTTPS URL pinning', () => { + it('pins a valid video URL and returns the CID', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('video/mp4', Buffer.alloc(1024)), + ); + mockedAxios.post.mockResolvedValue({ data: { IpfsHash: 'QmPinnedVideoCid' } }); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Match recording', + evidence_uri: 'https://example.com/video.mp4', + }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe('QmPinnedVideoCid'); + }); + + it('pins a valid image URL and returns the CID', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('image/jpeg', Buffer.alloc(512)), + ); + mockedAxios.post.mockResolvedValue({ data: { IpfsHash: 'QmPinnedImageCid' } }); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Action photo', + evidence_uri: 'https://example.com/photo.jpg', + }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe('QmPinnedImageCid'); + }); + + it('pins application/pdf and returns the CID', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('application/pdf', Buffer.alloc(256)), + ); + mockedAxios.post.mockResolvedValue({ data: { IpfsHash: 'QmPinnedPdfCid' } }); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Certificate', + evidence_uri: 'https://example.com/cert.pdf', + }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe('QmPinnedPdfCid'); + }); + + it('pins text/plain and returns the CID', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('text/plain; charset=utf-8', Buffer.from('stats')), + ); + mockedAxios.post.mockResolvedValue({ data: { IpfsHash: 'QmPinnedTextCid' } }); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Stats note', + evidence_uri: 'https://example.com/stats.txt', + }, + SESSION, + ), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.milestone.evidence_uri).toBe('QmPinnedTextCid'); + }); + + it('rejects application/x-executable with 422', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('application/x-executable', Buffer.alloc(100)), + ); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Bad evidence', + evidence_uri: 'https://example.com/malware.exe', + }, + SESSION, + ), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/unsupported content type/i); + }); + + it('rejects application/zip with 422', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('application/zip', Buffer.alloc(100)), + ); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Zip file', + evidence_uri: 'https://example.com/archive.zip', + }, + SESSION, + ), + ); + expect(res.status).toBe(422); + }); + + it('rejects files exceeding EVIDENCE_MAX_BYTES with 422', async () => { + process.env.EVIDENCE_MAX_BYTES = String(1024); // 1 KB limit for this test + // Simulate axios throwing a size-exceeded error + const axiosErr = Object.assign(new Error('Content length exceeded'), { + __isAxiosError: true, + code: 'ERR_CONTENT_LENGTH_EXCEEDED', + response: undefined, + }); + mockedAxios.get.mockRejectedValue(axiosErr); + (axios.isAxiosError as jest.Mock).mockReturnValueOnce(true); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Huge file', + evidence_uri: 'https://example.com/huge.mp4', + }, + SESSION, + ), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/too large/i); + }); + + it('returns 502 when Pinata upload fails', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('video/mp4', Buffer.alloc(100)), + ); + mockedAxios.post.mockRejectedValue(new Error('Pinata unavailable')); + + const res = await POST( + makeRequest( + { + playerId: 'p1', + validatorId: 'v1', + description: 'Video', + evidence_uri: 'https://example.com/clip.mp4', + }, + SESSION, + ), + ); + expect(res.status).toBe(502); + }); + }); + + describe('stored record', () => { + it('always stores evidence_uri as a CID after pinning', async () => { + mockedAxios.get.mockResolvedValue( + makeAxiosDownloadResponse('image/png', Buffer.alloc(64)), + ); + mockedAxios.post.mockResolvedValue({ data: { IpfsHash: 'QmStoredCid' } }); + + const res = await POST( + makeRequest( + { + playerId: 'player-xyz', + validatorId: 'val-abc', + description: 'Photo evidence', + evidence_uri: 'https://example.com/img.png', + }, + SESSION, + ), + ); + const body = await res.json(); + // evidence_uri must be a CID, not the original URL + expect(body.milestone.evidence_uri).toBe('QmStoredCid'); + expect(body.milestone.evidence_uri).not.toContain('https://'); + }); + + it('returns all expected milestone fields', async () => { + const cid = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; + const res = await POST( + makeRequest( + { + playerId: 'player-A', + validatorId: 'validator-B', + description: 'Scored a hat-trick', + evidence_uri: cid, + }, + SESSION, + ), + ); + const body = await res.json(); + const ms = body.milestone; + expect(typeof ms.id).toBe('string'); + expect(ms.playerId).toBe('player-A'); + expect(ms.validatorId).toBe('validator-B'); + expect(ms.description).toBe('Scored a hat-trick'); + expect(ms.evidence_uri).toBe(cid); + expect(typeof ms.createdAt).toBe('number'); + }); + }); +}); diff --git a/app/api/validators/milestone/route.ts b/app/api/validators/milestone/route.ts new file mode 100644 index 00000000..ece77b38 --- /dev/null +++ b/app/api/validators/milestone/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createRequestLogger } from '@/lib/logger'; +import { + isIpfsCid, + downloadAndPin, + isAcceptedContentType, + EvidenceContentTypeError, + EvidenceSizeError, + EvidencePinError, + ACCEPTED_CONTENT_TYPES, +} from '@/lib/evidencePinner'; +import { sanitize } from '@/lib/sanitize'; + +export const runtime = 'nodejs'; + +/** + * POST /api/validators/milestone + * + * Accepts a validator's milestone submission. The `evidence_uri` field is + * normalised to a CID before storage: + * + * - If it's already an IPFS CID (v0/v1) it is stored as-is. + * - If it's an HTTPS URL, the content is fetched, its Content-Type and size + * are validated, then it is pinned to IPFS via Pinata. The returned CID + * replaces the original URL in the stored record. + * + * Content-Type rules (HTTP 422 if violated): + * - video/* + * - image/* + * - application/pdf + * - text/plain + * + * Size limit: EVIDENCE_MAX_BYTES env var (default 50 MB). Returns 422 when + * exceeded. + * + * Request body: + * { + * playerId: string — required + * validatorId: string — required + * description: string — required + * evidence_uri: string — required; HTTPS URL or IPFS CID + * } + * + * Response on success (201): + * { + * milestone: { + * id: string + * playerId: string + * validatorId: string + * description: string + * evidence_uri: string — always a CID + * createdAt: number — Unix seconds + * } + * } + */ +export async function POST(req: NextRequest) { + const log = createRequestLogger(req); + + // Auth: requires a validator session cookie. + const sessionCookie = req.cookies.get('session')?.value; + if (!sessionCookie) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse body + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { + playerId, + validatorId, + description, + evidence_uri: rawEvidence, + } = (body ?? {}) as Record; + + // Field validation + if (typeof playerId !== 'string' || playerId.trim() === '') { + return NextResponse.json({ error: 'playerId is required' }, { status: 400 }); + } + if (typeof validatorId !== 'string' || validatorId.trim() === '') { + return NextResponse.json({ error: 'validatorId is required' }, { status: 400 }); + } + if (typeof description !== 'string' || description.trim() === '') { + return NextResponse.json({ error: 'description is required' }, { status: 400 }); + } + if (typeof rawEvidence !== 'string' || rawEvidence.trim() === '') { + return NextResponse.json({ error: 'evidence_uri is required' }, { status: 400 }); + } + + const evidenceInput = rawEvidence.trim(); + + // Resolve evidence_uri to a CID + let evidenceCid: string; + + if (isIpfsCid(evidenceInput)) { + // Already a CID — use as-is + evidenceCid = evidenceInput; + } else if (evidenceInput.startsWith('https://') || evidenceInput.startsWith('http://')) { + // HTTPS URL — download, validate, pin + try { + evidenceCid = await downloadAndPin(evidenceInput); + } catch (err) { + if (err instanceof EvidenceContentTypeError) { + log.warn('Rejected evidence: unsupported content type', { + url: evidenceInput, + contentType: err.contentType, + }); + return NextResponse.json( + { + error: `Unsupported content type "${err.contentType}". Accepted types: ${ACCEPTED_CONTENT_TYPES.join(', ')}`, + }, + { status: 422 }, + ); + } + if (err instanceof EvidenceSizeError) { + log.warn('Rejected evidence: file too large', { + url: evidenceInput, + bytes: err.bytes, + maxBytes: err.maxBytes, + }); + return NextResponse.json({ error: err.message }, { status: 422 }); + } + if (err instanceof EvidencePinError) { + log.error('Evidence pin failed', { + url: evidenceInput, + reason: err.message, + }); + return NextResponse.json({ error: 'Failed to pin evidence to IPFS' }, { status: 502 }); + } + throw err; + } + } else { + return NextResponse.json( + { error: 'evidence_uri must be an IPFS CID or an HTTPS URL' }, + { status: 400 }, + ); + } + + // Build the stored milestone record (sanitize free-text field). + const milestone = { + id: `ms-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + playerId: playerId.trim(), + validatorId: validatorId.trim(), + description: sanitize(description.trim()), + evidence_uri: evidenceCid, + createdAt: Math.floor(Date.now() / 1000), + }; + + log.info('Milestone submitted', { + milestoneId: milestone.id, + playerId: milestone.playerId, + validatorId: milestone.validatorId, + evidencePinned: rawEvidence !== evidenceCid, + }); + + return NextResponse.json({ milestone }, { status: 201 }); +} diff --git a/lib/evidencePinner.ts b/lib/evidencePinner.ts new file mode 100644 index 00000000..325569d6 --- /dev/null +++ b/lib/evidencePinner.ts @@ -0,0 +1,160 @@ +import axios from 'axios'; + +/** + * Accepted MIME type prefixes for milestone evidence. + * Anything that doesn't start with one of these is rejected with HTTP 422. + */ +export const ACCEPTED_CONTENT_TYPES = [ + 'video/', + 'image/', + 'application/pdf', + 'text/plain', +] as const; + +/** + * Default maximum evidence file size in bytes (50 MB). + * Override with the EVIDENCE_MAX_BYTES env var. + */ +export function getEvidenceMaxBytes(): number { + const raw = process.env.EVIDENCE_MAX_BYTES; + if (raw) { + const parsed = parseInt(raw, 10); + if (!isNaN(parsed) && parsed > 0) return parsed; + } + return 50 * 1024 * 1024; // 50 MB default +} + +/** + * Returns true when the given string looks like an IPFS CID (v0 or v1). + * - CIDv0: Qm… (Base58, 46 characters) + * - CIDv1: bafy… or similar (Base32/Base58btc, ≥32 chars, no protocol prefix) + */ +export function isIpfsCid(value: string): boolean { + // CIDv0: starts with "Qm" followed by 44 base58 characters + if (/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(value)) return true; + // CIDv1: starts with "b" or "z" followed by at least 30 base32/base58 chars + if (/^[bz][a-zA-Z0-9]{30,}$/.test(value)) return true; + return false; +} + +/** + * Returns true if the content type is accepted for milestone evidence. + */ +export function isAcceptedContentType(contentType: string): boolean { + const ct = contentType.toLowerCase().split(';')[0].trim(); + return ACCEPTED_CONTENT_TYPES.some((prefix) => ct.startsWith(prefix)); +} + +/** + * Download an HTTPS URL, validate its Content-Type and size, then pin it to + * IPFS via Pinata's `pinFileToIPFS` endpoint. Returns the resulting CID. + * + * Throws typed errors so callers can map them to the right HTTP status: + * - `EvidenceContentTypeError` → 422 + * - `EvidenceSizeError` → 422 + * - `EvidencePinError` → 502 + */ +export class EvidenceContentTypeError extends Error { + contentType: string; + constructor(contentType: string) { + super(`Unsupported content type: ${contentType}`); + this.name = 'EvidenceContentTypeError'; + this.contentType = contentType; + } +} + +export class EvidenceSizeError extends Error { + bytes: number; + maxBytes: number; + constructor(bytes: number, maxBytes: number) { + super( + `Evidence file is too large: ${(bytes / 1024 / 1024).toFixed(1)} MB (max ${(maxBytes / 1024 / 1024).toFixed(0)} MB)`, + ); + this.name = 'EvidenceSizeError'; + this.bytes = bytes; + this.maxBytes = maxBytes; + } +} + +export class EvidencePinError extends Error { + constructor(message: string) { + super(message); + this.name = 'EvidencePinError'; + } +} + +/** + * Download the content at `url`, validate content type and size, then pin it + * to IPFS via Pinata. Returns the CID string. + */ +export async function downloadAndPin(url: string): Promise { + const maxBytes = getEvidenceMaxBytes(); + + // Stream the response so we can check Content-Type and size before + // buffering the whole file. + let response: Awaited>; + try { + response = await axios.get(url, { + responseType: 'arraybuffer', + maxContentLength: maxBytes, + maxBodyLength: maxBytes, + validateStatus: (s) => s === 200, + }); + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + if (err.code === 'ERR_CONTENT_LENGTH_EXCEEDED' || err.code === 'ERR_BODY_LENGTH_EXCEEDED') { + throw new EvidenceSizeError(maxBytes + 1, maxBytes); + } + const msg = err.response + ? `Remote server returned HTTP ${err.response.status}` + : (err.message ?? 'Failed to download evidence URL'); + throw new EvidencePinError(msg); + } + throw new EvidencePinError( + err instanceof Error ? err.message : 'Failed to download evidence URL', + ); + } + + // Content-Type validation + const rawCt = (response.headers['content-type'] as string | undefined) ?? ''; + if (!isAcceptedContentType(rawCt)) { + throw new EvidenceContentTypeError(rawCt || 'unknown'); + } + + // Size validation (defensive — axios maxContentLength should catch most cases) + const buffer = Buffer.from(response.data as ArrayBuffer); + if (buffer.length > maxBytes) { + throw new EvidenceSizeError(buffer.length, maxBytes); + } + + // Derive a filename from the URL path, falling back to "evidence" + const urlPath = new URL(url).pathname; + const filename = urlPath.split('/').pop() || 'evidence'; + const mimeType = rawCt.split(';')[0].trim(); + + // Pin to Pinata + const pinataForm = new FormData(); + const file = new File([new Uint8Array(buffer)], filename, { type: mimeType }); + pinataForm.append('file', file); + + let cid: string; + try { + const { data } = await axios.post( + 'https://api.pinata.cloud/pinning/pinFileToIPFS', + pinataForm, + { + headers: { + pinata_api_key: process.env.PINATA_API_KEY!, + pinata_secret_api_key: process.env.PINATA_SECRET!, + }, + }, + ); + cid = data.IpfsHash as string; + } catch (err) { + throw new EvidencePinError( + err instanceof Error ? `Pinata upload failed: ${err.message}` : 'Pinata upload failed', + ); + } + + return cid; +}