'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 })) }; };