'use strict'; const crypto = require('node:crypto'); // Short-lived transfers/jobs/challenges. Identity/history live in accounts.js. module.exports = function ({ session, now = Date.now } = {}) { const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(), submissions = new Map(); const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024; // Keep only small idempotency tombstones after result expiry. Never silently // charge again when a client retries an old request_id in the same session. const submissionLifetime = 30 * 86400000; let bytes = 0; function clearPhotos(row) { for (const photo of Object.values(row.photos)) bytes -= photo.bytes; row.photos = {}; } function sweep() { for (const [id, row] of transfers) if (row.expires <= now() && !row.active) { clearPhotos(row); transfers.delete(id); } for (const [id, until] of challenges) if (until <= now()) challenges.delete(id); for (const [id, row] of rates) if (row.until <= now()) rates.delete(id); for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id); for (const [id, value] of submissions) if (value.expires <= now()) submissions.delete(id); } function scope(req, owner) { const authorization = req.headers && req.headers.authorization; return typeof authorization === 'string' && authorization ? crypto.createHash('sha256').update(authorization).digest('hex') : owner.id; } function challenge(address) { sweep(); const rate = rates.get(address) || { count: 0, until: now() + 60000 }; if (rates.size >= 10000 && !rates.has(address)) return null; rates.set(address, rate); if (++rate.count > 30 || challenges.size >= 1000) return null; const id = crypto.randomBytes(24).toString('hex'); challenges.set(id, now() + 60000); return id; } function consume(id) { sweep(); if (!challenges.has(id)) return false; challenges.delete(id); return true; } function row(req, create = false) { sweep(); const owner = session(req); if (!owner) return null; const key = scope(req, owner); if (!transfers.has(key) && create && transfers.size < 1000) transfers.set(key, { photos: {}, active: false, expires: now() + lifetime }); return transfers.get(key) || null; } function upload(req, body) { const current = row(req, true); if (!current) return { status: 401, message: '检测会话已失效,请重新开始' }; if (current.active) return { status: 409, message: '检测进行中,请稍候' }; const { view, upload_id: id, index, total, data } = body || {}; if (!['front', 'left', 'right'].includes(view) || !/^[a-z0-9-]{8,64}$/.test(id || '') || !Number.isInteger(total) || total < 1 || total > 64 || !Number.isInteger(index) || index < 0 || index >= total || typeof data !== 'string' || !data.length || data.length > maxChunk || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) { return { status: 400, message: '照片分片数据异常,请重新拍摄' }; } let photo = current.photos[view]; if (!photo || photo.id !== id) { if (index !== 0) return { status: 409, message: '照片上传已失效,请重试' }; if (photo) bytes -= photo.bytes; photo = current.photos[view] = { id, total, chunks: [], bytes: 0 }; } if (photo.total !== total || index > photo.chunks.length || (index < photo.chunks.length && photo.chunks[index] !== data)) { return { status: 409, message: '照片上传顺序异常,请重试' }; } if (index === photo.chunks.length) { if (photo.bytes + data.length > maxPhoto || bytes + data.length > 64 * 1024 * 1024) return { status: 413, message: '照片过大或服务繁忙,请稍后重试' }; photo.chunks.push(data); photo.bytes += data.length; bytes += data.length; } current.expires = now() + lifetime; return { status: 200, complete: photo.chunks.length === total, photo_ref: id }; } function availablePhotos(current, refs) { if (!current || current.active) return null; if (!refs || !['front', 'left', 'right'].every(view => { const photo = current.photos[view]; return photo && photo.id === refs[view] && photo.chunks.length === photo.total; })) return null; return Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')])); } function takePhotos(req, refs) { const current = row(req), photos = availablePhotos(current, refs); if (!photos) return null; clearPhotos(current); current.active = true; return photos; } function finish(req, key) { const current = key ? transfers.get(key) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } } function revoke(req) { const owner = session(req); if (!owner) return; const key = scope(req, owner), current = transfers.get(key); if (current) { clearPhotos(current); transfers.delete(key); } for (const [id, job] of jobs) if (job.scope === key) { job.result = null; jobs.delete(id); } for (const [id, value] of submissions) if (value.scope === key) submissions.delete(id); } function stable(value) { if (Array.isArray(value)) return value.map(stable); return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value; } function submission(req, requestId, body) { sweep(); const owner = session(req); if (!owner) return { status: 401, message: '检测会话已失效,请重新开始' }; if (requestId === undefined) return { status: 200 }; // Older clients remain supported. if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(requestId)) { return { status: 400, message: '检测请求标识异常,请重新开始' }; } const key = scope(req, owner) + ':' + requestId; const fingerprint = crypto.createHash('sha256').update(JSON.stringify(stable({ photo_refs: body.photo_refs, profile: body.profile }))).digest('hex'); const previous = submissions.get(key); if (previous) { if (previous.fingerprint !== fingerprint) return { status: 409, message: '检测请求已用于其他照片或档案,请重新开始' }; if (!jobs.has(previous.job)) return { status: 410, message: '本次检测任务已失效,请重新开始新的检测' }; return { status: 202, job: previous.job }; } if (submissions.size >= 10000) return { status: 429, message: '服务繁忙,请稍后再试' }; return { status: 200, key, fingerprint }; } function start(req, run, releasePhotos = false, attempt) { sweep(); const owner = session(req); if (!owner || jobs.size >= 1000) return null; const id = crypto.randomBytes(24).toString('hex'); const key = scope(req, owner); const job = { owner: owner.id, scope: key, status: 'running', expires: now() + lifetime, result: null }; jobs.set(id, job); if (attempt && attempt.key) submissions.set(attempt.key, { job: id, scope: key, fingerprint: attempt.fingerprint, expires: now() + submissionLifetime }); Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; }) .finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, key); }); return id; } function beginAnalysis(req, body, run) { const attempt = submission(req, body.request_id, body); if (attempt.status !== 200) return attempt; const current = row(req), photos = availablePhotos(current, body.photo_refs); if (!photos) return { status: 409, message: '照片上传未完成或检测进行中,请重试' }; // Reserve the job and its idempotency key BEFORE consuming uploads. There // are no awaits here; the paid callback is queued for the next microtask. // A full job queue leaves the complete uploads available for a later retry. const job = start(req, () => run(photos), true, attempt); if (!job) return { status: 429, message: '服务繁忙,请稍后再试' }; clearPhotos(current); current.active = true; return { status: 202, job }; } function result(req, id) { sweep(); const job = jobs.get(id), owner = session(req); return job && owner && job.owner === owner.id && job.scope === scope(req, owner) ? { status: job.status, payload: job.result } : null; } const timer = setInterval(sweep, 60000); timer.unref(); return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result }; };