import type { HttpContext } from '@adonisjs/core/http' import SeedOrchestrator, { LeaseLost } from '#services/SeedOrchestrator' import SeedFetchPool from '#services/SeedFetchPool' import type { SeedBatch } from '#lib/seed/wire' /** * The seed API (CONCEPT.md D38). Nodes make outgoing HTTPS requests only: * lease a range, post batches of what they found, then finish or release * the range. Bodies may be gzip-compressed (Content-Encoding: gzip). */ export default class SeedController { /** POST /api/v1/seed/lease → 200 { range } or 204 when there's nothing to do. */ async lease({ seedNode, response }: HttpContext) { const range = await SeedOrchestrator.lease(seedNode.id) return range ? { range } : response.noContent() } /** POST /api/v1/seed/ranges/:id/batches with a SeedBatch. */ async batch({ seedNode, params, request, response }: HttpContext) { const batch = request.body() as SeedBatch if (!Number.isSafeInteger(batch?.next) || !Array.isArray(batch.files) || !Array.isArray(batch.fragments)) { return response.badRequest({ error: 'Expected { next, headers, files[], fragments[] }' }) } return this.leased(response, async () => { const stored = await SeedOrchestrator.batch(seedNode.id, Number(params.id), { ...batch, headers: Number(batch.headers) || 0, obfuscated: Number(batch.obfuscated) || 0, deferred: Number(batch.deferred) || 0, }) return { stored } }) } /** * GET /api/v1/seed/ranges/:id/chunk?from=&to= → the overviews of those * articles as a zlib/gzip stream (relay nodes fetch through the indexer). */ async chunk({ seedNode, params, request, response }: HttpContext) { if (SeedFetchPool.size === 0) return response.serviceUnavailable({ error: 'Relay is off (SEED_NNTP_CONNECTIONS=0); fetch directly' }) const from = Number(request.input('from')) const to = Number(request.input('to')) if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return response.badRequest({ error: 'Expected ?from=&to=' }) return this.leased(response, async () => { try { const raw = await SeedOrchestrator.chunk(seedNode.id, Number(params.id), from, to) response.header('content-type', 'application/octet-stream') return response.send(raw) } catch (error) { if (error instanceof RangeError) return response.badRequest({ error: error.message }) throw error } }) } /** POST /api/v1/seed/ranges/:id/done */ async done({ seedNode, params, response }: HttpContext) { return this.leased(response, async () => { await SeedOrchestrator.finish(seedNode.id, Number(params.id)) return { ok: true } }) } /** POST /api/v1/seed/ranges/:id/release { error?, uncounted? } */ async release({ seedNode, params, request, response }: HttpContext) { const error = request.input('error') const uncounted = request.input('uncounted') === true return this.leased(response, async () => { await SeedOrchestrator.release(seedNode.id, Number(params.id), typeof error === 'string' ? error : undefined, uncounted) return { ok: true } }) } /** GET /api/v1/seed/status */ async status() { return SeedOrchestrator.status() } /** 409 when the range was given to another node meanwhile; the node should drop it. */ private async leased(response: HttpContext['response'], action: () => Promise) { try { return await action() } catch (error) { if (error instanceof LeaseLost) return response.conflict({ error: error.message }) throw error } } }