feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
+16
-1
@@ -33,6 +33,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
|
||||
const secure = !!(base && base.protocol === 'https:');
|
||||
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
|
||||
const taobaoGuests = new Map(); // Separate, temporary Bearer sessions; never written to db.
|
||||
const appId = env.WECHAT_APP_ID || '';
|
||||
const secret = env.WECHAT_APP_SECRET || '';
|
||||
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
|
||||
@@ -226,13 +227,27 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
save();
|
||||
return { token, expires_in: 30 * 86400, user: publicUser(user) };
|
||||
}
|
||||
function loginTaobaoGuest() {
|
||||
for (const [key, value] of taobaoGuests) if (value.expires <= Date.now()) taobaoGuests.delete(key);
|
||||
if (taobaoGuests.size >= 1000) return null;
|
||||
const token = random(), user = { id: crypto.randomUUID(), kind: 'taobao_guest' };
|
||||
taobaoGuests.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
|
||||
return { token, expires_in: 8 * 3600, user: publicUser(user) };
|
||||
}
|
||||
function taobaoSession(req) {
|
||||
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
|
||||
const key = match && hash(match[1]), guest = key && taobaoGuests.get(key);
|
||||
if (guest) {
|
||||
if (guest.expires > Date.now()) return guest.user;
|
||||
taobaoGuests.delete(key);
|
||||
return null;
|
||||
}
|
||||
const row = match && db.sessions[hash(match[1])];
|
||||
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
|
||||
}
|
||||
function taobaoLogout(req) {
|
||||
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
|
||||
if (match && taobaoGuests.delete(hash(match[1]))) return;
|
||||
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
|
||||
}
|
||||
function reports(owner) {
|
||||
@@ -247,5 +262,5 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
return snapshot;
|
||||
}
|
||||
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
|
||||
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
|
||||
loginTaobao, loginTaobaoGuest, taobaoSession, taobaoLogout, reports, ownedReport };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
// Administrative reads only. Detection/authentication continue to write solely
|
||||
// to the process's own DATA_ROOT. Source paths never come from an HTTP request.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = function ({ localRoot, localChannel = 'h5', taobaoRoot, publicRoot }) {
|
||||
const sources = new Map([['local', { id: 'local', root: path.resolve(localRoot), channel: localChannel, readonly: false }]]);
|
||||
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
|
||||
if (taobaoRoot) {
|
||||
if (!path.isAbsolute(taobaoRoot)) throw Error('JUZI_ADMIN_TAOBAO_DATA_ROOT must be absolute');
|
||||
const root = fs.realpathSync(taobaoRoot);
|
||||
const primary = fs.realpathSync(localRoot);
|
||||
if (inside(root, primary) || inside(primary, root)) throw Error('Administrative data sources must be separate directories');
|
||||
if (publicRoot && inside(root, fs.realpathSync(publicRoot))) throw Error('Administrative external data must be outside the public application directory');
|
||||
sources.set('taobao', { id: 'taobao', root, channel: 'taobao', readonly: true });
|
||||
}
|
||||
function source(id = 'local') { return sources.get(id) || null; }
|
||||
function records(id) {
|
||||
const selected = id ? [source(id)].filter(Boolean) : [...sources.values()];
|
||||
return selected.flatMap(s => {
|
||||
let text;
|
||||
const target = file(s.id, 'records.jsonl');
|
||||
if (!target) return [];
|
||||
try { text = fs.readFileSync(target, 'utf8'); }
|
||||
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
||||
return text.split('\n').flatMap(line => {
|
||||
let rec; try { rec = JSON.parse(line); } catch (_) { return []; }
|
||||
if (!rec || typeof rec.record_id !== 'string' || !/^[\w-]+$/.test(rec.record_id)) return [];
|
||||
return [{ ...rec, channel: rec.channel || s.channel, admin_source: s.id,
|
||||
admin_key: s.id + ':' + rec.record_id, admin_readonly: s.readonly }];
|
||||
});
|
||||
});
|
||||
}
|
||||
function find(id, sourceId = 'local') { return records(sourceId).find(r => r.record_id === id) || null; }
|
||||
function file(sourceId, ...parts) {
|
||||
const s = source(sourceId);
|
||||
if (!s || parts.some(p => typeof p !== 'string' || !p || p !== path.basename(p) || p === '.' || p === '..')) return null;
|
||||
const target = path.resolve(s.root, ...parts);
|
||||
if (!inside(target, s.root)) return null;
|
||||
try { return inside(fs.realpathSync(target), fs.realpathSync(s.root)) ? target : null; }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
||||
}
|
||||
function presentation(rec) {
|
||||
const target = file(rec.admin_source, 'accounts', 'reports', rec.owner_id, rec.record_id + '.json');
|
||||
if (!target) return null;
|
||||
try { return JSON.parse(fs.readFileSync(target, 'utf8')).presentation || null; }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function funnel(now, channel, localFunnel) {
|
||||
const stats = [...sources.values()].filter(s => !channel || s.channel === channel).map(s => {
|
||||
if (s.id === 'local') return localFunnel.stats(now);
|
||||
return require('./capture-funnel')(path.join(s.root, 'capture-funnel.jsonl')).stats(now);
|
||||
});
|
||||
function sum(scope) {
|
||||
const rows = stats.map(st => st[scope]);
|
||||
const counts = {};
|
||||
for (const st of rows) for (const [stage, n] of Object.entries(st.counts)) counts[stage] = (counts[stage] || 0) + n;
|
||||
const reports = counts.report || 0;
|
||||
return { counts, rate: counts.started ? Math.round(reports / counts.started * 1000) / 10 : 0,
|
||||
response_without_report: rows.reduce((n, r) => n + r.response_without_report, 0),
|
||||
unfinished: rows.reduce((n, r) => n + r.unfinished, 0),
|
||||
avg_ms: reports ? Math.round(rows.reduce((n, r) => n + (r.avg_ms || 0) * r.counts.report, 0) / reports) : null };
|
||||
}
|
||||
// Keep the event schema even when a selected channel has no telemetry yet.
|
||||
if (!stats.length) return null;
|
||||
const starts = stats.map(st => st.since).filter(Number.isFinite);
|
||||
return { since: starts.length ? Math.min(...starts) : null, total: sum('total'), today: sum('today') };
|
||||
}
|
||||
return { source, records, find, file, presentation, funnel,
|
||||
descriptors: () => [...sources.values()].map(({ id, channel, readonly }) => ({ id, channel, readonly })) };
|
||||
};
|
||||
+12
-5
@@ -18,19 +18,26 @@ function signature(params, body, secret) {
|
||||
}
|
||||
return crypto.createHash('md5').update(secret + content + secret).digest('hex').toUpperCase();
|
||||
}
|
||||
function verify(url, body, env = process.env) {
|
||||
// Guest requests still require the real signed app gateway, without userInfo
|
||||
// authorization or an open_id. Body fields never establish an identity.
|
||||
function verifyGateway(url, body, env = process.env) {
|
||||
if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null;
|
||||
const params = Object.create(null);
|
||||
for (const [key, value] of new URL(url, 'http://localhost').searchParams) {
|
||||
if (key in params) return null; // Never ambiguously verify duplicate parameters.
|
||||
params[key] = value;
|
||||
}
|
||||
if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') ||
|
||||
if (params.app_key !== env.TAOBAO_APP_KEY ||
|
||||
!/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null;
|
||||
const expected = signature(params, body, env.TAOBAO_APP_SECRET);
|
||||
if (!expected || expected.length !== params.sign.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null;
|
||||
// Login challenge is single-use and signed inside the body, preventing replay.
|
||||
return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') };
|
||||
return { appKey: params.app_key, openId: params.open_id };
|
||||
}
|
||||
module.exports = { signature, verify };
|
||||
function verify(url, body, env = process.env) {
|
||||
const gateway = verifyGateway(url, body, env);
|
||||
if (!gateway || !/^[A-Za-z0-9_-]{4,256}$/.test(gateway.openId || '')) return null;
|
||||
// Login challenge is single-use and signed inside the body, preventing replay.
|
||||
return { providerKey: crypto.createHash('sha256').update(gateway.appKey + ':' + gateway.openId).digest('hex') };
|
||||
}
|
||||
module.exports = { signature, verify, verifyGateway };
|
||||
|
||||
+26
-9
@@ -11,6 +11,7 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
|
||||
try {
|
||||
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
|
||||
const body = JSON.parse(raw);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { ok: false, message: '请求数据异常' });
|
||||
if (pathname === '/api/taobao/challenge') {
|
||||
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
|
||||
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
|
||||
@@ -21,29 +22,45 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
|
||||
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
|
||||
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
|
||||
}
|
||||
if (pathname === '/api/taobao/guest-session') {
|
||||
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
|
||||
if (!TaobaoAuth.verifyGateway(req.url, raw) || !taobaoSessions.consume(body.challenge)) {
|
||||
return reply(401, { ok: false, message: '游客会话校验失败,请重新开始' });
|
||||
}
|
||||
const guest = accounts.loginTaobaoGuest();
|
||||
return reply(guest ? 200 : 429, guest ? { ok: true, ...guest } : { ok: false, message: '操作频繁,请稍后再试' });
|
||||
}
|
||||
const owner = accounts.taobaoSession(req);
|
||||
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' });
|
||||
if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } });
|
||||
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新开始' });
|
||||
if (pathname === '/api/taobao/me') {
|
||||
// A cached Bearer must not restore the previous Taobao account after
|
||||
// the host app switches accounts. Guests require a signed app gateway
|
||||
// but intentionally have no dependency on userInfo/open_id authorization.
|
||||
const identity = owner.kind === 'taobao_guest' ? null : TaobaoAuth.verify(req.url, raw);
|
||||
const matches = owner.kind === 'taobao_guest' ? !!TaobaoAuth.verifyGateway(req.url, raw)
|
||||
: !!identity && identity.providerKey === owner.provider_key;
|
||||
return reply(matches ? 200 : 401, matches ? { ok: true, user: { id: owner.id, kind: owner.kind } }
|
||||
: { ok: false, message: '淘宝账号状态已变化,请重新进入' });
|
||||
}
|
||||
if (pathname === '/api/taobao/photo') {
|
||||
const { status, ...data } = taobaoSessions.upload(req, body);
|
||||
return reply(status, { ok: status === 200, ...data });
|
||||
}
|
||||
if (pathname === '/api/taobao/skin-analyze') {
|
||||
const photos = taobaoSessions.takePhotos(req, body.photo_refs);
|
||||
if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' });
|
||||
// Start once and poll: cloud gateway requests have a maximum 10s timeout.
|
||||
const job = taobaoSessions.start(req, () => new Promise(resolve => {
|
||||
const attempt = taobaoSessions.beginAnalysis(req, body, photos => new Promise(resolve => {
|
||||
const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } };
|
||||
handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' }));
|
||||
}), true);
|
||||
if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); }
|
||||
return reply(202, { ok: true, job });
|
||||
}));
|
||||
return reply(attempt.status, { ok: attempt.status === 202,
|
||||
...(attempt.job ? { job: attempt.job } : { message: attempt.message }) });
|
||||
}
|
||||
if (pathname === '/api/taobao/analysis') {
|
||||
const result = taobaoSessions.result(req, body.job);
|
||||
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
|
||||
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: owner.kind === 'taobao_guest' ? '本次检测任务已失效,请重新开始' : '检测任务已失效,请查看历史报告或重新检测' });
|
||||
}
|
||||
if (pathname === '/api/taobao/reports') {
|
||||
if (owner.kind === 'taobao_guest') return reply(body.id ? 404 : 200, body.id ? { ok: false, message: '游客模式不保存历史报告' } : { ok: true, total: 0, items: [] });
|
||||
if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) });
|
||||
const report = accounts.ownedReport(owner.id, body.id);
|
||||
return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' });
|
||||
|
||||
+73
-13
@@ -3,8 +3,11 @@ 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 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;
|
||||
@@ -15,6 +18,12 @@ module.exports = function ({ session, now = Date.now } = {}) {
|
||||
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();
|
||||
@@ -35,8 +44,9 @@ module.exports = function ({ session, now = Date.now } = {}) {
|
||||
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;
|
||||
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);
|
||||
@@ -64,35 +74,85 @@ module.exports = function ({ session, now = Date.now } = {}) {
|
||||
current.expires = now() + lifetime;
|
||||
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
|
||||
}
|
||||
function takePhotos(req, refs) {
|
||||
const current = row(req);
|
||||
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;
|
||||
const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
|
||||
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, 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) {
|
||||
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 job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null };
|
||||
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, owner.id); });
|
||||
.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 ? { status: job.status, payload: job.result } : null;
|
||||
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, result };
|
||||
return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user