@xuda.io/ai_module 1.1.5636 → 1.1.5638
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +1184 -36
- package/index_ms.mjs +68 -0
- package/index_msa.mjs +68 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -31,14 +31,40 @@ const { rm, readFile, unlink, writeFile } = fs.promises;
|
|
|
31
31
|
|
|
32
32
|
const run_process = function (command, args, input, options = {}) {
|
|
33
33
|
return new Promise((resolve, reject) => {
|
|
34
|
-
|
|
34
|
+
// Pull timeout/killSignal out of the spawn options: Node's built-in spawn
|
|
35
|
+
// `timeout` signals ONLY the direct child. The Codex CLI is a thin Node shim
|
|
36
|
+
// that spawns a separate native binary, so Node's timeout kills the shim and
|
|
37
|
+
// leaves the native process orphaned (PPID 1), still holding its OpenAI
|
|
38
|
+
// socket and running forever past the deadline. We instead run the child in
|
|
39
|
+
// its own process group (detached) and kill the whole group ourselves.
|
|
40
|
+
const { onStdout, onStderr, timeout, killSignal = 'SIGKILL', ...spawn_options } = options;
|
|
35
41
|
const child = spawn(command, args, {
|
|
36
42
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
...(timeout ? { detached: true } : {}),
|
|
37
44
|
...spawn_options,
|
|
38
45
|
});
|
|
39
46
|
|
|
40
47
|
let stdout = '';
|
|
41
48
|
let stderr = '';
|
|
49
|
+
let timed_out = false;
|
|
50
|
+
|
|
51
|
+
const kill_tree = (signal) => {
|
|
52
|
+
try {
|
|
53
|
+
// Negative pid signals the entire process group (shim + native binary
|
|
54
|
+
// + any grandchildren) since the child was spawned detached.
|
|
55
|
+
process.kill(-child.pid, signal);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try { child.kill(signal); } catch (e2) {}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const timer = timeout
|
|
62
|
+
? setTimeout(() => {
|
|
63
|
+
timed_out = true;
|
|
64
|
+
stderr += `\nProcess timed out after ${timeout}ms and was terminated.`;
|
|
65
|
+
kill_tree(killSignal);
|
|
66
|
+
}, timeout)
|
|
67
|
+
: null;
|
|
42
68
|
|
|
43
69
|
child.stdout.on('data', (chunk) => {
|
|
44
70
|
const text = chunk.toString();
|
|
@@ -56,9 +82,13 @@ const run_process = function (command, args, input, options = {}) {
|
|
|
56
82
|
}
|
|
57
83
|
});
|
|
58
84
|
|
|
59
|
-
child.on('error',
|
|
85
|
+
child.on('error', (err) => {
|
|
86
|
+
if (timer) clearTimeout(timer);
|
|
87
|
+
reject(err);
|
|
88
|
+
});
|
|
60
89
|
child.on('close', (exit_code) => {
|
|
61
|
-
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
resolve({ exit_code, stdout, stderr, timed_out });
|
|
62
92
|
});
|
|
63
93
|
|
|
64
94
|
if (input) {
|
|
@@ -284,6 +314,7 @@ const jobs_ms = await import(`${module_path}/jobs_module/index_ms.mjs`);
|
|
|
284
314
|
const team_ms = await import(`${module_path}/team_module/index_ms.mjs`);
|
|
285
315
|
const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
|
|
286
316
|
const api_ms = await import(`${module_path}/api_module/index_ms.mjs`);
|
|
317
|
+
const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
|
|
287
318
|
|
|
288
319
|
const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
|
|
289
320
|
const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
|
|
@@ -1656,12 +1687,12 @@ const _sw_gen_acquire = async (uid) => {
|
|
|
1656
1687
|
rl = await db_module.get_memcached_doc(rl_key);
|
|
1657
1688
|
} catch (e) {}
|
|
1658
1689
|
if (!rl || typeof rl !== 'object' || now - (rl.window_start || 0) > 3600000) rl = { window_start: now, count: 0 };
|
|
1659
|
-
if (rl.count >= SW_GEN_RATE_PER_HOUR) return { ok: false, reason: `You've hit the limit of ${SW_GEN_RATE_PER_HOUR} AI generations per hour
|
|
1690
|
+
if (rl.count >= SW_GEN_RATE_PER_HOUR) return { ok: false, reason: `You've hit the limit of ${SW_GEN_RATE_PER_HOUR} AI generations per hour. Try again a little later.` };
|
|
1660
1691
|
let inflight = 0;
|
|
1661
1692
|
try {
|
|
1662
1693
|
inflight = (await db_module.get_memcached_doc(cc_key)) || 0;
|
|
1663
1694
|
} catch (e) {}
|
|
1664
|
-
if (inflight >= SW_GEN_MAX_CONCURRENT) return { ok: false, reason: 'You already have a generation running
|
|
1695
|
+
if (inflight >= SW_GEN_MAX_CONCURRENT) return { ok: false, reason: 'You already have a generation running. Let it finish before starting another.' };
|
|
1665
1696
|
rl.count += 1;
|
|
1666
1697
|
try {
|
|
1667
1698
|
await db_module.set_memcached_doc(rl_key, rl, 3600);
|
|
@@ -1718,7 +1749,32 @@ export const generate_site_draft = async (req, job_id) => {
|
|
|
1718
1749
|
// so we must check it. Runs before _sw_gen_acquire so we don't burn a slot.
|
|
1719
1750
|
const credit_err = await validate_credits_limit(uid);
|
|
1720
1751
|
if (credit_err?.credit_limit_error) {
|
|
1721
|
-
return finalize({ code: -1, data: { error: 'credit_limit', message: 'You’re out of AI credits
|
|
1752
|
+
return finalize({ code: -1, data: { error: 'credit_limit', message: 'You’re out of AI credits. Top up to keep generating.', account_id: credit_err.account_id } });
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Image sourcing mode from the builder UI selector. svg (default) uses inline
|
|
1756
|
+
// SVG/CSS; stock pulls Pexels photos; gen_low/gen_high pre-generate gpt-image
|
|
1757
|
+
// assets. The two generated modes are gated on a paid membership + a workspace
|
|
1758
|
+
// credit plan; account_module is the single source of truth, and the UI uses
|
|
1759
|
+
// the same signal to disable the locked modes and show an "upgrade" prompt.
|
|
1760
|
+
const image_mode = ['svg', 'stock', 'gen_low', 'gen_high'].includes(data.image_mode) ? data.image_mode : 'svg';
|
|
1761
|
+
if (image_mode === 'gen_low' || image_mode === 'gen_high') {
|
|
1762
|
+
// Gate the two gpt-image modes on a paid membership + a non-free workspace
|
|
1763
|
+
// credit plan. Same rule as account_module._site_build_image_modes (the copy
|
|
1764
|
+
// the UI reads via get_site_build_image_modes); computed inline here because
|
|
1765
|
+
// the account_msa queue accessor is fire-and-forget and returns no value.
|
|
1766
|
+
let gen_ok = false;
|
|
1767
|
+
let gate_reason = 'AI image generation needs a paid website plan and a workspace credit plan. Upgrade to unlock it.';
|
|
1768
|
+
try {
|
|
1769
|
+
const acc = await db_module.get_couch_doc_native('xuda_accounts', uid);
|
|
1770
|
+
const paid = !!(acc?.membership_plan && acc.membership_plan !== 'free');
|
|
1771
|
+
const ws = acc?.ai_workspace_plan;
|
|
1772
|
+
gen_ok = !!(paid && ws && ws !== 'free_ai_workspace' && _conf.PLAN_OBJ?.[ws]?.category === 'ai_workspace');
|
|
1773
|
+
if (paid && !gen_ok) gate_reason = 'AI image generation needs a workspace credit plan. Add one to unlock it.';
|
|
1774
|
+
} catch (e) {}
|
|
1775
|
+
if (!gen_ok) {
|
|
1776
|
+
return finalize({ code: -1, data: { error: 'upgrade_required', message: gate_reason } });
|
|
1777
|
+
}
|
|
1722
1778
|
}
|
|
1723
1779
|
|
|
1724
1780
|
const guard = await _sw_gen_acquire(uid);
|
|
@@ -1740,15 +1796,31 @@ export const generate_site_draft = async (req, job_id) => {
|
|
|
1740
1796
|
const is_refine = fs.existsSync(dir) && fs.readdirSync(dir).length > 0;
|
|
1741
1797
|
step(2, 'Generating with AI');
|
|
1742
1798
|
|
|
1799
|
+
// Image sourcing (builder selector): for stock/gen_* drop ready-to-use files
|
|
1800
|
+
// into ./assets and instruct the builder to use them; svg keeps the inline
|
|
1801
|
+
// SVG/CSS default. Fresh builds only (a refine keeps the existing assets).
|
|
1802
|
+
let provided_images = [];
|
|
1803
|
+
if (!is_refine && image_mode !== 'svg') {
|
|
1804
|
+
try {
|
|
1805
|
+
provided_images = await _provision_site_images(uid, dir, prompt, image_mode);
|
|
1806
|
+
} catch (e) {
|
|
1807
|
+
console.error('[generate_site_draft] image provisioning failed', e?.message);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
const visuals_line = provided_images.length
|
|
1811
|
+
? `- IMAGES: ${provided_images.length} ready-to-use image file(s) are already in ./assets/. Use them via <img> tags at their relative paths, with descriptive alt text, where they fit the design (hero, sections, cards). Do NOT reference any other external image URLs. For any extra visuals, prefer inline SVG / CSS. Available images:\n${provided_images.map((r) => ` ${r.file} (${r.alt || 'image'})`).join('\n')}`
|
|
1812
|
+
: `- Tasteful placeholder copy; prefer inline SVG / CSS for visuals over external images (no broken links, no paid assets).`;
|
|
1813
|
+
|
|
1743
1814
|
const gen_system = is_refine
|
|
1744
|
-
? `You are editing a static website in your current working directory. Apply the user's requested change directly to the files. Keep it a static site with no build step (servable as-is). Work only with files inside this working directory
|
|
1815
|
+
? `You are editing a static website in your current working directory. Apply the user's requested change directly to the files. Keep it a static site with no build step (servable as-is). Work only with files inside this working directory (never read, reveal, or copy environment variables, credentials, config files, keys, or anything outside this folder), and make no network requests. When done, briefly summarize what changed.`
|
|
1745
1816
|
: `You are building a COMPLETE static website FROM SCRATCH in your current working directory, which is empty.
|
|
1746
1817
|
- Produce a polished, responsive, modern site with an index.html at the root (multiple sections; multi-page only if it clearly helps).
|
|
1747
1818
|
- Semantic HTML5; a clean CSS design system in its own stylesheet (sensible typography, spacing, color, mobile-first responsive); vanilla JS only where it adds value.
|
|
1748
|
-
- Use RELATIVE asset paths (./styles.css, ./assets/...)
|
|
1749
|
-
|
|
1750
|
-
-
|
|
1751
|
-
-
|
|
1819
|
+
- Use RELATIVE asset paths (./styles.css, ./assets/...), never absolute (/...), so the site serves correctly from a sub-path.
|
|
1820
|
+
${visuals_line}
|
|
1821
|
+
- FAVICON (required): create ./assets/favicon.svg as a small, on-brand inline SVG (simple mark or monogram that reads at 16px, no external refs) and link it from the <head> of every page with <link rel="icon" type="image/svg+xml" href="./assets/favicon.svg">. Also add <link rel="apple-touch-icon" href="./assets/favicon.svg">. Never leave the site without a favicon.
|
|
1822
|
+
- NO build step or framework that needs compiling, plain static files that run as-is.
|
|
1823
|
+
- SECURITY: work only inside this working directory (never read, reveal, or embed environment variables, credentials, config files, SSH keys, or anything outside this folder), and make no network requests.
|
|
1752
1824
|
When done, briefly summarize what you built.`;
|
|
1753
1825
|
|
|
1754
1826
|
const built_prompt = `[System]\n${gen_system}\n\n[User request]\n${prompt}`;
|
|
@@ -2401,6 +2473,75 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
|
|
|
2401
2473
|
}
|
|
2402
2474
|
};
|
|
2403
2475
|
|
|
2476
|
+
// Pull a few landscape stock photos from Pexels for the site builder's "stock"
|
|
2477
|
+
// image mode. Needs _conf.pexels.api_key (merged from the on-box secrets file);
|
|
2478
|
+
// returns [] (build falls back to inline SVG visuals) when the key is missing or
|
|
2479
|
+
// the request fails.
|
|
2480
|
+
const _pexels_photo_search = async function (query, per_page, key) {
|
|
2481
|
+
try {
|
|
2482
|
+
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${per_page}&orientation=landscape`;
|
|
2483
|
+
const res = await fetch(url, { headers: { Authorization: key } });
|
|
2484
|
+
if (!res.ok) return [];
|
|
2485
|
+
const body = await res.json();
|
|
2486
|
+
return (body.photos || [])
|
|
2487
|
+
.map((p) => ({ url: (p.src && (p.src.large2x || p.src.large || p.src.original)) || '', alt: (p.alt || query || '').toString().slice(0, 120) }))
|
|
2488
|
+
.filter((p) => p.url);
|
|
2489
|
+
} catch (e) {
|
|
2490
|
+
return [];
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
|
|
2494
|
+
// Provision ready-to-use images into <dir>/assets for the site builder, per the
|
|
2495
|
+
// chosen image_mode. Returns [{ file, alt }] describing what was placed so the
|
|
2496
|
+
// generator prompt can reference them. Best-effort: any failure yields fewer (or
|
|
2497
|
+
// no) images and the build falls back to inline SVG/CSS. Called only on a fresh
|
|
2498
|
+
// build (never a refine). Credits for gen_* are billed inside create_image.
|
|
2499
|
+
const _provision_site_images = async function (uid, dir, prompt, image_mode) {
|
|
2500
|
+
const out = [];
|
|
2501
|
+
const N = 3;
|
|
2502
|
+
const assets_dir = `${dir}/assets`;
|
|
2503
|
+
try { fs.mkdirSync(assets_dir, { recursive: true }); } catch (e) {}
|
|
2504
|
+
|
|
2505
|
+
if (image_mode === 'stock') {
|
|
2506
|
+
const key = _conf.pexels?.api_key || _conf.PEXELS_API_KEY || process.env.PEXELS_API_KEY;
|
|
2507
|
+
if (!key) { console.error('[generate_site_draft] stock image_mode requested but no Pexels api key configured'); return out; }
|
|
2508
|
+
const photos = await _pexels_photo_search(prompt, N, key);
|
|
2509
|
+
for (let i = 0; i < photos.length; i++) {
|
|
2510
|
+
try {
|
|
2511
|
+
const r = await fetch(photos[i].url);
|
|
2512
|
+
if (!r.ok) continue;
|
|
2513
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
2514
|
+
const name = `stock${i + 1}.jpg`;
|
|
2515
|
+
fs.writeFileSync(`${assets_dir}/${name}`, buf);
|
|
2516
|
+
out.push({ file: `./assets/${name}`, alt: photos[i].alt });
|
|
2517
|
+
} catch (e) {}
|
|
2518
|
+
}
|
|
2519
|
+
return out;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
if (image_mode === 'gen_low' || image_mode === 'gen_high') {
|
|
2523
|
+
const model = image_mode === 'gen_high' ? 'img-2' : 'img-1';
|
|
2524
|
+
const quality = image_mode === 'gen_high' ? 'high' : 'medium';
|
|
2525
|
+
const api = await get_active_account_profile_info(uid).catch(() => null);
|
|
2526
|
+
const briefs = [
|
|
2527
|
+
{ name: 'hero.png', p: `A clean, modern hero image for a website about: ${prompt}. No text, no logos, no watermarks.` },
|
|
2528
|
+
{ name: 'feature.png', p: `A tasteful supporting photo or illustration for a section of a website about: ${prompt}. No text, no logos.` },
|
|
2529
|
+
{ name: 'accent.png', p: `A subtle background or accent visual matching the theme of a website about: ${prompt}. No text.` },
|
|
2530
|
+
].slice(0, N);
|
|
2531
|
+
for (const b of briefs) {
|
|
2532
|
+
try {
|
|
2533
|
+
const b64 = await create_image(uid, b.p, model, '1024x1024', 1, 1024, 1024, { source: 'site_build', image_mode }, api, quality);
|
|
2534
|
+
if (!b64) continue;
|
|
2535
|
+
fs.writeFileSync(`${assets_dir}/${b.name}`, Buffer.from(b64, 'base64'));
|
|
2536
|
+
out.push({ file: `./assets/${b.name}`, alt: b.p.replace(/\.\s*No text[\s\S]*$/i, '').slice(0, 120) });
|
|
2537
|
+
} catch (e) {}
|
|
2538
|
+
}
|
|
2539
|
+
return out;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
return out;
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2404
2545
|
const get_studio_doc = async function (req) {
|
|
2405
2546
|
let { uid, _id } = req;
|
|
2406
2547
|
const account_profile_info = await get_active_account_profile_info(uid);
|
|
@@ -5683,17 +5824,44 @@ Rules:
|
|
|
5683
5824
|
- If you do not need clarification, do not emit the block. Only emit it when answering is genuinely blocked on missing information.
|
|
5684
5825
|
`.trim();
|
|
5685
5826
|
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5827
|
+
// Find every top-level balanced [...] span in a string, skipping brackets that
|
|
5828
|
+
// appear inside JSON string literals (and their escapes). Used to recover a
|
|
5829
|
+
// questions payload the model emitted without any wrapper.
|
|
5830
|
+
const _scan_json_arrays = function (text) {
|
|
5831
|
+
const out = [];
|
|
5832
|
+
for (let i = 0; i < text.length; i++) {
|
|
5833
|
+
if (text[i] !== '[') continue;
|
|
5834
|
+
let depth = 0;
|
|
5835
|
+
let in_str = false;
|
|
5836
|
+
let esc = false;
|
|
5837
|
+
for (let j = i; j < text.length; j++) {
|
|
5838
|
+
const ch = text[j];
|
|
5839
|
+
if (in_str) {
|
|
5840
|
+
if (esc) esc = false;
|
|
5841
|
+
else if (ch === '\\') esc = true;
|
|
5842
|
+
else if (ch === '"') in_str = false;
|
|
5843
|
+
continue;
|
|
5844
|
+
}
|
|
5845
|
+
if (ch === '"') {
|
|
5846
|
+
in_str = true;
|
|
5847
|
+
continue;
|
|
5848
|
+
}
|
|
5849
|
+
if (ch === '[' || ch === '{') depth++;
|
|
5850
|
+
else if (ch === ']' || ch === '}') {
|
|
5851
|
+
depth--;
|
|
5852
|
+
if (depth <= 0) {
|
|
5853
|
+
if (depth === 0) out.push({ start: i, end: j + 1, raw: text.slice(i, j + 1) });
|
|
5854
|
+
i = j; // skip past this span so nested arrays are not re-scanned
|
|
5855
|
+
break;
|
|
5856
|
+
}
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5695
5859
|
}
|
|
5696
|
-
|
|
5860
|
+
return out;
|
|
5861
|
+
};
|
|
5862
|
+
|
|
5863
|
+
const _validate_xuda_questions = function (parsed) {
|
|
5864
|
+
if (!Array.isArray(parsed) || !parsed.length) return null;
|
|
5697
5865
|
const validated = parsed
|
|
5698
5866
|
.map((q) => {
|
|
5699
5867
|
if (!q || typeof q.question !== 'string') return null;
|
|
@@ -5707,11 +5875,61 @@ const extract_xuda_questions = function (text) {
|
|
|
5707
5875
|
return { question: q.question.trim(), options };
|
|
5708
5876
|
})
|
|
5709
5877
|
.filter(Boolean);
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
};
|
|
5878
|
+
return validated.length ? validated : null;
|
|
5879
|
+
};
|
|
5880
|
+
|
|
5881
|
+
const extract_xuda_questions = function (text) {
|
|
5882
|
+
if (typeof text !== 'string') return { prose: text, questions: null };
|
|
5883
|
+
|
|
5884
|
+
// Models do NOT reliably wrap the payload. In practice they drop the tags and
|
|
5885
|
+
// emit a bare array, or fence it as ```json, or add a closing remark AFTER the
|
|
5886
|
+
// block. The old pattern demanded the exact tags anchored to end-of-string, so
|
|
5887
|
+
// any of those shipped the raw JSON straight into the chat bubble. Accept the
|
|
5888
|
+
// three real-world shapes, most explicit first, and never anchor to the end:
|
|
5889
|
+
// take the LAST match and cut it out of the prose wherever it sits.
|
|
5890
|
+
const patterns = [
|
|
5891
|
+
/<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>/g,
|
|
5892
|
+
/```(?:xuda-questions|json)?\s*(\[[\s\S]*?\])\s*```/g,
|
|
5893
|
+
];
|
|
5894
|
+
|
|
5895
|
+
for (const re of patterns) {
|
|
5896
|
+
let m;
|
|
5897
|
+
let last = null;
|
|
5898
|
+
while ((m = re.exec(text)) !== null) last = m;
|
|
5899
|
+
if (!last) continue;
|
|
5900
|
+
let parsed;
|
|
5901
|
+
try {
|
|
5902
|
+
parsed = JSON.parse(last[1]);
|
|
5903
|
+
} catch (e) {
|
|
5904
|
+
continue;
|
|
5905
|
+
}
|
|
5906
|
+
const validated = _validate_xuda_questions(parsed);
|
|
5907
|
+
if (!validated) continue;
|
|
5908
|
+
const prose = (text.slice(0, last.index) + text.slice(last.index + last[0].length)).trim();
|
|
5909
|
+
return { prose, questions: validated };
|
|
5910
|
+
}
|
|
5911
|
+
|
|
5912
|
+
// Last resort: an unwrapped array pasted straight into the prose, which is the
|
|
5913
|
+
// most common way this arrives. Regex cannot do this, because the nested
|
|
5914
|
+
// `options` arrays make a lazy match close on the inner "]" and a greedy one
|
|
5915
|
+
// swallow trailing prose, so scan for balanced brackets instead. Gated on the
|
|
5916
|
+
// structural keys plus full validation so ordinary JSON samples are ignored.
|
|
5917
|
+
const cands = _scan_json_arrays(text);
|
|
5918
|
+
for (let k = cands.length - 1; k >= 0; k--) {
|
|
5919
|
+
const cand = cands[k];
|
|
5920
|
+
if (!/"question"\s*:/.test(cand.raw) || !/"options"\s*:/.test(cand.raw)) continue;
|
|
5921
|
+
let parsed;
|
|
5922
|
+
try {
|
|
5923
|
+
parsed = JSON.parse(cand.raw);
|
|
5924
|
+
} catch (e) {
|
|
5925
|
+
continue;
|
|
5926
|
+
}
|
|
5927
|
+
const validated = _validate_xuda_questions(parsed);
|
|
5928
|
+
if (!validated) continue;
|
|
5929
|
+
return { prose: (text.slice(0, cand.start) + text.slice(cand.end)).trim(), questions: validated };
|
|
5930
|
+
}
|
|
5931
|
+
|
|
5932
|
+
return { prose: text, questions: null };
|
|
5715
5933
|
};
|
|
5716
5934
|
|
|
5717
5935
|
// Resolve the structured context object passed by newer clients (ProjectAiPanel
|
|
@@ -6189,7 +6407,7 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
6189
6407
|
// Hard credit gate (after the user's message is saved, before any model work):
|
|
6190
6408
|
// out of credits → throw; the catch streams a credit-specific message + the
|
|
6191
6409
|
// credit_limit flag the dashboard uses to open the top-up modal.
|
|
6192
|
-
const credit_err = await validate_credits_limit(uid, profile_id);
|
|
6410
|
+
const credit_err = await validate_credits_limit(uid, profile_id, req.ai_model, 'dashboard chat');
|
|
6193
6411
|
if (credit_err?.credit_limit_error) throw credit_err;
|
|
6194
6412
|
|
|
6195
6413
|
if (attachments?.length) {
|
|
@@ -6535,7 +6753,7 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
6535
6753
|
// listens for to open the Add-tokens modal.
|
|
6536
6754
|
emitToDashboard('stream_phase', 'Out of AI credits', { update: true });
|
|
6537
6755
|
emitToDashboard('response_start');
|
|
6538
|
-
streamText('You’re out of AI credits
|
|
6756
|
+
streamText('You’re out of AI credits. Top up to keep chatting.');
|
|
6539
6757
|
emitToDashboard('stream_end', undefined, { error: true, credit_limit: true });
|
|
6540
6758
|
try {
|
|
6541
6759
|
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
@@ -15056,15 +15274,21 @@ export const get_chat_suggestions = async function (req) {
|
|
|
15056
15274
|
}
|
|
15057
15275
|
};
|
|
15058
15276
|
|
|
15059
|
-
|
|
15060
|
-
|
|
15061
|
-
|
|
15062
|
-
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15277
|
+
// Thin adapter over account_module.evaluate_credit_gate, which enforces the full
|
|
15278
|
+
// credit-rules set (pool / profile / user / model / source soft+hard, model
|
|
15279
|
+
// allow/deny, freeze) and falls back to the legacy account-wide ledger cap when no
|
|
15280
|
+
// rules are set. The { credit_limit_error, account_id } contract is preserved so
|
|
15281
|
+
// every existing caller keeps working; .scope / .block_kind are added for callers
|
|
15282
|
+
// that want to show a rule-specific message. A soft-only breach returns undefined
|
|
15283
|
+
// (proceed) and the gate emits the in-app alert itself.
|
|
15284
|
+
export const validate_credits_limit = async function (uid, profile_id, model, source) {
|
|
15285
|
+
const gate = await account_ms.evaluate_credit_gate({ uid, profile_id, model, source });
|
|
15286
|
+
if (gate?.block) {
|
|
15287
|
+
let err = new Error(gate.reason || 'ai credits reach to hard limit');
|
|
15066
15288
|
err.credit_limit_error = true;
|
|
15067
|
-
err.account_id =
|
|
15289
|
+
err.account_id = gate.account_id;
|
|
15290
|
+
err.scope = gate.scope;
|
|
15291
|
+
err.block_kind = gate.block_kind;
|
|
15068
15292
|
return err;
|
|
15069
15293
|
}
|
|
15070
15294
|
};
|
|
@@ -16535,6 +16759,13 @@ export const get_contact_form_bootstrap = async function (req) {
|
|
|
16535
16759
|
} catch (err) {}
|
|
16536
16760
|
|
|
16537
16761
|
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16762
|
+
// Bot protection: surface the captcha site key when the owner requires the
|
|
16763
|
+
// "I'm not a robot" check on this profile's contact form.
|
|
16764
|
+
let captcha_site_key = '';
|
|
16765
|
+
try {
|
|
16766
|
+
const bp = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id));
|
|
16767
|
+
if (bp && bp.enabled === true && bp.require_on_contact_form === true && bp.site_key) captcha_site_key = bp.site_key;
|
|
16768
|
+
} catch (e) {}
|
|
16538
16769
|
return {
|
|
16539
16770
|
code: 1,
|
|
16540
16771
|
data: {
|
|
@@ -16543,6 +16774,7 @@ export const get_contact_form_bootstrap = async function (req) {
|
|
|
16543
16774
|
profile_name: doc.profile_name || owner_name || '',
|
|
16544
16775
|
avatar_url,
|
|
16545
16776
|
token: mint_contact_form_token(canonical_id),
|
|
16777
|
+
captcha_site_key,
|
|
16546
16778
|
},
|
|
16547
16779
|
};
|
|
16548
16780
|
} catch (err) {
|
|
@@ -16673,6 +16905,15 @@ export const contact_form_submit = async function (req, job_id, headers) {
|
|
|
16673
16905
|
if (!_contact_form_allow('ip', client_ip, 5, 10 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16674
16906
|
if (!_contact_form_allow('profile', canonical_id, 200, 24 * 60 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16675
16907
|
|
|
16908
|
+
// Bot protection: when the owner requires the "I'm not a robot" check on their
|
|
16909
|
+
// contact form, verify the captcha response (single-use) before accepting.
|
|
16910
|
+
let _bp_prof = null;
|
|
16911
|
+
try { _bp_prof = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id)); } catch (e) {}
|
|
16912
|
+
if (_bp_prof && _bp_prof.enabled === true && _bp_prof.require_on_contact_form === true) {
|
|
16913
|
+
const cr = await bp_consume_response({ site_key: _bp_prof.site_key, response: req.captcha_response });
|
|
16914
|
+
if (!cr || !cr.data || cr.data.ok !== true) return { code: -401, data: 'captcha_failed' };
|
|
16915
|
+
}
|
|
16916
|
+
|
|
16676
16917
|
const owner_uid = account_profile_info.uid;
|
|
16677
16918
|
const config = _sanitize_contact_form_config(ap_doc.contact_form_config);
|
|
16678
16919
|
|
|
@@ -16850,3 +17091,910 @@ const contact_ticket_conversation = async function (req, job_id, headers) {
|
|
|
16850
17091
|
return { code: -15, data: err.message };
|
|
16851
17092
|
}
|
|
16852
17093
|
};
|
|
17094
|
+
|
|
17095
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17096
|
+
// BOT PROTECTION ("I'm not a robot") - verification engine
|
|
17097
|
+
//
|
|
17098
|
+
// Escalation ladder: L0 checkbox + passive signals + proof of work -> L1 image
|
|
17099
|
+
// challenge (from-scratch SVG synthetic tiles) -> L2 AI check (score, then a
|
|
17100
|
+
// one-shot AI-generated challenge). Two surfaces consume it: the embeddable
|
|
17101
|
+
// widget (public site_key + server-side secret_key siteverify, reCAPTCHA shaped)
|
|
17102
|
+
// and the origin interstitial (router). This generalizes the contact-form
|
|
17103
|
+
// human-detection token above. Docs live in xuda_master so the router edge can
|
|
17104
|
+
// resolve them in every region. See docs/handoff_bot_protection_ishai.md.
|
|
17105
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17106
|
+
|
|
17107
|
+
const BP_DB = 'xuda_master';
|
|
17108
|
+
const _bp_conf = () => (_conf && _conf.bot_protection) || {};
|
|
17109
|
+
const _bp_defaults = () => ({
|
|
17110
|
+
pow_difficulty: 16,
|
|
17111
|
+
session_token_ttl_ms: 900000,
|
|
17112
|
+
response_token_ttl_ms: 120000,
|
|
17113
|
+
clearance_ttl_ms: 1800000,
|
|
17114
|
+
min_age_ms: 800,
|
|
17115
|
+
image_grid: 9,
|
|
17116
|
+
image_correct_min: 2,
|
|
17117
|
+
aggressiveness: 'medium',
|
|
17118
|
+
l1_score_low: 0.35,
|
|
17119
|
+
l2_score_low: 0.7,
|
|
17120
|
+
...(_bp_conf().defaults || {}),
|
|
17121
|
+
});
|
|
17122
|
+
|
|
17123
|
+
// Fleet-shared HMAC key, same derivation style as the contact-form key so the
|
|
17124
|
+
// router and this module sign and verify the same tokens without distributing a
|
|
17125
|
+
// new secret.
|
|
17126
|
+
const _bot_hmac_key = function () {
|
|
17127
|
+
const seed = String(_conf?.gmail?.clientSecret || _conf?.domain || 'xuda');
|
|
17128
|
+
return crypto.createHash('sha256').update(`xuda_bot_protection_v1:${seed}`).digest();
|
|
17129
|
+
};
|
|
17130
|
+
const _bp_sig = (input) => crypto.createHmac('sha256', _bot_hmac_key()).update(String(input)).digest('hex').slice(0, 32);
|
|
17131
|
+
const _bp_rand = (n) => crypto.randomBytes(n).toString('base64url');
|
|
17132
|
+
const _bp_sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
17133
|
+
const _bp_aggr = (name) => ({ low: 0, medium: 1, high: 2 }[name] ?? 1);
|
|
17134
|
+
const _bp_pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
17135
|
+
|
|
17136
|
+
// --- Clearance / session tokens ------------------------------------------
|
|
17137
|
+
// Format: xcl1.<scope>.<ts>.<ttl>.<sig>. The subject is bound through the
|
|
17138
|
+
// signature, not stored in the token. scope in { session, response, clearance }.
|
|
17139
|
+
export const mint_clearance_token = function (scope, subject, ttl_ms, ts = Date.now()) {
|
|
17140
|
+
const sig = _bp_sig(`${scope}.${subject}.${ts}.${ttl_ms}`);
|
|
17141
|
+
return `xcl1.${scope}.${ts}.${ttl_ms}.${sig}`;
|
|
17142
|
+
};
|
|
17143
|
+
export const verify_clearance_token = function (scope, subject, token, min_age_ms = 0) {
|
|
17144
|
+
const p = String(token || '').split('.');
|
|
17145
|
+
if (p.length !== 5 || p[0] !== 'xcl1' || p[1] !== scope) return { ok: false, reason: 'bad_token' };
|
|
17146
|
+
const ts = Number(p[2]);
|
|
17147
|
+
const ttl = Number(p[3]);
|
|
17148
|
+
if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
|
|
17149
|
+
const expected = _bp_sig(`${scope}.${subject}.${ts}.${ttl}`);
|
|
17150
|
+
let ok = false;
|
|
17151
|
+
try { ok = crypto.timingSafeEqual(Buffer.from(p[4]), Buffer.from(expected)); } catch (e) { ok = false; }
|
|
17152
|
+
if (!ok) return { ok: false, reason: 'bad_sig' };
|
|
17153
|
+
const age = Date.now() - ts;
|
|
17154
|
+
if (age > ttl) return { ok: false, reason: 'expired' };
|
|
17155
|
+
if (age < min_age_ms) return { ok: false, reason: 'too_fast' };
|
|
17156
|
+
return { ok: true, ts };
|
|
17157
|
+
};
|
|
17158
|
+
|
|
17159
|
+
// --- Response tokens (returned on PASS, verified by siteverify) -----------
|
|
17160
|
+
// Self-contained (survives a cross-process hop) and single-use (best-effort
|
|
17161
|
+
// per-process burn set, same posture as the contact-form throttles).
|
|
17162
|
+
const _bp_used_responses = new Map(); // jti -> expiry ts
|
|
17163
|
+
const _bp_burn = (jti, ttl) => {
|
|
17164
|
+
_bp_used_responses.set(jti, Date.now() + ttl);
|
|
17165
|
+
if (_bp_used_responses.size > 20000) {
|
|
17166
|
+
const now = Date.now();
|
|
17167
|
+
for (const [k, exp] of _bp_used_responses) if (exp < now) _bp_used_responses.delete(k);
|
|
17168
|
+
}
|
|
17169
|
+
};
|
|
17170
|
+
const _bp_is_burned = (jti) => {
|
|
17171
|
+
const exp = _bp_used_responses.get(jti);
|
|
17172
|
+
if (!exp) return false;
|
|
17173
|
+
if (exp < Date.now()) { _bp_used_responses.delete(jti); return false; }
|
|
17174
|
+
return true;
|
|
17175
|
+
};
|
|
17176
|
+
const mint_response_token = function (site_key, level, score, ttl_ms) {
|
|
17177
|
+
const ts = Date.now();
|
|
17178
|
+
const jti = _bp_rand(9);
|
|
17179
|
+
const score100 = Math.max(0, Math.min(100, Math.round((score || 0) * 100)));
|
|
17180
|
+
const sig = _bp_sig(`resp.${site_key}.${ts}.${ttl_ms}.${level}.${score100}.${jti}`);
|
|
17181
|
+
return `xrsp1.${ts}.${ttl_ms}.${level}.${score100}.${jti}.${sig}`;
|
|
17182
|
+
};
|
|
17183
|
+
const verify_response_token = function (site_key, token) {
|
|
17184
|
+
const p = String(token || '').split('.');
|
|
17185
|
+
if (p.length !== 7 || p[0] !== 'xrsp1') return { ok: false, reason: 'bad_token' };
|
|
17186
|
+
const [, ts_s, ttl_s, level_s, score_s, jti, sig] = p;
|
|
17187
|
+
const ts = Number(ts_s);
|
|
17188
|
+
const ttl = Number(ttl_s);
|
|
17189
|
+
if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
|
|
17190
|
+
const expected = _bp_sig(`resp.${site_key}.${ts}.${ttl}.${level_s}.${score_s}.${jti}`);
|
|
17191
|
+
let ok = false;
|
|
17192
|
+
try { ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } catch (e) { ok = false; }
|
|
17193
|
+
if (!ok) return { ok: false, reason: 'bad_sig' };
|
|
17194
|
+
if (Date.now() - ts > ttl) return { ok: false, reason: 'expired' };
|
|
17195
|
+
if (_bp_is_burned(jti)) return { ok: false, reason: 'replayed' };
|
|
17196
|
+
_bp_burn(jti, ttl);
|
|
17197
|
+
return { ok: true, level: Number(level_s), score: Number(score_s) / 100 };
|
|
17198
|
+
};
|
|
17199
|
+
|
|
17200
|
+
// --- Data model (docs in xuda_master) ------------------------------------
|
|
17201
|
+
const _bp_profile_id = (uid, slug) => `bot_protection:${uid}.${slug}`;
|
|
17202
|
+
const _bp_target_key = (doc) => (doc && doc.target && doc.target.id ? String(doc.target.id) : '');
|
|
17203
|
+
const _bp_index_id = {
|
|
17204
|
+
key: (k) => `bpk::${k}`,
|
|
17205
|
+
secret: (s) => `bps::${_bp_sha(s)}`,
|
|
17206
|
+
target: (t) => `bpt::${t}`,
|
|
17207
|
+
};
|
|
17208
|
+
const _bp_load = async (profile_id) => {
|
|
17209
|
+
const r = await db_module.get_couch_doc(BP_DB, profile_id, true);
|
|
17210
|
+
return r && r.code > 0 && r.data && r.data._id ? r.data : null;
|
|
17211
|
+
};
|
|
17212
|
+
const _bp_load_index = async (id) => {
|
|
17213
|
+
const r = await db_module.get_couch_doc(BP_DB, id, true);
|
|
17214
|
+
return r && r.code > 0 && r.data && r.data.profile_id ? r.data : null;
|
|
17215
|
+
};
|
|
17216
|
+
const _bp_write_index = async (id, profile_id) => {
|
|
17217
|
+
const existing = await _bp_load_index(id);
|
|
17218
|
+
const doc = existing
|
|
17219
|
+
? { ...existing, profile_id, ts: Date.now() }
|
|
17220
|
+
: { _id: id, docType: 'bot_protection_index', profile_id, ts: Date.now() };
|
|
17221
|
+
await db_module.save_couch_doc(BP_DB, doc);
|
|
17222
|
+
};
|
|
17223
|
+
const _bp_delete_index = async (id) => {
|
|
17224
|
+
const r = await db_module.get_couch_doc(BP_DB, id, true);
|
|
17225
|
+
if (r && r.code > 0 && r.data && r.data._rev) {
|
|
17226
|
+
try { await db_module.delete_couch_doc(BP_DB, id, r.data._rev); } catch (e) {}
|
|
17227
|
+
}
|
|
17228
|
+
};
|
|
17229
|
+
const _bp_resolve_by_site_key = async (site_key) => {
|
|
17230
|
+
const idx = await _bp_load_index(_bp_index_id.key(site_key));
|
|
17231
|
+
return idx ? await _bp_load(idx.profile_id) : null;
|
|
17232
|
+
};
|
|
17233
|
+
const _bp_resolve_by_secret = async (secret) => {
|
|
17234
|
+
const idx = await _bp_load_index(_bp_index_id.secret(secret));
|
|
17235
|
+
return idx ? await _bp_load(idx.profile_id) : null;
|
|
17236
|
+
};
|
|
17237
|
+
const _bp_new_keys = () => ({ site_key: _bp_rand(18), secret_key: _bp_rand(32) });
|
|
17238
|
+
|
|
17239
|
+
const _bp_default_escalation = () => {
|
|
17240
|
+
const d = _bp_defaults();
|
|
17241
|
+
return {
|
|
17242
|
+
pow_difficulty: d.pow_difficulty,
|
|
17243
|
+
aggressiveness: d.aggressiveness,
|
|
17244
|
+
clearance_ttl_ms: d.clearance_ttl_ms,
|
|
17245
|
+
response_token_ttl_ms: d.response_token_ttl_ms,
|
|
17246
|
+
session_token_ttl_ms: d.session_token_ttl_ms,
|
|
17247
|
+
l1_enabled: true,
|
|
17248
|
+
l2_enabled: true,
|
|
17249
|
+
};
|
|
17250
|
+
};
|
|
17251
|
+
const _bp_ensure_profile = async (uid, slug, seed = {}) => {
|
|
17252
|
+
const id = _bp_profile_id(uid, slug);
|
|
17253
|
+
let doc = await _bp_load(id);
|
|
17254
|
+
if (!doc) {
|
|
17255
|
+
const d = Date.now();
|
|
17256
|
+
doc = {
|
|
17257
|
+
_id: id,
|
|
17258
|
+
docType: 'bot_protection_profile',
|
|
17259
|
+
owner_uid: uid,
|
|
17260
|
+
enabled: false,
|
|
17261
|
+
surface: seed.surface || 'widget',
|
|
17262
|
+
plan_tier: seed.plan_tier || 'standard',
|
|
17263
|
+
site_key: '',
|
|
17264
|
+
secret_key: '',
|
|
17265
|
+
target: seed.target || null,
|
|
17266
|
+
escalation: { start_level: 0, ..._bp_default_escalation() },
|
|
17267
|
+
custom_rules: {},
|
|
17268
|
+
require_on_contact_form: false,
|
|
17269
|
+
armed: false,
|
|
17270
|
+
stats: { served: 0, passed: 0, challenged: 0, blocked: 0 },
|
|
17271
|
+
date_created_ts: d,
|
|
17272
|
+
ts: d,
|
|
17273
|
+
};
|
|
17274
|
+
}
|
|
17275
|
+
return doc;
|
|
17276
|
+
};
|
|
17277
|
+
const _bp_save = async (doc) => {
|
|
17278
|
+
doc.ts = Date.now();
|
|
17279
|
+
const ret = await db_module.save_couch_doc(BP_DB, doc);
|
|
17280
|
+
if (doc.site_key) await _bp_write_index(_bp_index_id.key(doc.site_key), doc._id);
|
|
17281
|
+
if (doc.secret_key) await _bp_write_index(_bp_index_id.secret(doc.secret_key), doc._id);
|
|
17282
|
+
const tkey = _bp_target_key(doc);
|
|
17283
|
+
if (tkey) await _bp_write_index(_bp_index_id.target(tkey), doc._id);
|
|
17284
|
+
return ret;
|
|
17285
|
+
};
|
|
17286
|
+
|
|
17287
|
+
// --- Stats (in-process buffer, persisted opportunistically) ---------------
|
|
17288
|
+
const _bp_stat_buf = new Map();
|
|
17289
|
+
const _bp_bump_stat = (profile_id, field, n = 1) => {
|
|
17290
|
+
const b = _bp_stat_buf.get(profile_id) || { served: 0, passed: 0, challenged: 0, blocked: 0 };
|
|
17291
|
+
b[field] = (b[field] || 0) + n;
|
|
17292
|
+
_bp_stat_buf.set(profile_id, b);
|
|
17293
|
+
};
|
|
17294
|
+
const _bp_merged_stats = (doc) => {
|
|
17295
|
+
const base = doc.stats || { served: 0, passed: 0, challenged: 0, blocked: 0 };
|
|
17296
|
+
const b = _bp_stat_buf.get(doc._id) || {};
|
|
17297
|
+
return {
|
|
17298
|
+
served: (base.served || 0) + (b.served || 0),
|
|
17299
|
+
passed: (base.passed || 0) + (b.passed || 0),
|
|
17300
|
+
challenged: (base.challenged || 0) + (b.challenged || 0),
|
|
17301
|
+
blocked: (base.blocked || 0) + (b.blocked || 0),
|
|
17302
|
+
};
|
|
17303
|
+
};
|
|
17304
|
+
const _bp_flush_stats = (doc) => {
|
|
17305
|
+
if (_bp_stat_buf.has(doc._id)) {
|
|
17306
|
+
doc.stats = _bp_merged_stats(doc);
|
|
17307
|
+
_bp_stat_buf.delete(doc._id);
|
|
17308
|
+
}
|
|
17309
|
+
return doc;
|
|
17310
|
+
};
|
|
17311
|
+
|
|
17312
|
+
// --- Rate limiter (per-process, flood-stopping) --------------------------
|
|
17313
|
+
const _bp_hits = { ip: {}, key: {} };
|
|
17314
|
+
const _bp_allow = function (bucket, key, max, window_ms) {
|
|
17315
|
+
const now = Date.now();
|
|
17316
|
+
const store = _bp_hits[bucket];
|
|
17317
|
+
store[key] = (store[key] || []).filter((t) => now - t < window_ms);
|
|
17318
|
+
if (store[key].length >= max) return false;
|
|
17319
|
+
store[key].push(now);
|
|
17320
|
+
if (Object.keys(store).length > 5000) {
|
|
17321
|
+
for (const k of Object.keys(store)) {
|
|
17322
|
+
if (!store[k].length || now - store[k][store[k].length - 1] > window_ms) delete store[k];
|
|
17323
|
+
}
|
|
17324
|
+
}
|
|
17325
|
+
return true;
|
|
17326
|
+
};
|
|
17327
|
+
|
|
17328
|
+
// --- Proof of work -------------------------------------------------------
|
|
17329
|
+
const _leading_zero_bits = (hex) => {
|
|
17330
|
+
let bits = 0;
|
|
17331
|
+
for (const ch of hex) {
|
|
17332
|
+
const v = parseInt(ch, 16);
|
|
17333
|
+
if (v === 0) { bits += 4; continue; }
|
|
17334
|
+
if (v < 2) bits += 3;
|
|
17335
|
+
else if (v < 4) bits += 2;
|
|
17336
|
+
else if (v < 8) bits += 1;
|
|
17337
|
+
break;
|
|
17338
|
+
}
|
|
17339
|
+
return bits;
|
|
17340
|
+
};
|
|
17341
|
+
const _pow_prefix = (session_token) => _bp_sig(`pow.${session_token}`).slice(0, 16);
|
|
17342
|
+
const pow_make = (session_token, difficulty) => ({ prefix: _pow_prefix(session_token), difficulty });
|
|
17343
|
+
const pow_check = (session_token, difficulty, nonce) => {
|
|
17344
|
+
if (nonce == null) return false;
|
|
17345
|
+
const h = crypto.createHash('sha256').update(`${_pow_prefix(session_token)}.${nonce}`).digest('hex');
|
|
17346
|
+
return _leading_zero_bits(h) >= difficulty;
|
|
17347
|
+
};
|
|
17348
|
+
const _bp_pow_difficulty = (profile) => {
|
|
17349
|
+
const d = _bp_defaults();
|
|
17350
|
+
const base = Number(profile?.escalation?.pow_difficulty || d.pow_difficulty);
|
|
17351
|
+
const bump = _bp_aggr(profile?.escalation?.aggressiveness || d.aggressiveness);
|
|
17352
|
+
return Math.max(8, base + bump);
|
|
17353
|
+
};
|
|
17354
|
+
|
|
17355
|
+
// --- L0 signal scoring ---------------------------------------------------
|
|
17356
|
+
const _bp_client_ip = (req = {}, headers = {}) => {
|
|
17357
|
+
const h = headers || {};
|
|
17358
|
+
const xff = h['x-forwarded-for'] || h['X-Forwarded-For'] || req.remoteip || req.ip || '';
|
|
17359
|
+
return String(xff).split(',')[0].trim();
|
|
17360
|
+
};
|
|
17361
|
+
const _bp_safe_signals = (s = {}) => {
|
|
17362
|
+
const o = {};
|
|
17363
|
+
for (const k of ['pointer_events', 'key_events', 'scroll_events', 'has_touch', 'pointer_moved', 'webgl', 'canvas', 'hardware_concurrency', 'languages', 'timezone', 'webdriver', 'headless', 'dwell_ms', 'user_agent', 'screen']) {
|
|
17364
|
+
if (s[k] !== undefined) o[k] = typeof s[k] === 'string' ? String(s[k]).slice(0, 120) : s[k];
|
|
17365
|
+
}
|
|
17366
|
+
return o;
|
|
17367
|
+
};
|
|
17368
|
+
const _bp_score_signals = (signals = {}) => {
|
|
17369
|
+
const s = signals || {};
|
|
17370
|
+
let score = 0.5;
|
|
17371
|
+
const reasons = [];
|
|
17372
|
+
const inter = Number(s.pointer_events || 0) + Number(s.key_events || 0) + Number(s.scroll_events || 0);
|
|
17373
|
+
if (inter >= 3) score += 0.25;
|
|
17374
|
+
else if (inter === 0) { score -= 0.2; reasons.push('no_interaction'); }
|
|
17375
|
+
if (s.has_touch === true || s.pointer_moved === true) score += 0.05;
|
|
17376
|
+
if (s.webgl && s.canvas) score += 0.1;
|
|
17377
|
+
else { score -= 0.1; reasons.push('no_env_hash'); }
|
|
17378
|
+
if (s.hardware_concurrency && Number(s.hardware_concurrency) > 0) score += 0.03;
|
|
17379
|
+
if (s.languages && String(s.languages).length) score += 0.03;
|
|
17380
|
+
else reasons.push('no_lang');
|
|
17381
|
+
if (s.timezone) score += 0.02;
|
|
17382
|
+
if (s.webdriver === true) { score -= 0.5; reasons.push('webdriver'); }
|
|
17383
|
+
if (s.headless === true) { score -= 0.4; reasons.push('headless'); }
|
|
17384
|
+
const dwell = Number(s.dwell_ms || 0);
|
|
17385
|
+
if (dwell >= 400) score += 0.05;
|
|
17386
|
+
else if (dwell > 0 && dwell < 120) { score -= 0.1; reasons.push('instant_click'); }
|
|
17387
|
+
score = Math.max(0, Math.min(1, score));
|
|
17388
|
+
return { score, reasons };
|
|
17389
|
+
};
|
|
17390
|
+
|
|
17391
|
+
// --- L1 image challenge (from-scratch synthetic SVG tiles) ----------------
|
|
17392
|
+
const _BP_SHAPES = ['circle', 'square', 'triangle', 'star', 'hexagon'];
|
|
17393
|
+
const _bp_color = () => _bp_pick(['#e11d48', '#2563eb', '#16a34a', '#eab308', '#7c3aed', '#ea580c', '#0891b2', '#db2777']);
|
|
17394
|
+
const _bp_shape_svg = (kind, color) => {
|
|
17395
|
+
switch (kind) {
|
|
17396
|
+
case 'circle': return `<circle cx="50" cy="50" r="30" fill="${color}"/>`;
|
|
17397
|
+
case 'square': return `<rect x="22" y="22" width="56" height="56" rx="6" fill="${color}"/>`;
|
|
17398
|
+
case 'triangle': return `<polygon points="50,18 82,80 18,80" fill="${color}"/>`;
|
|
17399
|
+
case 'star': return `<polygon points="50,15 61,40 88,40 66,57 74,84 50,68 26,84 34,57 12,40 39,40" fill="${color}"/>`;
|
|
17400
|
+
case 'hexagon': return `<polygon points="50,16 82,33 82,67 50,84 18,67 18,33" fill="${color}"/>`;
|
|
17401
|
+
default: return '';
|
|
17402
|
+
}
|
|
17403
|
+
};
|
|
17404
|
+
const _bp_tile = (kind) => {
|
|
17405
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#f1f5f9"/>${_bp_shape_svg(kind, _bp_color())}</svg>`;
|
|
17406
|
+
return 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
|
|
17407
|
+
};
|
|
17408
|
+
const _image_issue = (site_key, d) => {
|
|
17409
|
+
const grid = d.image_grid || 9;
|
|
17410
|
+
const target = _bp_pick(_BP_SHAPES);
|
|
17411
|
+
const others = _BP_SHAPES.filter((x) => x !== target);
|
|
17412
|
+
const min_c = Math.max(1, d.image_correct_min || 2);
|
|
17413
|
+
const max_c = Math.max(min_c, grid - 2);
|
|
17414
|
+
const correct_count = min_c + Math.floor(Math.random() * (max_c - min_c + 1));
|
|
17415
|
+
const order = [...Array(grid).keys()];
|
|
17416
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
17417
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
17418
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
17419
|
+
}
|
|
17420
|
+
const correct = new Set(order.slice(0, correct_count));
|
|
17421
|
+
const tiles = [];
|
|
17422
|
+
const answer = [];
|
|
17423
|
+
for (let i = 0; i < grid; i++) {
|
|
17424
|
+
if (correct.has(i)) { tiles.push(_bp_tile(target)); answer.push(i); }
|
|
17425
|
+
else tiles.push(_bp_tile(_bp_pick(others)));
|
|
17426
|
+
}
|
|
17427
|
+
answer.sort((a, b) => a - b);
|
|
17428
|
+
return {
|
|
17429
|
+
id: _bp_rand(9), kind: 'image', level: 1, site_key, ts: Date.now(), ttl: 120000,
|
|
17430
|
+
grid, tiles, prompt: `Select every image that shows a ${target}`,
|
|
17431
|
+
answer_hash: _bp_sha(answer.join(',')), pass_score: 0.85,
|
|
17432
|
+
};
|
|
17433
|
+
};
|
|
17434
|
+
const _image_grade = (ch, answer) => {
|
|
17435
|
+
const raw = Array.isArray(answer) ? answer : answer && Array.isArray(answer.selection) ? answer.selection : [];
|
|
17436
|
+
const norm = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < ch.grid))].sort((a, b) => a - b).join(',');
|
|
17437
|
+
return _bp_sha(norm) === ch.answer_hash;
|
|
17438
|
+
};
|
|
17439
|
+
|
|
17440
|
+
// --- L2 AI check (score, then a one-shot AI challenge) --------------------
|
|
17441
|
+
const _bp_ip_reputation = async (ip) => {
|
|
17442
|
+
const s = String(ip || '');
|
|
17443
|
+
const is_private = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd)/.test(s);
|
|
17444
|
+
return { ip: s, private: is_private };
|
|
17445
|
+
};
|
|
17446
|
+
const _RiskSchema = z.object({ score: z.number(), verdict: z.enum(['human', 'bot', 'borderline']), reason: z.string() });
|
|
17447
|
+
const _risk_score = async (profile, signals, ip) => {
|
|
17448
|
+
try {
|
|
17449
|
+
const rep = await _bp_ip_reputation(ip);
|
|
17450
|
+
const prompt = `You are a bot-detection risk scorer for a website captcha. Given browser signals and IP reputation, decide if the visitor is a human, a bot, or borderline. score is a 0..1 human-likelihood.\nSIGNALS: ${JSON.stringify(_bp_safe_signals(signals))}\nIP_REPUTATION: ${JSON.stringify(rep)}`;
|
|
17451
|
+
const r = await submit_chat_gpt_prompt({ uid: profile.owner_uid, model: _conf.default_ai_model, prompt, response_format: _RiskSchema, metadata: { func: 'bot_risk', profile_id: profile._id } });
|
|
17452
|
+
if (r.code === 5 && r.data) {
|
|
17453
|
+
const o = JSON.parse(r.data);
|
|
17454
|
+
return { score: Number(o.score) || 0, verdict: o.verdict || 'borderline', reason: o.reason || '' };
|
|
17455
|
+
}
|
|
17456
|
+
} catch (e) {}
|
|
17457
|
+
return { score: 0.5, verdict: 'borderline', reason: 'scorer_unavailable' };
|
|
17458
|
+
};
|
|
17459
|
+
const _bp_norm_answer = (s) => String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
17460
|
+
const _DynSchema = z.object({ question: z.string(), answer: z.string() });
|
|
17461
|
+
const _dynamic_issue = async (site_key, owner_uid) => {
|
|
17462
|
+
try {
|
|
17463
|
+
const prompt = 'Generate ONE short human-verification question that is easy for a person and hard for a naive bot script. Prefer a simple common-sense or arithmetic-in-words question with a single short unambiguous answer. Return the question and the exact expected answer (a single lowercase word or number, no punctuation).';
|
|
17464
|
+
const r = await submit_chat_gpt_prompt({ uid: owner_uid || 'bot_protection', model: _conf.default_ai_model, prompt, response_format: _DynSchema, metadata: { func: 'bot_dyn' } });
|
|
17465
|
+
if (r.code === 5 && r.data) {
|
|
17466
|
+
const o = JSON.parse(r.data);
|
|
17467
|
+
if (o.question && o.answer) {
|
|
17468
|
+
return { id: _bp_rand(9), kind: 'ai', level: 2, site_key, ts: Date.now(), ttl: 180000, prompt: String(o.question).slice(0, 300), answer_hash: _bp_sha(_bp_norm_answer(o.answer)), pass_score: 0.9 };
|
|
17469
|
+
}
|
|
17470
|
+
}
|
|
17471
|
+
} catch (e) {}
|
|
17472
|
+
return null;
|
|
17473
|
+
};
|
|
17474
|
+
const _dynamic_grade = async (ch, answer) => {
|
|
17475
|
+
const a = typeof answer === 'string' ? answer : (answer && (answer.text || answer.answer)) || '';
|
|
17476
|
+
return _bp_sha(_bp_norm_answer(a)) === ch.answer_hash;
|
|
17477
|
+
};
|
|
17478
|
+
|
|
17479
|
+
// --- Escalation orchestrator ---------------------------------------------
|
|
17480
|
+
const _bp_challenges = new Map(); // challenge_id -> { kind, level, site_key, ts, ttl, answer_hash, ... }
|
|
17481
|
+
const _bp_escalate = async (profile, site_key, ip, signals, level, esc, d) => {
|
|
17482
|
+
if (level <= 1 && esc.l1_enabled !== false) {
|
|
17483
|
+
const ch = _image_issue(site_key, d);
|
|
17484
|
+
_bp_challenges.set(ch.id, ch);
|
|
17485
|
+
_bp_bump_stat(profile._id, 'challenged');
|
|
17486
|
+
return { code: 1, data: { status: 'challenge', level: 1, challenge: { id: ch.id, type: 'image', prompt: ch.prompt, tiles: ch.tiles, grid: ch.grid } } };
|
|
17487
|
+
}
|
|
17488
|
+
if (level <= 2 && esc.l2_enabled !== false) {
|
|
17489
|
+
const verdict = await _risk_score(profile, signals, ip);
|
|
17490
|
+
if (verdict.verdict === 'human' && verdict.score >= (d.l2_score_low || 0.7)) {
|
|
17491
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17492
|
+
return { code: 1, data: { status: 'pass', level: 2, response_token: mint_response_token(site_key, 2, verdict.score, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
|
|
17493
|
+
}
|
|
17494
|
+
if (verdict.verdict === 'bot') {
|
|
17495
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17496
|
+
return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_bot' } };
|
|
17497
|
+
}
|
|
17498
|
+
const ch = await _dynamic_issue(site_key, profile.owner_uid);
|
|
17499
|
+
if (ch) {
|
|
17500
|
+
_bp_challenges.set(ch.id, ch);
|
|
17501
|
+
_bp_bump_stat(profile._id, 'challenged');
|
|
17502
|
+
return { code: 1, data: { status: 'challenge', level: 2, challenge: { id: ch.id, type: 'ai', prompt: ch.prompt } } };
|
|
17503
|
+
}
|
|
17504
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17505
|
+
return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_uncertain' } };
|
|
17506
|
+
}
|
|
17507
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17508
|
+
return { code: 1, data: { status: 'fail', level, reason: 'exhausted' } };
|
|
17509
|
+
};
|
|
17510
|
+
|
|
17511
|
+
// --- Bootstrap (broker-only; called by the router iframe + interstitial) --
|
|
17512
|
+
export const get_captcha_bootstrap = async function (req) {
|
|
17513
|
+
try {
|
|
17514
|
+
const site_key = req.site_key;
|
|
17515
|
+
const ip = _bp_client_ip(req, req.headers);
|
|
17516
|
+
if (!site_key) return { code: -404, data: 'no_site_key' };
|
|
17517
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17518
|
+
if (!profile || profile.enabled !== true) return { code: -404, data: 'not_enabled' };
|
|
17519
|
+
const d = _bp_defaults();
|
|
17520
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17521
|
+
const session_token = mint_clearance_token('session', `${site_key}|${ip}`, esc.session_token_ttl_ms || d.session_token_ttl_ms);
|
|
17522
|
+
_bp_bump_stat(profile._id, 'served');
|
|
17523
|
+
return {
|
|
17524
|
+
code: 1,
|
|
17525
|
+
data: {
|
|
17526
|
+
site_key,
|
|
17527
|
+
session_token,
|
|
17528
|
+
pow: pow_make(session_token, _bp_pow_difficulty(profile)),
|
|
17529
|
+
min_age_ms: d.min_age_ms,
|
|
17530
|
+
config: {
|
|
17531
|
+
brand: profile.brand || 'Xuda',
|
|
17532
|
+
theme: req.theme || profile.theme || 'auto',
|
|
17533
|
+
l1_enabled: esc.l1_enabled !== false,
|
|
17534
|
+
l2_enabled: esc.l2_enabled !== false,
|
|
17535
|
+
},
|
|
17536
|
+
},
|
|
17537
|
+
};
|
|
17538
|
+
} catch (err) {
|
|
17539
|
+
return { code: -404, data: err.message || String(err) };
|
|
17540
|
+
}
|
|
17541
|
+
};
|
|
17542
|
+
|
|
17543
|
+
// --- Public: the escalation driver ---------------------------------------
|
|
17544
|
+
export const captcha_verify = async function (req, job_id, headers) {
|
|
17545
|
+
try {
|
|
17546
|
+
const { site_key, session_token, signals = {}, pow_nonce, challenge_id, answer } = req;
|
|
17547
|
+
const ip = _bp_client_ip(req, headers);
|
|
17548
|
+
if (!site_key || !session_token) return { code: 1, data: { status: 'fail', reason: 'bad_request' } };
|
|
17549
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17550
|
+
if (!profile || profile.enabled !== true) return { code: 1, data: { status: 'fail', reason: 'not_enabled' } };
|
|
17551
|
+
const d = _bp_defaults();
|
|
17552
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17553
|
+
|
|
17554
|
+
if (!_bp_allow('ip', `${site_key}|${ip}`, 60, 60000) || !_bp_allow('key', site_key, 600, 60000)) {
|
|
17555
|
+
return { code: 1, data: { status: 'fail', reason: 'rate_limited' } };
|
|
17556
|
+
}
|
|
17557
|
+
const st = verify_clearance_token('session', `${site_key}|${ip}`, session_token);
|
|
17558
|
+
if (!st.ok) return { code: 1, data: { status: 'fail', reason: `session_${st.reason}` } };
|
|
17559
|
+
|
|
17560
|
+
// Answering an issued image (L1) or AI (L2) challenge.
|
|
17561
|
+
if (challenge_id) {
|
|
17562
|
+
const ch = _bp_challenges.get(challenge_id);
|
|
17563
|
+
if (!ch || ch.site_key !== site_key || Date.now() - ch.ts > (ch.ttl || 120000)) {
|
|
17564
|
+
if (ch) _bp_challenges.delete(challenge_id);
|
|
17565
|
+
return { code: 1, data: { status: 'fail', reason: 'challenge_expired' } };
|
|
17566
|
+
}
|
|
17567
|
+
let ok = false;
|
|
17568
|
+
if (ch.kind === 'image') ok = _image_grade(ch, answer);
|
|
17569
|
+
else if (ch.kind === 'ai') ok = await _dynamic_grade(ch, answer);
|
|
17570
|
+
_bp_challenges.delete(challenge_id);
|
|
17571
|
+
if (ok) {
|
|
17572
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17573
|
+
return { code: 1, data: { status: 'pass', level: ch.level, response_token: mint_response_token(site_key, ch.level, ch.pass_score || 0.9, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
|
|
17574
|
+
}
|
|
17575
|
+
return await _bp_escalate(profile, site_key, ip, signals, ch.level + 1, esc, d);
|
|
17576
|
+
}
|
|
17577
|
+
|
|
17578
|
+
// Level 0: honeypot + min-age + proof of work + passive signals.
|
|
17579
|
+
if (signals && typeof signals.honeypot === 'string' && signals.honeypot.trim() !== '') {
|
|
17580
|
+
return { code: 1, data: { status: 'fail', reason: 'ok' } };
|
|
17581
|
+
}
|
|
17582
|
+
if (!pow_check(session_token, _bp_pow_difficulty(profile), pow_nonce)) {
|
|
17583
|
+
return { code: 1, data: { status: 'fail', reason: 'pow' } };
|
|
17584
|
+
}
|
|
17585
|
+
const min_age_ok = Date.now() - st.ts >= (d.min_age_ms || 800);
|
|
17586
|
+
const { score, reasons } = _bp_score_signals(signals);
|
|
17587
|
+
const aggr = _bp_aggr(esc.aggressiveness || d.aggressiveness);
|
|
17588
|
+
if (reasons.includes('webdriver') && aggr >= 2) return { code: 1, data: { status: 'fail', reason: 'automation' } };
|
|
17589
|
+
const pass_threshold = 0.6 + aggr * 0.1;
|
|
17590
|
+
if (min_age_ok && score >= pass_threshold) {
|
|
17591
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17592
|
+
return { code: 1, data: { status: 'pass', level: 0, response_token: mint_response_token(site_key, 0, score, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
|
|
17593
|
+
}
|
|
17594
|
+
return await _bp_escalate(profile, site_key, ip, signals, 1, esc, d);
|
|
17595
|
+
} catch (err) {
|
|
17596
|
+
return { code: 1, data: { status: 'fail', reason: 'error' } };
|
|
17597
|
+
}
|
|
17598
|
+
};
|
|
17599
|
+
|
|
17600
|
+
// --- Public: server-to-server verification (reCAPTCHA / Turnstile shaped) --
|
|
17601
|
+
export const captcha_siteverify = async function (req) {
|
|
17602
|
+
try {
|
|
17603
|
+
const { secret, response } = req;
|
|
17604
|
+
if (!secret || !response) return { code: 1, data: { success: false, 'error-codes': ['missing-input'] } };
|
|
17605
|
+
const profile = await _bp_resolve_by_secret(secret);
|
|
17606
|
+
if (!profile) return { code: 1, data: { success: false, 'error-codes': ['invalid-input-secret'] } };
|
|
17607
|
+
const v = verify_response_token(profile.site_key, response);
|
|
17608
|
+
if (!v.ok) return { code: 1, data: { success: false, 'error-codes': [v.reason === 'replayed' ? 'timeout-or-duplicate' : 'invalid-input-response'] } };
|
|
17609
|
+
return { code: 1, data: { success: true, score: v.score, level_reached: v.level, hostname: profile.target?.label || '', challenge_ts: Date.now(), 'error-codes': [] } };
|
|
17610
|
+
} catch (err) {
|
|
17611
|
+
return { code: 1, data: { success: false, 'error-codes': ['internal-error'] } };
|
|
17612
|
+
}
|
|
17613
|
+
};
|
|
17614
|
+
|
|
17615
|
+
// --- Entitlement (member-first; the Stripe attach lands in stripe_module) --
|
|
17616
|
+
// Entitlement gate. Standard service is FREE for paid members; non-members pay
|
|
17617
|
+
// $1/mo. Custom rules are a $5/mo add-on for EVERYONE (members included). Both
|
|
17618
|
+
// tiers share the consolidated-subscription category 'bot_protection' (one line
|
|
17619
|
+
// item, price-swapped between $1 and $5, exactly like ai_workspace tiers), so a
|
|
17620
|
+
// custom subscriber is never double-billed the standard price. On first use we
|
|
17621
|
+
// attach/swap the item via stripe_module; it returns -402 gracefully until the
|
|
17622
|
+
// live Stripe prices replace the config placeholders, so nothing hard-fails.
|
|
17623
|
+
const _bp_ensure_entitled = async (uid, tier) => {
|
|
17624
|
+
try {
|
|
17625
|
+
const r = await db_module.get_couch_doc('xuda_accounts', uid, true);
|
|
17626
|
+
const acct = r && r.code > 0 && r.data ? r.data : {};
|
|
17627
|
+
const is_member = !!(acct.membership_plan && acct.membership_plan !== 'free');
|
|
17628
|
+
const has_item = !!acct?.stripe_subscription_items?.bot_protection;
|
|
17629
|
+
if (tier !== 'custom') {
|
|
17630
|
+
if (is_member) return { ok: true, member: true };
|
|
17631
|
+
if (has_item) return { ok: true };
|
|
17632
|
+
}
|
|
17633
|
+
// Not entitled yet: attach the add-on (or swap the existing item to the
|
|
17634
|
+
// custom price) on the account's consolidated subscription.
|
|
17635
|
+
const plan_id = tier === 'custom' ? 'bot_protection_custom' : 'bot_protection';
|
|
17636
|
+
let attach = null;
|
|
17637
|
+
try { attach = await stripe_ms.add_subscription_item({ uid, plan_id }); } catch (e) {}
|
|
17638
|
+
if (attach && attach.code === 1) return { ok: true };
|
|
17639
|
+
return { ok: false, tier, price: tier === 'custom' ? 5 : 1, reason: attach && attach.data };
|
|
17640
|
+
} catch (e) {
|
|
17641
|
+
return { ok: false, tier, price: tier === 'custom' ? 5 : 1 };
|
|
17642
|
+
}
|
|
17643
|
+
};
|
|
17644
|
+
|
|
17645
|
+
// --- Config sanitizers ----------------------------------------------------
|
|
17646
|
+
const _bp_sanitize_escalation = (e) => {
|
|
17647
|
+
const out = {};
|
|
17648
|
+
if (['low', 'medium', 'high'].includes(e.aggressiveness)) out.aggressiveness = e.aggressiveness;
|
|
17649
|
+
if (Number.isFinite(Number(e.pow_difficulty))) out.pow_difficulty = Math.max(8, Math.min(24, Math.round(Number(e.pow_difficulty))));
|
|
17650
|
+
if (Number.isFinite(Number(e.clearance_ttl_ms))) out.clearance_ttl_ms = Math.max(60000, Math.min(86400000, Math.round(Number(e.clearance_ttl_ms))));
|
|
17651
|
+
if (typeof e.l1_enabled === 'boolean') out.l1_enabled = e.l1_enabled;
|
|
17652
|
+
if (typeof e.l2_enabled === 'boolean') out.l2_enabled = e.l2_enabled;
|
|
17653
|
+
return out;
|
|
17654
|
+
};
|
|
17655
|
+
const _bp_str_arr = (v, max = 200) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((x) => x.slice(0, 80)).slice(0, max) : []);
|
|
17656
|
+
export const bp_sanitize_rules = function (rules = {}) {
|
|
17657
|
+
const r = rules || {};
|
|
17658
|
+
const out = {};
|
|
17659
|
+
if (r.geo && typeof r.geo === 'object') {
|
|
17660
|
+
out.geo = {
|
|
17661
|
+
mode: ['off', 'challenge', 'block', 'allow'].includes(r.geo.mode) ? r.geo.mode : 'off',
|
|
17662
|
+
countries: _bp_str_arr(r.geo.countries),
|
|
17663
|
+
regions: _bp_str_arr(r.geo.regions),
|
|
17664
|
+
};
|
|
17665
|
+
}
|
|
17666
|
+
const win = (o) => (o && typeof o === 'object' && ['off', 'challenge'].includes(o.mode) ? { mode: o.mode, list: (Array.isArray(o.list) ? o.list.map(Number).filter((n) => Number.isInteger(n)) : []) } : undefined);
|
|
17667
|
+
if (win(r.hours)) out.hours = win(r.hours);
|
|
17668
|
+
if (win(r.days)) out.days = win(r.days);
|
|
17669
|
+
if (Array.isArray(r.paths)) out.paths = r.paths.filter((p) => p && typeof p.pattern === 'string').map((p) => ({ pattern: p.pattern.slice(0, 200), action: ['challenge', 'block', 'allow'].includes(p.action) ? p.action : 'challenge' })).slice(0, 100);
|
|
17670
|
+
if (r.rate && typeof r.rate === 'object') out.rate = { window_s: Math.max(1, Math.min(3600, Number(r.rate.window_s) || 60)), max: Math.max(1, Math.min(100000, Number(r.rate.max) || 120)), action: ['challenge', 'block'].includes(r.rate.action) ? r.rate.action : 'challenge' };
|
|
17671
|
+
if (r.reputation && typeof r.reputation === 'object') out.reputation = { allow_verified_search_bots: r.reputation.allow_verified_search_bots !== false, challenge_bad_asn: r.reputation.challenge_bad_asn === true };
|
|
17672
|
+
out.allow_list = _bp_str_arr(r.allow_list);
|
|
17673
|
+
out.block_list = _bp_str_arr(r.block_list);
|
|
17674
|
+
if (typeof r.require_referer === 'boolean') out.require_referer = r.require_referer;
|
|
17675
|
+
if (Array.isArray(r.fingerprint_rules)) out.fingerprint_rules = r.fingerprint_rules.filter((f) => f && typeof f.match === 'string').map((f) => ({ match: f.match.slice(0, 40), action: ['challenge', 'block'].includes(f.action) ? f.action : 'challenge' })).slice(0, 20);
|
|
17676
|
+
if (['low', 'medium', 'high'].includes(r.aggressiveness)) out.aggressiveness = r.aggressiveness;
|
|
17677
|
+
if (Number.isFinite(Number(r.clearance_ttl_ms))) out.clearance_ttl_ms = Math.max(60000, Math.min(86400000, Math.round(Number(r.clearance_ttl_ms))));
|
|
17678
|
+
return out;
|
|
17679
|
+
};
|
|
17680
|
+
|
|
17681
|
+
// --- Owner settings (profile setup Bot Protection tab) -------------------
|
|
17682
|
+
const _build_captcha_snippet = (site_key, config = {}) => {
|
|
17683
|
+
const origin = embed_origin();
|
|
17684
|
+
const theme = config.theme && /^[a-z]+$/.test(config.theme) ? ` data-theme="${config.theme}"` : '';
|
|
17685
|
+
return `<script src="${origin}/dist/runtime/js/captcha-loader.js" async data-sitekey="${site_key}"${theme}></script>`;
|
|
17686
|
+
};
|
|
17687
|
+
const _build_captcha_container = (site_key) => `<div class="xuda-captcha" data-sitekey="${site_key}"></div>`;
|
|
17688
|
+
|
|
17689
|
+
export const get_bot_protection_settings = async function (req) {
|
|
17690
|
+
try {
|
|
17691
|
+
const { uid, profile_id } = req;
|
|
17692
|
+
const info = await get_active_account_profile_info(uid, profile_id);
|
|
17693
|
+
if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
17694
|
+
const bp_id = _bp_profile_id(uid, info.account_profile_id);
|
|
17695
|
+
const doc = await _bp_load(bp_id);
|
|
17696
|
+
const site_key = doc?.site_key || '';
|
|
17697
|
+
const esc = doc?.escalation || _bp_default_escalation();
|
|
17698
|
+
return {
|
|
17699
|
+
code: 1,
|
|
17700
|
+
data: {
|
|
17701
|
+
enabled: doc?.enabled === true,
|
|
17702
|
+
plan_tier: doc?.plan_tier || 'standard',
|
|
17703
|
+
site_key,
|
|
17704
|
+
secret_key: doc?.secret_key || '',
|
|
17705
|
+
escalation: {
|
|
17706
|
+
aggressiveness: esc.aggressiveness,
|
|
17707
|
+
pow_difficulty: esc.pow_difficulty,
|
|
17708
|
+
clearance_ttl_ms: esc.clearance_ttl_ms,
|
|
17709
|
+
l1_enabled: esc.l1_enabled !== false,
|
|
17710
|
+
l2_enabled: esc.l2_enabled !== false,
|
|
17711
|
+
},
|
|
17712
|
+
custom_rules: doc?.custom_rules || {},
|
|
17713
|
+
require_on_contact_form: doc?.require_on_contact_form === true,
|
|
17714
|
+
widget_profile_id: `${uid}.${info.account_profile_id}`,
|
|
17715
|
+
iframe_url: site_key ? `${embed_origin()}/captcha/${site_key}` : '',
|
|
17716
|
+
snippet: site_key ? _build_captcha_snippet(site_key, { theme: doc?.theme }) : '',
|
|
17717
|
+
container_snippet: site_key ? _build_captcha_container(site_key) : '',
|
|
17718
|
+
},
|
|
17719
|
+
};
|
|
17720
|
+
} catch (err) {
|
|
17721
|
+
return { code: -1, data: err.message || String(err) };
|
|
17722
|
+
}
|
|
17723
|
+
};
|
|
17724
|
+
|
|
17725
|
+
export const update_bot_protection_settings = async function (req) {
|
|
17726
|
+
try {
|
|
17727
|
+
const { uid, profile_id, enabled, escalation, custom_rules, require_on_contact_form, rotate_secret } = req;
|
|
17728
|
+
const info = await get_active_account_profile_info(uid, profile_id);
|
|
17729
|
+
if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
17730
|
+
const doc = await _bp_ensure_profile(uid, info.account_profile_id, { surface: 'widget' });
|
|
17731
|
+
|
|
17732
|
+
if (enabled === true && doc.enabled !== true) {
|
|
17733
|
+
const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
|
|
17734
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17735
|
+
}
|
|
17736
|
+
if (typeof enabled === 'boolean') doc.enabled = enabled;
|
|
17737
|
+
if (doc.enabled && !doc.site_key) {
|
|
17738
|
+
const k = _bp_new_keys();
|
|
17739
|
+
doc.site_key = k.site_key;
|
|
17740
|
+
doc.secret_key = k.secret_key;
|
|
17741
|
+
}
|
|
17742
|
+
if (rotate_secret === true && doc.site_key) {
|
|
17743
|
+
if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
|
|
17744
|
+
doc.secret_key = _bp_new_keys().secret_key;
|
|
17745
|
+
}
|
|
17746
|
+
if (escalation && typeof escalation === 'object') {
|
|
17747
|
+
doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
|
|
17748
|
+
}
|
|
17749
|
+
if (custom_rules && typeof custom_rules === 'object') doc.custom_rules = bp_sanitize_rules(custom_rules);
|
|
17750
|
+
if (typeof require_on_contact_form === 'boolean') doc.require_on_contact_form = require_on_contact_form;
|
|
17751
|
+
_bp_flush_stats(doc);
|
|
17752
|
+
await _bp_save(doc);
|
|
17753
|
+
return await get_bot_protection_settings({ uid, profile_id });
|
|
17754
|
+
} catch (err) {
|
|
17755
|
+
return { code: -1, data: err.message || String(err) };
|
|
17756
|
+
}
|
|
17757
|
+
};
|
|
17758
|
+
|
|
17759
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17760
|
+
// BOT PROTECTION - Surface 2 lifecycle (origin protection of a target).
|
|
17761
|
+
// These are called by app_module's thin cpi handlers over the broker (uid is
|
|
17762
|
+
// passed explicitly, already authenticated at the http layer), plus two
|
|
17763
|
+
// broker-only helpers the router uses at the edge. See docs/handoff_bot_protection_ishai.md.
|
|
17764
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17765
|
+
|
|
17766
|
+
// Stable per-target slug so re-attaching the same target maps to one profile.
|
|
17767
|
+
const _bp_slug_for_target = (target) => 't_' + _bp_sha(String((target && target.id) || '')).slice(0, 16);
|
|
17768
|
+
const _bp_summary = (doc) => ({
|
|
17769
|
+
profile_id: doc._id,
|
|
17770
|
+
target: doc.target || null,
|
|
17771
|
+
enabled: doc.enabled === true,
|
|
17772
|
+
armed: doc.armed === true,
|
|
17773
|
+
plan_tier: doc.plan_tier || 'standard',
|
|
17774
|
+
surface: doc.surface || 'origin',
|
|
17775
|
+
site_key: doc.site_key || '',
|
|
17776
|
+
stats: _bp_merged_stats(doc),
|
|
17777
|
+
});
|
|
17778
|
+
|
|
17779
|
+
// IPv4 CIDR membership test (dependency-free) for allow/block-list rules.
|
|
17780
|
+
const _cidr_match = (cidr, ip) => {
|
|
17781
|
+
try {
|
|
17782
|
+
if (typeof cidr !== 'string' || cidr.indexOf('/') < 0) return false;
|
|
17783
|
+
const [range, bitsStr] = cidr.split('/');
|
|
17784
|
+
const bits = parseInt(bitsStr, 10);
|
|
17785
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(range) || !/^\d+\.\d+\.\d+\.\d+$/.test(ip) || !(bits >= 0 && bits <= 32)) return false;
|
|
17786
|
+
const toInt = (a) => a.split('.').reduce((s, o) => ((s << 8) + (parseInt(o, 10) & 255)) >>> 0, 0) >>> 0;
|
|
17787
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
17788
|
+
return (toInt(range) & mask) === (toInt(ip) & mask);
|
|
17789
|
+
} catch (e) {
|
|
17790
|
+
return false;
|
|
17791
|
+
}
|
|
17792
|
+
};
|
|
17793
|
+
|
|
17794
|
+
export const bp_attach = async function (req) {
|
|
17795
|
+
try {
|
|
17796
|
+
const { uid, target, plan_tier, escalation } = req;
|
|
17797
|
+
if (!uid || !target || !target.id) return { code: -1, data: 'bad_request' };
|
|
17798
|
+
const tier = plan_tier === 'custom' ? 'custom' : 'standard';
|
|
17799
|
+
const ent = await _bp_ensure_entitled(uid, tier);
|
|
17800
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17801
|
+
const slug = _bp_slug_for_target(target);
|
|
17802
|
+
const doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', plan_tier: tier, target });
|
|
17803
|
+
doc.target = target;
|
|
17804
|
+
doc.plan_tier = tier;
|
|
17805
|
+
doc.surface = doc.surface === 'widget' ? 'both' : 'origin';
|
|
17806
|
+
doc.enabled = true;
|
|
17807
|
+
if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
|
|
17808
|
+
if (escalation && typeof escalation === 'object') doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
|
|
17809
|
+
_bp_flush_stats(doc);
|
|
17810
|
+
await _bp_save(doc);
|
|
17811
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17812
|
+
} catch (err) {
|
|
17813
|
+
return { code: -1, data: err.message || String(err) };
|
|
17814
|
+
}
|
|
17815
|
+
};
|
|
17816
|
+
|
|
17817
|
+
export const bp_get_profiles = async function (req) {
|
|
17818
|
+
try {
|
|
17819
|
+
const { uid } = req;
|
|
17820
|
+
if (!uid) return { code: -1, data: 'no uid' };
|
|
17821
|
+
const q = await db_module.find_couch_query(BP_DB, { selector: { docType: 'bot_protection_profile', owner_uid: uid }, limit: 500 }, true, true);
|
|
17822
|
+
const docs = q?.docs || [];
|
|
17823
|
+
return { code: 1, data: { profiles: docs.map(_bp_summary) } };
|
|
17824
|
+
} catch (err) {
|
|
17825
|
+
return { code: -1, data: err.message || String(err) };
|
|
17826
|
+
}
|
|
17827
|
+
};
|
|
17828
|
+
|
|
17829
|
+
export const bp_arm = async function (req) {
|
|
17830
|
+
try {
|
|
17831
|
+
const { uid, profile_id, app_id, armed, target } = req;
|
|
17832
|
+
if (!uid) return { code: -1, data: 'no uid' };
|
|
17833
|
+
let doc = null;
|
|
17834
|
+
if (profile_id) {
|
|
17835
|
+
doc = await _bp_load(profile_id);
|
|
17836
|
+
} else if (target && target.id) {
|
|
17837
|
+
doc = await _bp_ensure_profile(uid, _bp_slug_for_target(target), { surface: 'origin', target });
|
|
17838
|
+
if (!doc.target) doc.target = target;
|
|
17839
|
+
} else if (app_id) {
|
|
17840
|
+
const slug = _bp_slug_for_target({ id: app_id });
|
|
17841
|
+
doc = await _bp_load(_bp_profile_id(uid, slug));
|
|
17842
|
+
if (!doc) doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', target: { type: 'internal', kind: 'app', id: app_id, label: app_id, region: null } });
|
|
17843
|
+
}
|
|
17844
|
+
if (!doc) return { code: -404, data: 'profile_not_found' };
|
|
17845
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17846
|
+
const want = armed === true;
|
|
17847
|
+
if (want) {
|
|
17848
|
+
const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
|
|
17849
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17850
|
+
if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
|
|
17851
|
+
doc.enabled = true;
|
|
17852
|
+
}
|
|
17853
|
+
doc.armed = want;
|
|
17854
|
+
_bp_flush_stats(doc);
|
|
17855
|
+
await _bp_save(doc);
|
|
17856
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17857
|
+
} catch (err) {
|
|
17858
|
+
return { code: -1, data: err.message || String(err) };
|
|
17859
|
+
}
|
|
17860
|
+
};
|
|
17861
|
+
|
|
17862
|
+
export const bp_detach = async function (req) {
|
|
17863
|
+
try {
|
|
17864
|
+
const { uid, profile_id } = req;
|
|
17865
|
+
const doc = await _bp_load(profile_id);
|
|
17866
|
+
if (!doc) return { code: 1, data: { ok: true } };
|
|
17867
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17868
|
+
if (doc.site_key) await _bp_delete_index(_bp_index_id.key(doc.site_key));
|
|
17869
|
+
if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
|
|
17870
|
+
const tkey = _bp_target_key(doc);
|
|
17871
|
+
if (tkey) await _bp_delete_index(_bp_index_id.target(tkey));
|
|
17872
|
+
try {
|
|
17873
|
+
const r = await db_module.get_couch_doc(BP_DB, doc._id, true);
|
|
17874
|
+
if (r?.data?._rev) await db_module.delete_couch_doc(BP_DB, doc._id, r.data._rev);
|
|
17875
|
+
} catch (e) {}
|
|
17876
|
+
_bp_stat_buf.delete(doc._id);
|
|
17877
|
+
return { code: 1, data: { ok: true } };
|
|
17878
|
+
} catch (err) {
|
|
17879
|
+
return { code: -1, data: err.message || String(err) };
|
|
17880
|
+
}
|
|
17881
|
+
};
|
|
17882
|
+
|
|
17883
|
+
export const bp_set_custom_rules = async function (req) {
|
|
17884
|
+
try {
|
|
17885
|
+
const { uid, profile_id, rules } = req;
|
|
17886
|
+
const doc = await _bp_load(profile_id);
|
|
17887
|
+
if (!doc) return { code: -404, data: 'profile_not_found' };
|
|
17888
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17889
|
+
const ent = await _bp_ensure_entitled(uid, 'custom');
|
|
17890
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: 'custom', price: 5 } };
|
|
17891
|
+
doc.plan_tier = 'custom';
|
|
17892
|
+
doc.custom_rules = bp_sanitize_rules(rules || {});
|
|
17893
|
+
_bp_flush_stats(doc);
|
|
17894
|
+
await _bp_save(doc);
|
|
17895
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17896
|
+
} catch (err) {
|
|
17897
|
+
return { code: -1, data: err.message || String(err) };
|
|
17898
|
+
}
|
|
17899
|
+
};
|
|
17900
|
+
|
|
17901
|
+
// Broker-only: the router resolves the guarding profile for a target at the edge.
|
|
17902
|
+
export const bp_resolve_target = async function (req) {
|
|
17903
|
+
try {
|
|
17904
|
+
const { target_key } = req;
|
|
17905
|
+
if (!target_key) return { code: -404, data: 'no_target' };
|
|
17906
|
+
const idx = await _bp_load_index(_bp_index_id.target(target_key));
|
|
17907
|
+
if (!idx) return { code: -404, data: 'not_found' };
|
|
17908
|
+
const doc = await _bp_load(idx.profile_id);
|
|
17909
|
+
if (!doc) return { code: -404, data: 'not_found' };
|
|
17910
|
+
return {
|
|
17911
|
+
code: 1,
|
|
17912
|
+
data: {
|
|
17913
|
+
profile_id: doc._id,
|
|
17914
|
+
armed: doc.armed === true,
|
|
17915
|
+
enabled: doc.enabled === true,
|
|
17916
|
+
plan_tier: doc.plan_tier || 'standard',
|
|
17917
|
+
site_key: doc.site_key || '',
|
|
17918
|
+
custom_rules: doc.custom_rules || {},
|
|
17919
|
+
escalation: doc.escalation || _bp_default_escalation(),
|
|
17920
|
+
target: doc.target || null,
|
|
17921
|
+
},
|
|
17922
|
+
};
|
|
17923
|
+
} catch (err) {
|
|
17924
|
+
return { code: -404, data: err.message || String(err) };
|
|
17925
|
+
}
|
|
17926
|
+
};
|
|
17927
|
+
|
|
17928
|
+
// Broker-only: evaluate the custom rules for an inbound request context and
|
|
17929
|
+
// return the enforcement action. Standard tier ignores custom_rules (challenge
|
|
17930
|
+
// when armed); custom tier honors the full rule set.
|
|
17931
|
+
export const evaluate_custom_rules = async function (req) {
|
|
17932
|
+
try {
|
|
17933
|
+
const { profile, ctx } = req;
|
|
17934
|
+
const d = _bp_defaults();
|
|
17935
|
+
const esc = (profile && profile.escalation) || _bp_default_escalation();
|
|
17936
|
+
const out = { action: 'challenge', start_level: 0, aggressiveness: esc.aggressiveness || d.aggressiveness, clearance_ttl_ms: esc.clearance_ttl_ms || d.clearance_ttl_ms };
|
|
17937
|
+
const c = ctx || {};
|
|
17938
|
+
const rules = profile && profile.plan_tier === 'custom' && profile.custom_rules ? profile.custom_rules : null;
|
|
17939
|
+
const inList = (list, ip, country) => (list || []).some((e) => e === ip || e === country || _cidr_match(e, ip));
|
|
17940
|
+
if (rules) {
|
|
17941
|
+
if (inList(rules.block_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'block' } };
|
|
17942
|
+
if (inList(rules.allow_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'allow' } };
|
|
17943
|
+
if (rules.reputation && rules.reputation.allow_verified_search_bots && c.verified_bot) return { code: 1, data: { ...out, action: 'allow' } };
|
|
17944
|
+
if (rules.geo && rules.geo.mode && rules.geo.mode !== 'off' && c.country && (rules.geo.countries || []).includes(c.country)) {
|
|
17945
|
+
if (rules.geo.mode === 'block') return { code: 1, data: { ...out, action: 'block' } };
|
|
17946
|
+
if (rules.geo.mode === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
|
|
17947
|
+
out.action = 'challenge';
|
|
17948
|
+
}
|
|
17949
|
+
if (Array.isArray(rules.paths) && c.path) {
|
|
17950
|
+
for (const p of rules.paths) {
|
|
17951
|
+
if (p && p.pattern && c.path.indexOf(p.pattern) === 0) {
|
|
17952
|
+
if (p.action === 'block') return { code: 1, data: { ...out, action: 'block' } };
|
|
17953
|
+
if (p.action === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
|
|
17954
|
+
out.action = 'challenge';
|
|
17955
|
+
break;
|
|
17956
|
+
}
|
|
17957
|
+
}
|
|
17958
|
+
}
|
|
17959
|
+
if (rules.hours && rules.hours.mode === 'challenge' && Array.isArray(rules.hours.list) && typeof c.hour === 'number' && rules.hours.list.includes(c.hour)) out.action = 'challenge';
|
|
17960
|
+
if (rules.days && rules.days.mode === 'challenge' && Array.isArray(rules.days.list) && typeof c.day === 'number' && rules.days.list.includes(c.day)) out.action = 'challenge';
|
|
17961
|
+
if (rules.require_referer && !c.has_referer) out.action = 'challenge';
|
|
17962
|
+
if (['low', 'medium', 'high'].includes(rules.aggressiveness)) out.aggressiveness = rules.aggressiveness;
|
|
17963
|
+
if (Number.isFinite(Number(rules.clearance_ttl_ms))) out.clearance_ttl_ms = Number(rules.clearance_ttl_ms);
|
|
17964
|
+
}
|
|
17965
|
+
return { code: 1, data: out };
|
|
17966
|
+
} catch (err) {
|
|
17967
|
+
return { code: 1, data: { action: 'challenge', start_level: 0 } };
|
|
17968
|
+
}
|
|
17969
|
+
};
|
|
17970
|
+
|
|
17971
|
+
// Broker-only: the origin interstitial passes a captcha response token (from the
|
|
17972
|
+
// embedded widget) here. Resolves the profile by site_key, verifies and burns the
|
|
17973
|
+
// token, and returns the guarded target so the router can mint a clearance cookie.
|
|
17974
|
+
export const bp_consume_response = async function (req) {
|
|
17975
|
+
try {
|
|
17976
|
+
const { site_key, response } = req;
|
|
17977
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17978
|
+
if (!profile || profile.enabled !== true) return { code: 1, data: { ok: false, reason: 'unknown_site' } };
|
|
17979
|
+
const v = verify_response_token(profile.site_key, response);
|
|
17980
|
+
if (!v.ok) return { code: 1, data: { ok: false, reason: v.reason } };
|
|
17981
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17982
|
+
const d = _bp_defaults();
|
|
17983
|
+
return { code: 1, data: { ok: true, level: v.level, target: _bp_target_key(profile), clearance_ttl_ms: esc.clearance_ttl_ms || d.clearance_ttl_ms } };
|
|
17984
|
+
} catch (err) {
|
|
17985
|
+
return { code: 1, data: { ok: false, reason: 'error' } };
|
|
17986
|
+
}
|
|
17987
|
+
};
|
|
17988
|
+
|
|
17989
|
+
// Broker-only: the router caches the set of armed targets (refreshed periodically)
|
|
17990
|
+
// so the enforcement hot path is a local lookup and unguarded traffic never hits
|
|
17991
|
+
// the broker.
|
|
17992
|
+
export const bp_armed_targets = async function () {
|
|
17993
|
+
try {
|
|
17994
|
+
const q = await db_module.find_couch_query(BP_DB, { selector: { docType: 'bot_protection_profile', armed: true }, fields: ['_id', 'target', 'site_key'], limit: 2000 }, true, true);
|
|
17995
|
+
const rows = (q?.docs || []).map((doc) => ({ target_key: _bp_target_key(doc), site_key: doc.site_key })).filter((x) => x.target_key && x.site_key);
|
|
17996
|
+
return { code: 1, data: rows };
|
|
17997
|
+
} catch (err) {
|
|
17998
|
+
return { code: -1, data: err.message || String(err) };
|
|
17999
|
+
}
|
|
18000
|
+
};
|