import type { HttpContext } from '@adonisjs/core/http' import SeedOrchestrator, { LeaseLost } from '#services/SeedOrchestrator' import SeedFetchPool from '#services/SeedFetchPool' import RemoteContentService, { JobLost } from '#services/RemoteContentService' 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 } }) } /** * POST /api/v1/seed/inspect/lease { max } → 200 { jobs } or 204: content * inspection and archive walks for the node (CONCEPT.md D41). */ async inspectLease({ seedNode, request, response }: HttpContext) { const max = Math.min(10, Math.max(1, Number(request.input('max')) || 1)) const jobs = await RemoteContentService.lease(seedNode.id, max) return jobs.length > 0 ? { jobs } : response.noContent() } /** * POST /api/v1/seed/inspect/done { queue, jobId, token, result } where * result is the probe as a JSON string (the body parser would turn the * empty strings inside it into nulls). */ async inspectDone({ request, response }: HttpContext) { const { queue, jobId, token, result } = request.only(['queue', 'jobId', 'token', 'result']) if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string' || typeof result !== 'string') { return response.badRequest({ error: 'Expected { queue, jobId, token, result: JSON string }' }) } return this.job(response, () => RemoteContentService.finish(queue, jobId, token, JSON.parse(result))) } /** POST /api/v1/seed/inspect/failed { queue, jobId, token, error } */ async inspectFailed({ request, response }: HttpContext) { const { queue, jobId, token, error } = request.only(['queue', 'jobId', 'token', 'error']) if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') { return response.badRequest({ error: 'Expected { queue, jobId, token, error }' }) } return this.job(response, () => RemoteContentService.fail(queue, jobId, token, String(error ?? 'unknown error'))) } /** POST /api/v1/seed/inspect/release { queue, jobId, token }: a stopping node gives a job back. */ async inspectRelease({ request, response }: HttpContext) { const { queue, jobId, token } = request.only(['queue', 'jobId', 'token']) if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') { return response.badRequest({ error: 'Expected { queue, jobId, token }' }) } return this.job(response, () => RemoteContentService.release(queue, jobId, token)) } /** GET /api/v1/seed/articles?id= → the raw article body; 410 when the server doesn't have it. */ async article({ request, response }: HttpContext) { const id = request.input('id') if (typeof id !== 'string' || id.length === 0 || id.length > 250) return response.badRequest({ error: 'Expected ?id=' }) try { const body = await RemoteContentService.article(id) if (!body) return response.gone({ error: 'No such article' }) response.header('content-type', 'application/octet-stream') return response.send(body) } catch (error: any) { return response.badGateway({ error: `NNTP: ${error.message}` }) } } /** GET /api/v1/seed/status */ async status() { return SeedOrchestrator.status() } /** 409 when the job's lock is gone (it was given to another node meanwhile). */ private async job(response: HttpContext['response'], action: () => Promise) { try { await action() return { ok: true } } catch (error) { if (error instanceof JobLost) return response.conflict({ error: error.message }) throw error } } /** 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 } } }