'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(); const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024; 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); } 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; if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime }); return transfers.get(owner.id) || 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 takePhotos(req, refs) { const current = row(req); 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; const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')])); clearPhotos(current); current.active = true; return photos; } function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } } function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } } function start(req, run, releasePhotos = false) { sweep(); const owner = session(req); if (!owner || jobs.size >= 1000) return null; const id = crypto.randomBytes(24).toString('hex'); const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null }; jobs.set(id, job); 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, owner.id); }); return id; } function result(req, id) { sweep(); const job = jobs.get(id), owner = session(req); return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null; } const timer = setInterval(sweep, 60000); timer.unref(); return { challenge, consume, upload, takePhotos, finish, revoke, start, result }; };