@xuda.io/ai_module 1.1.5636 → 1.1.5637
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 +1091 -21
- 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,30 @@ 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
|
-
- NO build step or framework that needs compiling
|
|
1751
|
-
- SECURITY: work only inside this working directory
|
|
1819
|
+
- Use RELATIVE asset paths (./styles.css, ./assets/...), never absolute (/...), so the site serves correctly from a sub-path.
|
|
1820
|
+
${visuals_line}
|
|
1821
|
+
- NO build step or framework that needs compiling, plain static files that run as-is.
|
|
1822
|
+
- 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
1823
|
When done, briefly summarize what you built.`;
|
|
1753
1824
|
|
|
1754
1825
|
const built_prompt = `[System]\n${gen_system}\n\n[User request]\n${prompt}`;
|
|
@@ -2401,6 +2472,75 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
|
|
|
2401
2472
|
}
|
|
2402
2473
|
};
|
|
2403
2474
|
|
|
2475
|
+
// Pull a few landscape stock photos from Pexels for the site builder's "stock"
|
|
2476
|
+
// image mode. Needs _conf.pexels.api_key (merged from the on-box secrets file);
|
|
2477
|
+
// returns [] (build falls back to inline SVG visuals) when the key is missing or
|
|
2478
|
+
// the request fails.
|
|
2479
|
+
const _pexels_photo_search = async function (query, per_page, key) {
|
|
2480
|
+
try {
|
|
2481
|
+
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${per_page}&orientation=landscape`;
|
|
2482
|
+
const res = await fetch(url, { headers: { Authorization: key } });
|
|
2483
|
+
if (!res.ok) return [];
|
|
2484
|
+
const body = await res.json();
|
|
2485
|
+
return (body.photos || [])
|
|
2486
|
+
.map((p) => ({ url: (p.src && (p.src.large2x || p.src.large || p.src.original)) || '', alt: (p.alt || query || '').toString().slice(0, 120) }))
|
|
2487
|
+
.filter((p) => p.url);
|
|
2488
|
+
} catch (e) {
|
|
2489
|
+
return [];
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
|
|
2493
|
+
// Provision ready-to-use images into <dir>/assets for the site builder, per the
|
|
2494
|
+
// chosen image_mode. Returns [{ file, alt }] describing what was placed so the
|
|
2495
|
+
// generator prompt can reference them. Best-effort: any failure yields fewer (or
|
|
2496
|
+
// no) images and the build falls back to inline SVG/CSS. Called only on a fresh
|
|
2497
|
+
// build (never a refine). Credits for gen_* are billed inside create_image.
|
|
2498
|
+
const _provision_site_images = async function (uid, dir, prompt, image_mode) {
|
|
2499
|
+
const out = [];
|
|
2500
|
+
const N = 3;
|
|
2501
|
+
const assets_dir = `${dir}/assets`;
|
|
2502
|
+
try { fs.mkdirSync(assets_dir, { recursive: true }); } catch (e) {}
|
|
2503
|
+
|
|
2504
|
+
if (image_mode === 'stock') {
|
|
2505
|
+
const key = _conf.pexels?.api_key || _conf.PEXELS_API_KEY || process.env.PEXELS_API_KEY;
|
|
2506
|
+
if (!key) { console.error('[generate_site_draft] stock image_mode requested but no Pexels api key configured'); return out; }
|
|
2507
|
+
const photos = await _pexels_photo_search(prompt, N, key);
|
|
2508
|
+
for (let i = 0; i < photos.length; i++) {
|
|
2509
|
+
try {
|
|
2510
|
+
const r = await fetch(photos[i].url);
|
|
2511
|
+
if (!r.ok) continue;
|
|
2512
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
2513
|
+
const name = `stock${i + 1}.jpg`;
|
|
2514
|
+
fs.writeFileSync(`${assets_dir}/${name}`, buf);
|
|
2515
|
+
out.push({ file: `./assets/${name}`, alt: photos[i].alt });
|
|
2516
|
+
} catch (e) {}
|
|
2517
|
+
}
|
|
2518
|
+
return out;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
if (image_mode === 'gen_low' || image_mode === 'gen_high') {
|
|
2522
|
+
const model = image_mode === 'gen_high' ? 'img-2' : 'img-1';
|
|
2523
|
+
const quality = image_mode === 'gen_high' ? 'high' : 'medium';
|
|
2524
|
+
const api = await get_active_account_profile_info(uid).catch(() => null);
|
|
2525
|
+
const briefs = [
|
|
2526
|
+
{ name: 'hero.png', p: `A clean, modern hero image for a website about: ${prompt}. No text, no logos, no watermarks.` },
|
|
2527
|
+
{ name: 'feature.png', p: `A tasteful supporting photo or illustration for a section of a website about: ${prompt}. No text, no logos.` },
|
|
2528
|
+
{ name: 'accent.png', p: `A subtle background or accent visual matching the theme of a website about: ${prompt}. No text.` },
|
|
2529
|
+
].slice(0, N);
|
|
2530
|
+
for (const b of briefs) {
|
|
2531
|
+
try {
|
|
2532
|
+
const b64 = await create_image(uid, b.p, model, '1024x1024', 1, 1024, 1024, { source: 'site_build', image_mode }, api, quality);
|
|
2533
|
+
if (!b64) continue;
|
|
2534
|
+
fs.writeFileSync(`${assets_dir}/${b.name}`, Buffer.from(b64, 'base64'));
|
|
2535
|
+
out.push({ file: `./assets/${b.name}`, alt: b.p.replace(/\.\s*No text[\s\S]*$/i, '').slice(0, 120) });
|
|
2536
|
+
} catch (e) {}
|
|
2537
|
+
}
|
|
2538
|
+
return out;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
return out;
|
|
2542
|
+
};
|
|
2543
|
+
|
|
2404
2544
|
const get_studio_doc = async function (req) {
|
|
2405
2545
|
let { uid, _id } = req;
|
|
2406
2546
|
const account_profile_info = await get_active_account_profile_info(uid);
|
|
@@ -6189,7 +6329,7 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
6189
6329
|
// Hard credit gate (after the user's message is saved, before any model work):
|
|
6190
6330
|
// out of credits → throw; the catch streams a credit-specific message + the
|
|
6191
6331
|
// credit_limit flag the dashboard uses to open the top-up modal.
|
|
6192
|
-
const credit_err = await validate_credits_limit(uid, profile_id);
|
|
6332
|
+
const credit_err = await validate_credits_limit(uid, profile_id, req.ai_model, 'dashboard chat');
|
|
6193
6333
|
if (credit_err?.credit_limit_error) throw credit_err;
|
|
6194
6334
|
|
|
6195
6335
|
if (attachments?.length) {
|
|
@@ -6535,7 +6675,7 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
6535
6675
|
// listens for to open the Add-tokens modal.
|
|
6536
6676
|
emitToDashboard('stream_phase', 'Out of AI credits', { update: true });
|
|
6537
6677
|
emitToDashboard('response_start');
|
|
6538
|
-
streamText('You’re out of AI credits
|
|
6678
|
+
streamText('You’re out of AI credits. Top up to keep chatting.');
|
|
6539
6679
|
emitToDashboard('stream_end', undefined, { error: true, credit_limit: true });
|
|
6540
6680
|
try {
|
|
6541
6681
|
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
@@ -15056,15 +15196,21 @@ export const get_chat_suggestions = async function (req) {
|
|
|
15056
15196
|
}
|
|
15057
15197
|
};
|
|
15058
15198
|
|
|
15059
|
-
|
|
15060
|
-
|
|
15061
|
-
|
|
15062
|
-
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15199
|
+
// Thin adapter over account_module.evaluate_credit_gate, which enforces the full
|
|
15200
|
+
// credit-rules set (pool / profile / user / model / source soft+hard, model
|
|
15201
|
+
// allow/deny, freeze) and falls back to the legacy account-wide ledger cap when no
|
|
15202
|
+
// rules are set. The { credit_limit_error, account_id } contract is preserved so
|
|
15203
|
+
// every existing caller keeps working; .scope / .block_kind are added for callers
|
|
15204
|
+
// that want to show a rule-specific message. A soft-only breach returns undefined
|
|
15205
|
+
// (proceed) and the gate emits the in-app alert itself.
|
|
15206
|
+
export const validate_credits_limit = async function (uid, profile_id, model, source) {
|
|
15207
|
+
const gate = await account_ms.evaluate_credit_gate({ uid, profile_id, model, source });
|
|
15208
|
+
if (gate?.block) {
|
|
15209
|
+
let err = new Error(gate.reason || 'ai credits reach to hard limit');
|
|
15066
15210
|
err.credit_limit_error = true;
|
|
15067
|
-
err.account_id =
|
|
15211
|
+
err.account_id = gate.account_id;
|
|
15212
|
+
err.scope = gate.scope;
|
|
15213
|
+
err.block_kind = gate.block_kind;
|
|
15068
15214
|
return err;
|
|
15069
15215
|
}
|
|
15070
15216
|
};
|
|
@@ -16535,6 +16681,13 @@ export const get_contact_form_bootstrap = async function (req) {
|
|
|
16535
16681
|
} catch (err) {}
|
|
16536
16682
|
|
|
16537
16683
|
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16684
|
+
// Bot protection: surface the captcha site key when the owner requires the
|
|
16685
|
+
// "I'm not a robot" check on this profile's contact form.
|
|
16686
|
+
let captcha_site_key = '';
|
|
16687
|
+
try {
|
|
16688
|
+
const bp = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id));
|
|
16689
|
+
if (bp && bp.enabled === true && bp.require_on_contact_form === true && bp.site_key) captcha_site_key = bp.site_key;
|
|
16690
|
+
} catch (e) {}
|
|
16538
16691
|
return {
|
|
16539
16692
|
code: 1,
|
|
16540
16693
|
data: {
|
|
@@ -16543,6 +16696,7 @@ export const get_contact_form_bootstrap = async function (req) {
|
|
|
16543
16696
|
profile_name: doc.profile_name || owner_name || '',
|
|
16544
16697
|
avatar_url,
|
|
16545
16698
|
token: mint_contact_form_token(canonical_id),
|
|
16699
|
+
captcha_site_key,
|
|
16546
16700
|
},
|
|
16547
16701
|
};
|
|
16548
16702
|
} catch (err) {
|
|
@@ -16673,6 +16827,15 @@ export const contact_form_submit = async function (req, job_id, headers) {
|
|
|
16673
16827
|
if (!_contact_form_allow('ip', client_ip, 5, 10 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16674
16828
|
if (!_contact_form_allow('profile', canonical_id, 200, 24 * 60 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16675
16829
|
|
|
16830
|
+
// Bot protection: when the owner requires the "I'm not a robot" check on their
|
|
16831
|
+
// contact form, verify the captcha response (single-use) before accepting.
|
|
16832
|
+
let _bp_prof = null;
|
|
16833
|
+
try { _bp_prof = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id)); } catch (e) {}
|
|
16834
|
+
if (_bp_prof && _bp_prof.enabled === true && _bp_prof.require_on_contact_form === true) {
|
|
16835
|
+
const cr = await bp_consume_response({ site_key: _bp_prof.site_key, response: req.captcha_response });
|
|
16836
|
+
if (!cr || !cr.data || cr.data.ok !== true) return { code: -401, data: 'captcha_failed' };
|
|
16837
|
+
}
|
|
16838
|
+
|
|
16676
16839
|
const owner_uid = account_profile_info.uid;
|
|
16677
16840
|
const config = _sanitize_contact_form_config(ap_doc.contact_form_config);
|
|
16678
16841
|
|
|
@@ -16850,3 +17013,910 @@ const contact_ticket_conversation = async function (req, job_id, headers) {
|
|
|
16850
17013
|
return { code: -15, data: err.message };
|
|
16851
17014
|
}
|
|
16852
17015
|
};
|
|
17016
|
+
|
|
17017
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17018
|
+
// BOT PROTECTION ("I'm not a robot") - verification engine
|
|
17019
|
+
//
|
|
17020
|
+
// Escalation ladder: L0 checkbox + passive signals + proof of work -> L1 image
|
|
17021
|
+
// challenge (from-scratch SVG synthetic tiles) -> L2 AI check (score, then a
|
|
17022
|
+
// one-shot AI-generated challenge). Two surfaces consume it: the embeddable
|
|
17023
|
+
// widget (public site_key + server-side secret_key siteverify, reCAPTCHA shaped)
|
|
17024
|
+
// and the origin interstitial (router). This generalizes the contact-form
|
|
17025
|
+
// human-detection token above. Docs live in xuda_master so the router edge can
|
|
17026
|
+
// resolve them in every region. See docs/handoff_bot_protection_ishai.md.
|
|
17027
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17028
|
+
|
|
17029
|
+
const BP_DB = 'xuda_master';
|
|
17030
|
+
const _bp_conf = () => (_conf && _conf.bot_protection) || {};
|
|
17031
|
+
const _bp_defaults = () => ({
|
|
17032
|
+
pow_difficulty: 16,
|
|
17033
|
+
session_token_ttl_ms: 900000,
|
|
17034
|
+
response_token_ttl_ms: 120000,
|
|
17035
|
+
clearance_ttl_ms: 1800000,
|
|
17036
|
+
min_age_ms: 800,
|
|
17037
|
+
image_grid: 9,
|
|
17038
|
+
image_correct_min: 2,
|
|
17039
|
+
aggressiveness: 'medium',
|
|
17040
|
+
l1_score_low: 0.35,
|
|
17041
|
+
l2_score_low: 0.7,
|
|
17042
|
+
...(_bp_conf().defaults || {}),
|
|
17043
|
+
});
|
|
17044
|
+
|
|
17045
|
+
// Fleet-shared HMAC key, same derivation style as the contact-form key so the
|
|
17046
|
+
// router and this module sign and verify the same tokens without distributing a
|
|
17047
|
+
// new secret.
|
|
17048
|
+
const _bot_hmac_key = function () {
|
|
17049
|
+
const seed = String(_conf?.gmail?.clientSecret || _conf?.domain || 'xuda');
|
|
17050
|
+
return crypto.createHash('sha256').update(`xuda_bot_protection_v1:${seed}`).digest();
|
|
17051
|
+
};
|
|
17052
|
+
const _bp_sig = (input) => crypto.createHmac('sha256', _bot_hmac_key()).update(String(input)).digest('hex').slice(0, 32);
|
|
17053
|
+
const _bp_rand = (n) => crypto.randomBytes(n).toString('base64url');
|
|
17054
|
+
const _bp_sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
17055
|
+
const _bp_aggr = (name) => ({ low: 0, medium: 1, high: 2 }[name] ?? 1);
|
|
17056
|
+
const _bp_pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
17057
|
+
|
|
17058
|
+
// --- Clearance / session tokens ------------------------------------------
|
|
17059
|
+
// Format: xcl1.<scope>.<ts>.<ttl>.<sig>. The subject is bound through the
|
|
17060
|
+
// signature, not stored in the token. scope in { session, response, clearance }.
|
|
17061
|
+
export const mint_clearance_token = function (scope, subject, ttl_ms, ts = Date.now()) {
|
|
17062
|
+
const sig = _bp_sig(`${scope}.${subject}.${ts}.${ttl_ms}`);
|
|
17063
|
+
return `xcl1.${scope}.${ts}.${ttl_ms}.${sig}`;
|
|
17064
|
+
};
|
|
17065
|
+
export const verify_clearance_token = function (scope, subject, token, min_age_ms = 0) {
|
|
17066
|
+
const p = String(token || '').split('.');
|
|
17067
|
+
if (p.length !== 5 || p[0] !== 'xcl1' || p[1] !== scope) return { ok: false, reason: 'bad_token' };
|
|
17068
|
+
const ts = Number(p[2]);
|
|
17069
|
+
const ttl = Number(p[3]);
|
|
17070
|
+
if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
|
|
17071
|
+
const expected = _bp_sig(`${scope}.${subject}.${ts}.${ttl}`);
|
|
17072
|
+
let ok = false;
|
|
17073
|
+
try { ok = crypto.timingSafeEqual(Buffer.from(p[4]), Buffer.from(expected)); } catch (e) { ok = false; }
|
|
17074
|
+
if (!ok) return { ok: false, reason: 'bad_sig' };
|
|
17075
|
+
const age = Date.now() - ts;
|
|
17076
|
+
if (age > ttl) return { ok: false, reason: 'expired' };
|
|
17077
|
+
if (age < min_age_ms) return { ok: false, reason: 'too_fast' };
|
|
17078
|
+
return { ok: true, ts };
|
|
17079
|
+
};
|
|
17080
|
+
|
|
17081
|
+
// --- Response tokens (returned on PASS, verified by siteverify) -----------
|
|
17082
|
+
// Self-contained (survives a cross-process hop) and single-use (best-effort
|
|
17083
|
+
// per-process burn set, same posture as the contact-form throttles).
|
|
17084
|
+
const _bp_used_responses = new Map(); // jti -> expiry ts
|
|
17085
|
+
const _bp_burn = (jti, ttl) => {
|
|
17086
|
+
_bp_used_responses.set(jti, Date.now() + ttl);
|
|
17087
|
+
if (_bp_used_responses.size > 20000) {
|
|
17088
|
+
const now = Date.now();
|
|
17089
|
+
for (const [k, exp] of _bp_used_responses) if (exp < now) _bp_used_responses.delete(k);
|
|
17090
|
+
}
|
|
17091
|
+
};
|
|
17092
|
+
const _bp_is_burned = (jti) => {
|
|
17093
|
+
const exp = _bp_used_responses.get(jti);
|
|
17094
|
+
if (!exp) return false;
|
|
17095
|
+
if (exp < Date.now()) { _bp_used_responses.delete(jti); return false; }
|
|
17096
|
+
return true;
|
|
17097
|
+
};
|
|
17098
|
+
const mint_response_token = function (site_key, level, score, ttl_ms) {
|
|
17099
|
+
const ts = Date.now();
|
|
17100
|
+
const jti = _bp_rand(9);
|
|
17101
|
+
const score100 = Math.max(0, Math.min(100, Math.round((score || 0) * 100)));
|
|
17102
|
+
const sig = _bp_sig(`resp.${site_key}.${ts}.${ttl_ms}.${level}.${score100}.${jti}`);
|
|
17103
|
+
return `xrsp1.${ts}.${ttl_ms}.${level}.${score100}.${jti}.${sig}`;
|
|
17104
|
+
};
|
|
17105
|
+
const verify_response_token = function (site_key, token) {
|
|
17106
|
+
const p = String(token || '').split('.');
|
|
17107
|
+
if (p.length !== 7 || p[0] !== 'xrsp1') return { ok: false, reason: 'bad_token' };
|
|
17108
|
+
const [, ts_s, ttl_s, level_s, score_s, jti, sig] = p;
|
|
17109
|
+
const ts = Number(ts_s);
|
|
17110
|
+
const ttl = Number(ttl_s);
|
|
17111
|
+
if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
|
|
17112
|
+
const expected = _bp_sig(`resp.${site_key}.${ts}.${ttl}.${level_s}.${score_s}.${jti}`);
|
|
17113
|
+
let ok = false;
|
|
17114
|
+
try { ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } catch (e) { ok = false; }
|
|
17115
|
+
if (!ok) return { ok: false, reason: 'bad_sig' };
|
|
17116
|
+
if (Date.now() - ts > ttl) return { ok: false, reason: 'expired' };
|
|
17117
|
+
if (_bp_is_burned(jti)) return { ok: false, reason: 'replayed' };
|
|
17118
|
+
_bp_burn(jti, ttl);
|
|
17119
|
+
return { ok: true, level: Number(level_s), score: Number(score_s) / 100 };
|
|
17120
|
+
};
|
|
17121
|
+
|
|
17122
|
+
// --- Data model (docs in xuda_master) ------------------------------------
|
|
17123
|
+
const _bp_profile_id = (uid, slug) => `bot_protection:${uid}.${slug}`;
|
|
17124
|
+
const _bp_target_key = (doc) => (doc && doc.target && doc.target.id ? String(doc.target.id) : '');
|
|
17125
|
+
const _bp_index_id = {
|
|
17126
|
+
key: (k) => `bpk::${k}`,
|
|
17127
|
+
secret: (s) => `bps::${_bp_sha(s)}`,
|
|
17128
|
+
target: (t) => `bpt::${t}`,
|
|
17129
|
+
};
|
|
17130
|
+
const _bp_load = async (profile_id) => {
|
|
17131
|
+
const r = await db_module.get_couch_doc(BP_DB, profile_id, true);
|
|
17132
|
+
return r && r.code > 0 && r.data && r.data._id ? r.data : null;
|
|
17133
|
+
};
|
|
17134
|
+
const _bp_load_index = async (id) => {
|
|
17135
|
+
const r = await db_module.get_couch_doc(BP_DB, id, true);
|
|
17136
|
+
return r && r.code > 0 && r.data && r.data.profile_id ? r.data : null;
|
|
17137
|
+
};
|
|
17138
|
+
const _bp_write_index = async (id, profile_id) => {
|
|
17139
|
+
const existing = await _bp_load_index(id);
|
|
17140
|
+
const doc = existing
|
|
17141
|
+
? { ...existing, profile_id, ts: Date.now() }
|
|
17142
|
+
: { _id: id, docType: 'bot_protection_index', profile_id, ts: Date.now() };
|
|
17143
|
+
await db_module.save_couch_doc(BP_DB, doc);
|
|
17144
|
+
};
|
|
17145
|
+
const _bp_delete_index = async (id) => {
|
|
17146
|
+
const r = await db_module.get_couch_doc(BP_DB, id, true);
|
|
17147
|
+
if (r && r.code > 0 && r.data && r.data._rev) {
|
|
17148
|
+
try { await db_module.delete_couch_doc(BP_DB, id, r.data._rev); } catch (e) {}
|
|
17149
|
+
}
|
|
17150
|
+
};
|
|
17151
|
+
const _bp_resolve_by_site_key = async (site_key) => {
|
|
17152
|
+
const idx = await _bp_load_index(_bp_index_id.key(site_key));
|
|
17153
|
+
return idx ? await _bp_load(idx.profile_id) : null;
|
|
17154
|
+
};
|
|
17155
|
+
const _bp_resolve_by_secret = async (secret) => {
|
|
17156
|
+
const idx = await _bp_load_index(_bp_index_id.secret(secret));
|
|
17157
|
+
return idx ? await _bp_load(idx.profile_id) : null;
|
|
17158
|
+
};
|
|
17159
|
+
const _bp_new_keys = () => ({ site_key: _bp_rand(18), secret_key: _bp_rand(32) });
|
|
17160
|
+
|
|
17161
|
+
const _bp_default_escalation = () => {
|
|
17162
|
+
const d = _bp_defaults();
|
|
17163
|
+
return {
|
|
17164
|
+
pow_difficulty: d.pow_difficulty,
|
|
17165
|
+
aggressiveness: d.aggressiveness,
|
|
17166
|
+
clearance_ttl_ms: d.clearance_ttl_ms,
|
|
17167
|
+
response_token_ttl_ms: d.response_token_ttl_ms,
|
|
17168
|
+
session_token_ttl_ms: d.session_token_ttl_ms,
|
|
17169
|
+
l1_enabled: true,
|
|
17170
|
+
l2_enabled: true,
|
|
17171
|
+
};
|
|
17172
|
+
};
|
|
17173
|
+
const _bp_ensure_profile = async (uid, slug, seed = {}) => {
|
|
17174
|
+
const id = _bp_profile_id(uid, slug);
|
|
17175
|
+
let doc = await _bp_load(id);
|
|
17176
|
+
if (!doc) {
|
|
17177
|
+
const d = Date.now();
|
|
17178
|
+
doc = {
|
|
17179
|
+
_id: id,
|
|
17180
|
+
docType: 'bot_protection_profile',
|
|
17181
|
+
owner_uid: uid,
|
|
17182
|
+
enabled: false,
|
|
17183
|
+
surface: seed.surface || 'widget',
|
|
17184
|
+
plan_tier: seed.plan_tier || 'standard',
|
|
17185
|
+
site_key: '',
|
|
17186
|
+
secret_key: '',
|
|
17187
|
+
target: seed.target || null,
|
|
17188
|
+
escalation: { start_level: 0, ..._bp_default_escalation() },
|
|
17189
|
+
custom_rules: {},
|
|
17190
|
+
require_on_contact_form: false,
|
|
17191
|
+
armed: false,
|
|
17192
|
+
stats: { served: 0, passed: 0, challenged: 0, blocked: 0 },
|
|
17193
|
+
date_created_ts: d,
|
|
17194
|
+
ts: d,
|
|
17195
|
+
};
|
|
17196
|
+
}
|
|
17197
|
+
return doc;
|
|
17198
|
+
};
|
|
17199
|
+
const _bp_save = async (doc) => {
|
|
17200
|
+
doc.ts = Date.now();
|
|
17201
|
+
const ret = await db_module.save_couch_doc(BP_DB, doc);
|
|
17202
|
+
if (doc.site_key) await _bp_write_index(_bp_index_id.key(doc.site_key), doc._id);
|
|
17203
|
+
if (doc.secret_key) await _bp_write_index(_bp_index_id.secret(doc.secret_key), doc._id);
|
|
17204
|
+
const tkey = _bp_target_key(doc);
|
|
17205
|
+
if (tkey) await _bp_write_index(_bp_index_id.target(tkey), doc._id);
|
|
17206
|
+
return ret;
|
|
17207
|
+
};
|
|
17208
|
+
|
|
17209
|
+
// --- Stats (in-process buffer, persisted opportunistically) ---------------
|
|
17210
|
+
const _bp_stat_buf = new Map();
|
|
17211
|
+
const _bp_bump_stat = (profile_id, field, n = 1) => {
|
|
17212
|
+
const b = _bp_stat_buf.get(profile_id) || { served: 0, passed: 0, challenged: 0, blocked: 0 };
|
|
17213
|
+
b[field] = (b[field] || 0) + n;
|
|
17214
|
+
_bp_stat_buf.set(profile_id, b);
|
|
17215
|
+
};
|
|
17216
|
+
const _bp_merged_stats = (doc) => {
|
|
17217
|
+
const base = doc.stats || { served: 0, passed: 0, challenged: 0, blocked: 0 };
|
|
17218
|
+
const b = _bp_stat_buf.get(doc._id) || {};
|
|
17219
|
+
return {
|
|
17220
|
+
served: (base.served || 0) + (b.served || 0),
|
|
17221
|
+
passed: (base.passed || 0) + (b.passed || 0),
|
|
17222
|
+
challenged: (base.challenged || 0) + (b.challenged || 0),
|
|
17223
|
+
blocked: (base.blocked || 0) + (b.blocked || 0),
|
|
17224
|
+
};
|
|
17225
|
+
};
|
|
17226
|
+
const _bp_flush_stats = (doc) => {
|
|
17227
|
+
if (_bp_stat_buf.has(doc._id)) {
|
|
17228
|
+
doc.stats = _bp_merged_stats(doc);
|
|
17229
|
+
_bp_stat_buf.delete(doc._id);
|
|
17230
|
+
}
|
|
17231
|
+
return doc;
|
|
17232
|
+
};
|
|
17233
|
+
|
|
17234
|
+
// --- Rate limiter (per-process, flood-stopping) --------------------------
|
|
17235
|
+
const _bp_hits = { ip: {}, key: {} };
|
|
17236
|
+
const _bp_allow = function (bucket, key, max, window_ms) {
|
|
17237
|
+
const now = Date.now();
|
|
17238
|
+
const store = _bp_hits[bucket];
|
|
17239
|
+
store[key] = (store[key] || []).filter((t) => now - t < window_ms);
|
|
17240
|
+
if (store[key].length >= max) return false;
|
|
17241
|
+
store[key].push(now);
|
|
17242
|
+
if (Object.keys(store).length > 5000) {
|
|
17243
|
+
for (const k of Object.keys(store)) {
|
|
17244
|
+
if (!store[k].length || now - store[k][store[k].length - 1] > window_ms) delete store[k];
|
|
17245
|
+
}
|
|
17246
|
+
}
|
|
17247
|
+
return true;
|
|
17248
|
+
};
|
|
17249
|
+
|
|
17250
|
+
// --- Proof of work -------------------------------------------------------
|
|
17251
|
+
const _leading_zero_bits = (hex) => {
|
|
17252
|
+
let bits = 0;
|
|
17253
|
+
for (const ch of hex) {
|
|
17254
|
+
const v = parseInt(ch, 16);
|
|
17255
|
+
if (v === 0) { bits += 4; continue; }
|
|
17256
|
+
if (v < 2) bits += 3;
|
|
17257
|
+
else if (v < 4) bits += 2;
|
|
17258
|
+
else if (v < 8) bits += 1;
|
|
17259
|
+
break;
|
|
17260
|
+
}
|
|
17261
|
+
return bits;
|
|
17262
|
+
};
|
|
17263
|
+
const _pow_prefix = (session_token) => _bp_sig(`pow.${session_token}`).slice(0, 16);
|
|
17264
|
+
const pow_make = (session_token, difficulty) => ({ prefix: _pow_prefix(session_token), difficulty });
|
|
17265
|
+
const pow_check = (session_token, difficulty, nonce) => {
|
|
17266
|
+
if (nonce == null) return false;
|
|
17267
|
+
const h = crypto.createHash('sha256').update(`${_pow_prefix(session_token)}.${nonce}`).digest('hex');
|
|
17268
|
+
return _leading_zero_bits(h) >= difficulty;
|
|
17269
|
+
};
|
|
17270
|
+
const _bp_pow_difficulty = (profile) => {
|
|
17271
|
+
const d = _bp_defaults();
|
|
17272
|
+
const base = Number(profile?.escalation?.pow_difficulty || d.pow_difficulty);
|
|
17273
|
+
const bump = _bp_aggr(profile?.escalation?.aggressiveness || d.aggressiveness);
|
|
17274
|
+
return Math.max(8, base + bump);
|
|
17275
|
+
};
|
|
17276
|
+
|
|
17277
|
+
// --- L0 signal scoring ---------------------------------------------------
|
|
17278
|
+
const _bp_client_ip = (req = {}, headers = {}) => {
|
|
17279
|
+
const h = headers || {};
|
|
17280
|
+
const xff = h['x-forwarded-for'] || h['X-Forwarded-For'] || req.remoteip || req.ip || '';
|
|
17281
|
+
return String(xff).split(',')[0].trim();
|
|
17282
|
+
};
|
|
17283
|
+
const _bp_safe_signals = (s = {}) => {
|
|
17284
|
+
const o = {};
|
|
17285
|
+
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']) {
|
|
17286
|
+
if (s[k] !== undefined) o[k] = typeof s[k] === 'string' ? String(s[k]).slice(0, 120) : s[k];
|
|
17287
|
+
}
|
|
17288
|
+
return o;
|
|
17289
|
+
};
|
|
17290
|
+
const _bp_score_signals = (signals = {}) => {
|
|
17291
|
+
const s = signals || {};
|
|
17292
|
+
let score = 0.5;
|
|
17293
|
+
const reasons = [];
|
|
17294
|
+
const inter = Number(s.pointer_events || 0) + Number(s.key_events || 0) + Number(s.scroll_events || 0);
|
|
17295
|
+
if (inter >= 3) score += 0.25;
|
|
17296
|
+
else if (inter === 0) { score -= 0.2; reasons.push('no_interaction'); }
|
|
17297
|
+
if (s.has_touch === true || s.pointer_moved === true) score += 0.05;
|
|
17298
|
+
if (s.webgl && s.canvas) score += 0.1;
|
|
17299
|
+
else { score -= 0.1; reasons.push('no_env_hash'); }
|
|
17300
|
+
if (s.hardware_concurrency && Number(s.hardware_concurrency) > 0) score += 0.03;
|
|
17301
|
+
if (s.languages && String(s.languages).length) score += 0.03;
|
|
17302
|
+
else reasons.push('no_lang');
|
|
17303
|
+
if (s.timezone) score += 0.02;
|
|
17304
|
+
if (s.webdriver === true) { score -= 0.5; reasons.push('webdriver'); }
|
|
17305
|
+
if (s.headless === true) { score -= 0.4; reasons.push('headless'); }
|
|
17306
|
+
const dwell = Number(s.dwell_ms || 0);
|
|
17307
|
+
if (dwell >= 400) score += 0.05;
|
|
17308
|
+
else if (dwell > 0 && dwell < 120) { score -= 0.1; reasons.push('instant_click'); }
|
|
17309
|
+
score = Math.max(0, Math.min(1, score));
|
|
17310
|
+
return { score, reasons };
|
|
17311
|
+
};
|
|
17312
|
+
|
|
17313
|
+
// --- L1 image challenge (from-scratch synthetic SVG tiles) ----------------
|
|
17314
|
+
const _BP_SHAPES = ['circle', 'square', 'triangle', 'star', 'hexagon'];
|
|
17315
|
+
const _bp_color = () => _bp_pick(['#e11d48', '#2563eb', '#16a34a', '#eab308', '#7c3aed', '#ea580c', '#0891b2', '#db2777']);
|
|
17316
|
+
const _bp_shape_svg = (kind, color) => {
|
|
17317
|
+
switch (kind) {
|
|
17318
|
+
case 'circle': return `<circle cx="50" cy="50" r="30" fill="${color}"/>`;
|
|
17319
|
+
case 'square': return `<rect x="22" y="22" width="56" height="56" rx="6" fill="${color}"/>`;
|
|
17320
|
+
case 'triangle': return `<polygon points="50,18 82,80 18,80" fill="${color}"/>`;
|
|
17321
|
+
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}"/>`;
|
|
17322
|
+
case 'hexagon': return `<polygon points="50,16 82,33 82,67 50,84 18,67 18,33" fill="${color}"/>`;
|
|
17323
|
+
default: return '';
|
|
17324
|
+
}
|
|
17325
|
+
};
|
|
17326
|
+
const _bp_tile = (kind) => {
|
|
17327
|
+
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>`;
|
|
17328
|
+
return 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
|
|
17329
|
+
};
|
|
17330
|
+
const _image_issue = (site_key, d) => {
|
|
17331
|
+
const grid = d.image_grid || 9;
|
|
17332
|
+
const target = _bp_pick(_BP_SHAPES);
|
|
17333
|
+
const others = _BP_SHAPES.filter((x) => x !== target);
|
|
17334
|
+
const min_c = Math.max(1, d.image_correct_min || 2);
|
|
17335
|
+
const max_c = Math.max(min_c, grid - 2);
|
|
17336
|
+
const correct_count = min_c + Math.floor(Math.random() * (max_c - min_c + 1));
|
|
17337
|
+
const order = [...Array(grid).keys()];
|
|
17338
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
17339
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
17340
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
17341
|
+
}
|
|
17342
|
+
const correct = new Set(order.slice(0, correct_count));
|
|
17343
|
+
const tiles = [];
|
|
17344
|
+
const answer = [];
|
|
17345
|
+
for (let i = 0; i < grid; i++) {
|
|
17346
|
+
if (correct.has(i)) { tiles.push(_bp_tile(target)); answer.push(i); }
|
|
17347
|
+
else tiles.push(_bp_tile(_bp_pick(others)));
|
|
17348
|
+
}
|
|
17349
|
+
answer.sort((a, b) => a - b);
|
|
17350
|
+
return {
|
|
17351
|
+
id: _bp_rand(9), kind: 'image', level: 1, site_key, ts: Date.now(), ttl: 120000,
|
|
17352
|
+
grid, tiles, prompt: `Select every image that shows a ${target}`,
|
|
17353
|
+
answer_hash: _bp_sha(answer.join(',')), pass_score: 0.85,
|
|
17354
|
+
};
|
|
17355
|
+
};
|
|
17356
|
+
const _image_grade = (ch, answer) => {
|
|
17357
|
+
const raw = Array.isArray(answer) ? answer : answer && Array.isArray(answer.selection) ? answer.selection : [];
|
|
17358
|
+
const norm = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < ch.grid))].sort((a, b) => a - b).join(',');
|
|
17359
|
+
return _bp_sha(norm) === ch.answer_hash;
|
|
17360
|
+
};
|
|
17361
|
+
|
|
17362
|
+
// --- L2 AI check (score, then a one-shot AI challenge) --------------------
|
|
17363
|
+
const _bp_ip_reputation = async (ip) => {
|
|
17364
|
+
const s = String(ip || '');
|
|
17365
|
+
const is_private = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd)/.test(s);
|
|
17366
|
+
return { ip: s, private: is_private };
|
|
17367
|
+
};
|
|
17368
|
+
const _RiskSchema = z.object({ score: z.number(), verdict: z.enum(['human', 'bot', 'borderline']), reason: z.string() });
|
|
17369
|
+
const _risk_score = async (profile, signals, ip) => {
|
|
17370
|
+
try {
|
|
17371
|
+
const rep = await _bp_ip_reputation(ip);
|
|
17372
|
+
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)}`;
|
|
17373
|
+
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 } });
|
|
17374
|
+
if (r.code === 5 && r.data) {
|
|
17375
|
+
const o = JSON.parse(r.data);
|
|
17376
|
+
return { score: Number(o.score) || 0, verdict: o.verdict || 'borderline', reason: o.reason || '' };
|
|
17377
|
+
}
|
|
17378
|
+
} catch (e) {}
|
|
17379
|
+
return { score: 0.5, verdict: 'borderline', reason: 'scorer_unavailable' };
|
|
17380
|
+
};
|
|
17381
|
+
const _bp_norm_answer = (s) => String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
17382
|
+
const _DynSchema = z.object({ question: z.string(), answer: z.string() });
|
|
17383
|
+
const _dynamic_issue = async (site_key, owner_uid) => {
|
|
17384
|
+
try {
|
|
17385
|
+
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).';
|
|
17386
|
+
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' } });
|
|
17387
|
+
if (r.code === 5 && r.data) {
|
|
17388
|
+
const o = JSON.parse(r.data);
|
|
17389
|
+
if (o.question && o.answer) {
|
|
17390
|
+
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 };
|
|
17391
|
+
}
|
|
17392
|
+
}
|
|
17393
|
+
} catch (e) {}
|
|
17394
|
+
return null;
|
|
17395
|
+
};
|
|
17396
|
+
const _dynamic_grade = async (ch, answer) => {
|
|
17397
|
+
const a = typeof answer === 'string' ? answer : (answer && (answer.text || answer.answer)) || '';
|
|
17398
|
+
return _bp_sha(_bp_norm_answer(a)) === ch.answer_hash;
|
|
17399
|
+
};
|
|
17400
|
+
|
|
17401
|
+
// --- Escalation orchestrator ---------------------------------------------
|
|
17402
|
+
const _bp_challenges = new Map(); // challenge_id -> { kind, level, site_key, ts, ttl, answer_hash, ... }
|
|
17403
|
+
const _bp_escalate = async (profile, site_key, ip, signals, level, esc, d) => {
|
|
17404
|
+
if (level <= 1 && esc.l1_enabled !== false) {
|
|
17405
|
+
const ch = _image_issue(site_key, d);
|
|
17406
|
+
_bp_challenges.set(ch.id, ch);
|
|
17407
|
+
_bp_bump_stat(profile._id, 'challenged');
|
|
17408
|
+
return { code: 1, data: { status: 'challenge', level: 1, challenge: { id: ch.id, type: 'image', prompt: ch.prompt, tiles: ch.tiles, grid: ch.grid } } };
|
|
17409
|
+
}
|
|
17410
|
+
if (level <= 2 && esc.l2_enabled !== false) {
|
|
17411
|
+
const verdict = await _risk_score(profile, signals, ip);
|
|
17412
|
+
if (verdict.verdict === 'human' && verdict.score >= (d.l2_score_low || 0.7)) {
|
|
17413
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17414
|
+
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) } };
|
|
17415
|
+
}
|
|
17416
|
+
if (verdict.verdict === 'bot') {
|
|
17417
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17418
|
+
return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_bot' } };
|
|
17419
|
+
}
|
|
17420
|
+
const ch = await _dynamic_issue(site_key, profile.owner_uid);
|
|
17421
|
+
if (ch) {
|
|
17422
|
+
_bp_challenges.set(ch.id, ch);
|
|
17423
|
+
_bp_bump_stat(profile._id, 'challenged');
|
|
17424
|
+
return { code: 1, data: { status: 'challenge', level: 2, challenge: { id: ch.id, type: 'ai', prompt: ch.prompt } } };
|
|
17425
|
+
}
|
|
17426
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17427
|
+
return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_uncertain' } };
|
|
17428
|
+
}
|
|
17429
|
+
_bp_bump_stat(profile._id, 'blocked');
|
|
17430
|
+
return { code: 1, data: { status: 'fail', level, reason: 'exhausted' } };
|
|
17431
|
+
};
|
|
17432
|
+
|
|
17433
|
+
// --- Bootstrap (broker-only; called by the router iframe + interstitial) --
|
|
17434
|
+
export const get_captcha_bootstrap = async function (req) {
|
|
17435
|
+
try {
|
|
17436
|
+
const site_key = req.site_key;
|
|
17437
|
+
const ip = _bp_client_ip(req, req.headers);
|
|
17438
|
+
if (!site_key) return { code: -404, data: 'no_site_key' };
|
|
17439
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17440
|
+
if (!profile || profile.enabled !== true) return { code: -404, data: 'not_enabled' };
|
|
17441
|
+
const d = _bp_defaults();
|
|
17442
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17443
|
+
const session_token = mint_clearance_token('session', `${site_key}|${ip}`, esc.session_token_ttl_ms || d.session_token_ttl_ms);
|
|
17444
|
+
_bp_bump_stat(profile._id, 'served');
|
|
17445
|
+
return {
|
|
17446
|
+
code: 1,
|
|
17447
|
+
data: {
|
|
17448
|
+
site_key,
|
|
17449
|
+
session_token,
|
|
17450
|
+
pow: pow_make(session_token, _bp_pow_difficulty(profile)),
|
|
17451
|
+
min_age_ms: d.min_age_ms,
|
|
17452
|
+
config: {
|
|
17453
|
+
brand: profile.brand || 'Xuda',
|
|
17454
|
+
theme: req.theme || profile.theme || 'auto',
|
|
17455
|
+
l1_enabled: esc.l1_enabled !== false,
|
|
17456
|
+
l2_enabled: esc.l2_enabled !== false,
|
|
17457
|
+
},
|
|
17458
|
+
},
|
|
17459
|
+
};
|
|
17460
|
+
} catch (err) {
|
|
17461
|
+
return { code: -404, data: err.message || String(err) };
|
|
17462
|
+
}
|
|
17463
|
+
};
|
|
17464
|
+
|
|
17465
|
+
// --- Public: the escalation driver ---------------------------------------
|
|
17466
|
+
export const captcha_verify = async function (req, job_id, headers) {
|
|
17467
|
+
try {
|
|
17468
|
+
const { site_key, session_token, signals = {}, pow_nonce, challenge_id, answer } = req;
|
|
17469
|
+
const ip = _bp_client_ip(req, headers);
|
|
17470
|
+
if (!site_key || !session_token) return { code: 1, data: { status: 'fail', reason: 'bad_request' } };
|
|
17471
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17472
|
+
if (!profile || profile.enabled !== true) return { code: 1, data: { status: 'fail', reason: 'not_enabled' } };
|
|
17473
|
+
const d = _bp_defaults();
|
|
17474
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17475
|
+
|
|
17476
|
+
if (!_bp_allow('ip', `${site_key}|${ip}`, 60, 60000) || !_bp_allow('key', site_key, 600, 60000)) {
|
|
17477
|
+
return { code: 1, data: { status: 'fail', reason: 'rate_limited' } };
|
|
17478
|
+
}
|
|
17479
|
+
const st = verify_clearance_token('session', `${site_key}|${ip}`, session_token);
|
|
17480
|
+
if (!st.ok) return { code: 1, data: { status: 'fail', reason: `session_${st.reason}` } };
|
|
17481
|
+
|
|
17482
|
+
// Answering an issued image (L1) or AI (L2) challenge.
|
|
17483
|
+
if (challenge_id) {
|
|
17484
|
+
const ch = _bp_challenges.get(challenge_id);
|
|
17485
|
+
if (!ch || ch.site_key !== site_key || Date.now() - ch.ts > (ch.ttl || 120000)) {
|
|
17486
|
+
if (ch) _bp_challenges.delete(challenge_id);
|
|
17487
|
+
return { code: 1, data: { status: 'fail', reason: 'challenge_expired' } };
|
|
17488
|
+
}
|
|
17489
|
+
let ok = false;
|
|
17490
|
+
if (ch.kind === 'image') ok = _image_grade(ch, answer);
|
|
17491
|
+
else if (ch.kind === 'ai') ok = await _dynamic_grade(ch, answer);
|
|
17492
|
+
_bp_challenges.delete(challenge_id);
|
|
17493
|
+
if (ok) {
|
|
17494
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17495
|
+
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) } };
|
|
17496
|
+
}
|
|
17497
|
+
return await _bp_escalate(profile, site_key, ip, signals, ch.level + 1, esc, d);
|
|
17498
|
+
}
|
|
17499
|
+
|
|
17500
|
+
// Level 0: honeypot + min-age + proof of work + passive signals.
|
|
17501
|
+
if (signals && typeof signals.honeypot === 'string' && signals.honeypot.trim() !== '') {
|
|
17502
|
+
return { code: 1, data: { status: 'fail', reason: 'ok' } };
|
|
17503
|
+
}
|
|
17504
|
+
if (!pow_check(session_token, _bp_pow_difficulty(profile), pow_nonce)) {
|
|
17505
|
+
return { code: 1, data: { status: 'fail', reason: 'pow' } };
|
|
17506
|
+
}
|
|
17507
|
+
const min_age_ok = Date.now() - st.ts >= (d.min_age_ms || 800);
|
|
17508
|
+
const { score, reasons } = _bp_score_signals(signals);
|
|
17509
|
+
const aggr = _bp_aggr(esc.aggressiveness || d.aggressiveness);
|
|
17510
|
+
if (reasons.includes('webdriver') && aggr >= 2) return { code: 1, data: { status: 'fail', reason: 'automation' } };
|
|
17511
|
+
const pass_threshold = 0.6 + aggr * 0.1;
|
|
17512
|
+
if (min_age_ok && score >= pass_threshold) {
|
|
17513
|
+
_bp_bump_stat(profile._id, 'passed');
|
|
17514
|
+
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) } };
|
|
17515
|
+
}
|
|
17516
|
+
return await _bp_escalate(profile, site_key, ip, signals, 1, esc, d);
|
|
17517
|
+
} catch (err) {
|
|
17518
|
+
return { code: 1, data: { status: 'fail', reason: 'error' } };
|
|
17519
|
+
}
|
|
17520
|
+
};
|
|
17521
|
+
|
|
17522
|
+
// --- Public: server-to-server verification (reCAPTCHA / Turnstile shaped) --
|
|
17523
|
+
export const captcha_siteverify = async function (req) {
|
|
17524
|
+
try {
|
|
17525
|
+
const { secret, response } = req;
|
|
17526
|
+
if (!secret || !response) return { code: 1, data: { success: false, 'error-codes': ['missing-input'] } };
|
|
17527
|
+
const profile = await _bp_resolve_by_secret(secret);
|
|
17528
|
+
if (!profile) return { code: 1, data: { success: false, 'error-codes': ['invalid-input-secret'] } };
|
|
17529
|
+
const v = verify_response_token(profile.site_key, response);
|
|
17530
|
+
if (!v.ok) return { code: 1, data: { success: false, 'error-codes': [v.reason === 'replayed' ? 'timeout-or-duplicate' : 'invalid-input-response'] } };
|
|
17531
|
+
return { code: 1, data: { success: true, score: v.score, level_reached: v.level, hostname: profile.target?.label || '', challenge_ts: Date.now(), 'error-codes': [] } };
|
|
17532
|
+
} catch (err) {
|
|
17533
|
+
return { code: 1, data: { success: false, 'error-codes': ['internal-error'] } };
|
|
17534
|
+
}
|
|
17535
|
+
};
|
|
17536
|
+
|
|
17537
|
+
// --- Entitlement (member-first; the Stripe attach lands in stripe_module) --
|
|
17538
|
+
// Entitlement gate. Standard service is FREE for paid members; non-members pay
|
|
17539
|
+
// $1/mo. Custom rules are a $5/mo add-on for EVERYONE (members included). Both
|
|
17540
|
+
// tiers share the consolidated-subscription category 'bot_protection' (one line
|
|
17541
|
+
// item, price-swapped between $1 and $5, exactly like ai_workspace tiers), so a
|
|
17542
|
+
// custom subscriber is never double-billed the standard price. On first use we
|
|
17543
|
+
// attach/swap the item via stripe_module; it returns -402 gracefully until the
|
|
17544
|
+
// live Stripe prices replace the config placeholders, so nothing hard-fails.
|
|
17545
|
+
const _bp_ensure_entitled = async (uid, tier) => {
|
|
17546
|
+
try {
|
|
17547
|
+
const r = await db_module.get_couch_doc('xuda_accounts', uid, true);
|
|
17548
|
+
const acct = r && r.code > 0 && r.data ? r.data : {};
|
|
17549
|
+
const is_member = !!(acct.membership_plan && acct.membership_plan !== 'free');
|
|
17550
|
+
const has_item = !!acct?.stripe_subscription_items?.bot_protection;
|
|
17551
|
+
if (tier !== 'custom') {
|
|
17552
|
+
if (is_member) return { ok: true, member: true };
|
|
17553
|
+
if (has_item) return { ok: true };
|
|
17554
|
+
}
|
|
17555
|
+
// Not entitled yet: attach the add-on (or swap the existing item to the
|
|
17556
|
+
// custom price) on the account's consolidated subscription.
|
|
17557
|
+
const plan_id = tier === 'custom' ? 'bot_protection_custom' : 'bot_protection';
|
|
17558
|
+
let attach = null;
|
|
17559
|
+
try { attach = await stripe_ms.add_subscription_item({ uid, plan_id }); } catch (e) {}
|
|
17560
|
+
if (attach && attach.code === 1) return { ok: true };
|
|
17561
|
+
return { ok: false, tier, price: tier === 'custom' ? 5 : 1, reason: attach && attach.data };
|
|
17562
|
+
} catch (e) {
|
|
17563
|
+
return { ok: false, tier, price: tier === 'custom' ? 5 : 1 };
|
|
17564
|
+
}
|
|
17565
|
+
};
|
|
17566
|
+
|
|
17567
|
+
// --- Config sanitizers ----------------------------------------------------
|
|
17568
|
+
const _bp_sanitize_escalation = (e) => {
|
|
17569
|
+
const out = {};
|
|
17570
|
+
if (['low', 'medium', 'high'].includes(e.aggressiveness)) out.aggressiveness = e.aggressiveness;
|
|
17571
|
+
if (Number.isFinite(Number(e.pow_difficulty))) out.pow_difficulty = Math.max(8, Math.min(24, Math.round(Number(e.pow_difficulty))));
|
|
17572
|
+
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))));
|
|
17573
|
+
if (typeof e.l1_enabled === 'boolean') out.l1_enabled = e.l1_enabled;
|
|
17574
|
+
if (typeof e.l2_enabled === 'boolean') out.l2_enabled = e.l2_enabled;
|
|
17575
|
+
return out;
|
|
17576
|
+
};
|
|
17577
|
+
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) : []);
|
|
17578
|
+
export const bp_sanitize_rules = function (rules = {}) {
|
|
17579
|
+
const r = rules || {};
|
|
17580
|
+
const out = {};
|
|
17581
|
+
if (r.geo && typeof r.geo === 'object') {
|
|
17582
|
+
out.geo = {
|
|
17583
|
+
mode: ['off', 'challenge', 'block', 'allow'].includes(r.geo.mode) ? r.geo.mode : 'off',
|
|
17584
|
+
countries: _bp_str_arr(r.geo.countries),
|
|
17585
|
+
regions: _bp_str_arr(r.geo.regions),
|
|
17586
|
+
};
|
|
17587
|
+
}
|
|
17588
|
+
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);
|
|
17589
|
+
if (win(r.hours)) out.hours = win(r.hours);
|
|
17590
|
+
if (win(r.days)) out.days = win(r.days);
|
|
17591
|
+
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);
|
|
17592
|
+
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' };
|
|
17593
|
+
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 };
|
|
17594
|
+
out.allow_list = _bp_str_arr(r.allow_list);
|
|
17595
|
+
out.block_list = _bp_str_arr(r.block_list);
|
|
17596
|
+
if (typeof r.require_referer === 'boolean') out.require_referer = r.require_referer;
|
|
17597
|
+
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);
|
|
17598
|
+
if (['low', 'medium', 'high'].includes(r.aggressiveness)) out.aggressiveness = r.aggressiveness;
|
|
17599
|
+
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))));
|
|
17600
|
+
return out;
|
|
17601
|
+
};
|
|
17602
|
+
|
|
17603
|
+
// --- Owner settings (profile setup Bot Protection tab) -------------------
|
|
17604
|
+
const _build_captcha_snippet = (site_key, config = {}) => {
|
|
17605
|
+
const origin = embed_origin();
|
|
17606
|
+
const theme = config.theme && /^[a-z]+$/.test(config.theme) ? ` data-theme="${config.theme}"` : '';
|
|
17607
|
+
return `<script src="${origin}/dist/runtime/js/captcha-loader.js" async data-sitekey="${site_key}"${theme}></script>`;
|
|
17608
|
+
};
|
|
17609
|
+
const _build_captcha_container = (site_key) => `<div class="xuda-captcha" data-sitekey="${site_key}"></div>`;
|
|
17610
|
+
|
|
17611
|
+
export const get_bot_protection_settings = async function (req) {
|
|
17612
|
+
try {
|
|
17613
|
+
const { uid, profile_id } = req;
|
|
17614
|
+
const info = await get_active_account_profile_info(uid, profile_id);
|
|
17615
|
+
if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
17616
|
+
const bp_id = _bp_profile_id(uid, info.account_profile_id);
|
|
17617
|
+
const doc = await _bp_load(bp_id);
|
|
17618
|
+
const site_key = doc?.site_key || '';
|
|
17619
|
+
const esc = doc?.escalation || _bp_default_escalation();
|
|
17620
|
+
return {
|
|
17621
|
+
code: 1,
|
|
17622
|
+
data: {
|
|
17623
|
+
enabled: doc?.enabled === true,
|
|
17624
|
+
plan_tier: doc?.plan_tier || 'standard',
|
|
17625
|
+
site_key,
|
|
17626
|
+
secret_key: doc?.secret_key || '',
|
|
17627
|
+
escalation: {
|
|
17628
|
+
aggressiveness: esc.aggressiveness,
|
|
17629
|
+
pow_difficulty: esc.pow_difficulty,
|
|
17630
|
+
clearance_ttl_ms: esc.clearance_ttl_ms,
|
|
17631
|
+
l1_enabled: esc.l1_enabled !== false,
|
|
17632
|
+
l2_enabled: esc.l2_enabled !== false,
|
|
17633
|
+
},
|
|
17634
|
+
custom_rules: doc?.custom_rules || {},
|
|
17635
|
+
require_on_contact_form: doc?.require_on_contact_form === true,
|
|
17636
|
+
widget_profile_id: `${uid}.${info.account_profile_id}`,
|
|
17637
|
+
iframe_url: site_key ? `${embed_origin()}/captcha/${site_key}` : '',
|
|
17638
|
+
snippet: site_key ? _build_captcha_snippet(site_key, { theme: doc?.theme }) : '',
|
|
17639
|
+
container_snippet: site_key ? _build_captcha_container(site_key) : '',
|
|
17640
|
+
},
|
|
17641
|
+
};
|
|
17642
|
+
} catch (err) {
|
|
17643
|
+
return { code: -1, data: err.message || String(err) };
|
|
17644
|
+
}
|
|
17645
|
+
};
|
|
17646
|
+
|
|
17647
|
+
export const update_bot_protection_settings = async function (req) {
|
|
17648
|
+
try {
|
|
17649
|
+
const { uid, profile_id, enabled, escalation, custom_rules, require_on_contact_form, rotate_secret } = req;
|
|
17650
|
+
const info = await get_active_account_profile_info(uid, profile_id);
|
|
17651
|
+
if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
17652
|
+
const doc = await _bp_ensure_profile(uid, info.account_profile_id, { surface: 'widget' });
|
|
17653
|
+
|
|
17654
|
+
if (enabled === true && doc.enabled !== true) {
|
|
17655
|
+
const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
|
|
17656
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17657
|
+
}
|
|
17658
|
+
if (typeof enabled === 'boolean') doc.enabled = enabled;
|
|
17659
|
+
if (doc.enabled && !doc.site_key) {
|
|
17660
|
+
const k = _bp_new_keys();
|
|
17661
|
+
doc.site_key = k.site_key;
|
|
17662
|
+
doc.secret_key = k.secret_key;
|
|
17663
|
+
}
|
|
17664
|
+
if (rotate_secret === true && doc.site_key) {
|
|
17665
|
+
if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
|
|
17666
|
+
doc.secret_key = _bp_new_keys().secret_key;
|
|
17667
|
+
}
|
|
17668
|
+
if (escalation && typeof escalation === 'object') {
|
|
17669
|
+
doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
|
|
17670
|
+
}
|
|
17671
|
+
if (custom_rules && typeof custom_rules === 'object') doc.custom_rules = bp_sanitize_rules(custom_rules);
|
|
17672
|
+
if (typeof require_on_contact_form === 'boolean') doc.require_on_contact_form = require_on_contact_form;
|
|
17673
|
+
_bp_flush_stats(doc);
|
|
17674
|
+
await _bp_save(doc);
|
|
17675
|
+
return await get_bot_protection_settings({ uid, profile_id });
|
|
17676
|
+
} catch (err) {
|
|
17677
|
+
return { code: -1, data: err.message || String(err) };
|
|
17678
|
+
}
|
|
17679
|
+
};
|
|
17680
|
+
|
|
17681
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17682
|
+
// BOT PROTECTION - Surface 2 lifecycle (origin protection of a target).
|
|
17683
|
+
// These are called by app_module's thin cpi handlers over the broker (uid is
|
|
17684
|
+
// passed explicitly, already authenticated at the http layer), plus two
|
|
17685
|
+
// broker-only helpers the router uses at the edge. See docs/handoff_bot_protection_ishai.md.
|
|
17686
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17687
|
+
|
|
17688
|
+
// Stable per-target slug so re-attaching the same target maps to one profile.
|
|
17689
|
+
const _bp_slug_for_target = (target) => 't_' + _bp_sha(String((target && target.id) || '')).slice(0, 16);
|
|
17690
|
+
const _bp_summary = (doc) => ({
|
|
17691
|
+
profile_id: doc._id,
|
|
17692
|
+
target: doc.target || null,
|
|
17693
|
+
enabled: doc.enabled === true,
|
|
17694
|
+
armed: doc.armed === true,
|
|
17695
|
+
plan_tier: doc.plan_tier || 'standard',
|
|
17696
|
+
surface: doc.surface || 'origin',
|
|
17697
|
+
site_key: doc.site_key || '',
|
|
17698
|
+
stats: _bp_merged_stats(doc),
|
|
17699
|
+
});
|
|
17700
|
+
|
|
17701
|
+
// IPv4 CIDR membership test (dependency-free) for allow/block-list rules.
|
|
17702
|
+
const _cidr_match = (cidr, ip) => {
|
|
17703
|
+
try {
|
|
17704
|
+
if (typeof cidr !== 'string' || cidr.indexOf('/') < 0) return false;
|
|
17705
|
+
const [range, bitsStr] = cidr.split('/');
|
|
17706
|
+
const bits = parseInt(bitsStr, 10);
|
|
17707
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(range) || !/^\d+\.\d+\.\d+\.\d+$/.test(ip) || !(bits >= 0 && bits <= 32)) return false;
|
|
17708
|
+
const toInt = (a) => a.split('.').reduce((s, o) => ((s << 8) + (parseInt(o, 10) & 255)) >>> 0, 0) >>> 0;
|
|
17709
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
17710
|
+
return (toInt(range) & mask) === (toInt(ip) & mask);
|
|
17711
|
+
} catch (e) {
|
|
17712
|
+
return false;
|
|
17713
|
+
}
|
|
17714
|
+
};
|
|
17715
|
+
|
|
17716
|
+
export const bp_attach = async function (req) {
|
|
17717
|
+
try {
|
|
17718
|
+
const { uid, target, plan_tier, escalation } = req;
|
|
17719
|
+
if (!uid || !target || !target.id) return { code: -1, data: 'bad_request' };
|
|
17720
|
+
const tier = plan_tier === 'custom' ? 'custom' : 'standard';
|
|
17721
|
+
const ent = await _bp_ensure_entitled(uid, tier);
|
|
17722
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17723
|
+
const slug = _bp_slug_for_target(target);
|
|
17724
|
+
const doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', plan_tier: tier, target });
|
|
17725
|
+
doc.target = target;
|
|
17726
|
+
doc.plan_tier = tier;
|
|
17727
|
+
doc.surface = doc.surface === 'widget' ? 'both' : 'origin';
|
|
17728
|
+
doc.enabled = true;
|
|
17729
|
+
if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
|
|
17730
|
+
if (escalation && typeof escalation === 'object') doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
|
|
17731
|
+
_bp_flush_stats(doc);
|
|
17732
|
+
await _bp_save(doc);
|
|
17733
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17734
|
+
} catch (err) {
|
|
17735
|
+
return { code: -1, data: err.message || String(err) };
|
|
17736
|
+
}
|
|
17737
|
+
};
|
|
17738
|
+
|
|
17739
|
+
export const bp_get_profiles = async function (req) {
|
|
17740
|
+
try {
|
|
17741
|
+
const { uid } = req;
|
|
17742
|
+
if (!uid) return { code: -1, data: 'no uid' };
|
|
17743
|
+
const q = await db_module.find_couch_query(BP_DB, { selector: { docType: 'bot_protection_profile', owner_uid: uid }, limit: 500 }, true, true);
|
|
17744
|
+
const docs = q?.docs || [];
|
|
17745
|
+
return { code: 1, data: { profiles: docs.map(_bp_summary) } };
|
|
17746
|
+
} catch (err) {
|
|
17747
|
+
return { code: -1, data: err.message || String(err) };
|
|
17748
|
+
}
|
|
17749
|
+
};
|
|
17750
|
+
|
|
17751
|
+
export const bp_arm = async function (req) {
|
|
17752
|
+
try {
|
|
17753
|
+
const { uid, profile_id, app_id, armed, target } = req;
|
|
17754
|
+
if (!uid) return { code: -1, data: 'no uid' };
|
|
17755
|
+
let doc = null;
|
|
17756
|
+
if (profile_id) {
|
|
17757
|
+
doc = await _bp_load(profile_id);
|
|
17758
|
+
} else if (target && target.id) {
|
|
17759
|
+
doc = await _bp_ensure_profile(uid, _bp_slug_for_target(target), { surface: 'origin', target });
|
|
17760
|
+
if (!doc.target) doc.target = target;
|
|
17761
|
+
} else if (app_id) {
|
|
17762
|
+
const slug = _bp_slug_for_target({ id: app_id });
|
|
17763
|
+
doc = await _bp_load(_bp_profile_id(uid, slug));
|
|
17764
|
+
if (!doc) doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', target: { type: 'internal', kind: 'app', id: app_id, label: app_id, region: null } });
|
|
17765
|
+
}
|
|
17766
|
+
if (!doc) return { code: -404, data: 'profile_not_found' };
|
|
17767
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17768
|
+
const want = armed === true;
|
|
17769
|
+
if (want) {
|
|
17770
|
+
const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
|
|
17771
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
|
|
17772
|
+
if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
|
|
17773
|
+
doc.enabled = true;
|
|
17774
|
+
}
|
|
17775
|
+
doc.armed = want;
|
|
17776
|
+
_bp_flush_stats(doc);
|
|
17777
|
+
await _bp_save(doc);
|
|
17778
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17779
|
+
} catch (err) {
|
|
17780
|
+
return { code: -1, data: err.message || String(err) };
|
|
17781
|
+
}
|
|
17782
|
+
};
|
|
17783
|
+
|
|
17784
|
+
export const bp_detach = async function (req) {
|
|
17785
|
+
try {
|
|
17786
|
+
const { uid, profile_id } = req;
|
|
17787
|
+
const doc = await _bp_load(profile_id);
|
|
17788
|
+
if (!doc) return { code: 1, data: { ok: true } };
|
|
17789
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17790
|
+
if (doc.site_key) await _bp_delete_index(_bp_index_id.key(doc.site_key));
|
|
17791
|
+
if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
|
|
17792
|
+
const tkey = _bp_target_key(doc);
|
|
17793
|
+
if (tkey) await _bp_delete_index(_bp_index_id.target(tkey));
|
|
17794
|
+
try {
|
|
17795
|
+
const r = await db_module.get_couch_doc(BP_DB, doc._id, true);
|
|
17796
|
+
if (r?.data?._rev) await db_module.delete_couch_doc(BP_DB, doc._id, r.data._rev);
|
|
17797
|
+
} catch (e) {}
|
|
17798
|
+
_bp_stat_buf.delete(doc._id);
|
|
17799
|
+
return { code: 1, data: { ok: true } };
|
|
17800
|
+
} catch (err) {
|
|
17801
|
+
return { code: -1, data: err.message || String(err) };
|
|
17802
|
+
}
|
|
17803
|
+
};
|
|
17804
|
+
|
|
17805
|
+
export const bp_set_custom_rules = async function (req) {
|
|
17806
|
+
try {
|
|
17807
|
+
const { uid, profile_id, rules } = req;
|
|
17808
|
+
const doc = await _bp_load(profile_id);
|
|
17809
|
+
if (!doc) return { code: -404, data: 'profile_not_found' };
|
|
17810
|
+
if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
|
|
17811
|
+
const ent = await _bp_ensure_entitled(uid, 'custom');
|
|
17812
|
+
if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: 'custom', price: 5 } };
|
|
17813
|
+
doc.plan_tier = 'custom';
|
|
17814
|
+
doc.custom_rules = bp_sanitize_rules(rules || {});
|
|
17815
|
+
_bp_flush_stats(doc);
|
|
17816
|
+
await _bp_save(doc);
|
|
17817
|
+
return { code: 1, data: _bp_summary(doc) };
|
|
17818
|
+
} catch (err) {
|
|
17819
|
+
return { code: -1, data: err.message || String(err) };
|
|
17820
|
+
}
|
|
17821
|
+
};
|
|
17822
|
+
|
|
17823
|
+
// Broker-only: the router resolves the guarding profile for a target at the edge.
|
|
17824
|
+
export const bp_resolve_target = async function (req) {
|
|
17825
|
+
try {
|
|
17826
|
+
const { target_key } = req;
|
|
17827
|
+
if (!target_key) return { code: -404, data: 'no_target' };
|
|
17828
|
+
const idx = await _bp_load_index(_bp_index_id.target(target_key));
|
|
17829
|
+
if (!idx) return { code: -404, data: 'not_found' };
|
|
17830
|
+
const doc = await _bp_load(idx.profile_id);
|
|
17831
|
+
if (!doc) return { code: -404, data: 'not_found' };
|
|
17832
|
+
return {
|
|
17833
|
+
code: 1,
|
|
17834
|
+
data: {
|
|
17835
|
+
profile_id: doc._id,
|
|
17836
|
+
armed: doc.armed === true,
|
|
17837
|
+
enabled: doc.enabled === true,
|
|
17838
|
+
plan_tier: doc.plan_tier || 'standard',
|
|
17839
|
+
site_key: doc.site_key || '',
|
|
17840
|
+
custom_rules: doc.custom_rules || {},
|
|
17841
|
+
escalation: doc.escalation || _bp_default_escalation(),
|
|
17842
|
+
target: doc.target || null,
|
|
17843
|
+
},
|
|
17844
|
+
};
|
|
17845
|
+
} catch (err) {
|
|
17846
|
+
return { code: -404, data: err.message || String(err) };
|
|
17847
|
+
}
|
|
17848
|
+
};
|
|
17849
|
+
|
|
17850
|
+
// Broker-only: evaluate the custom rules for an inbound request context and
|
|
17851
|
+
// return the enforcement action. Standard tier ignores custom_rules (challenge
|
|
17852
|
+
// when armed); custom tier honors the full rule set.
|
|
17853
|
+
export const evaluate_custom_rules = async function (req) {
|
|
17854
|
+
try {
|
|
17855
|
+
const { profile, ctx } = req;
|
|
17856
|
+
const d = _bp_defaults();
|
|
17857
|
+
const esc = (profile && profile.escalation) || _bp_default_escalation();
|
|
17858
|
+
const out = { action: 'challenge', start_level: 0, aggressiveness: esc.aggressiveness || d.aggressiveness, clearance_ttl_ms: esc.clearance_ttl_ms || d.clearance_ttl_ms };
|
|
17859
|
+
const c = ctx || {};
|
|
17860
|
+
const rules = profile && profile.plan_tier === 'custom' && profile.custom_rules ? profile.custom_rules : null;
|
|
17861
|
+
const inList = (list, ip, country) => (list || []).some((e) => e === ip || e === country || _cidr_match(e, ip));
|
|
17862
|
+
if (rules) {
|
|
17863
|
+
if (inList(rules.block_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'block' } };
|
|
17864
|
+
if (inList(rules.allow_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'allow' } };
|
|
17865
|
+
if (rules.reputation && rules.reputation.allow_verified_search_bots && c.verified_bot) return { code: 1, data: { ...out, action: 'allow' } };
|
|
17866
|
+
if (rules.geo && rules.geo.mode && rules.geo.mode !== 'off' && c.country && (rules.geo.countries || []).includes(c.country)) {
|
|
17867
|
+
if (rules.geo.mode === 'block') return { code: 1, data: { ...out, action: 'block' } };
|
|
17868
|
+
if (rules.geo.mode === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
|
|
17869
|
+
out.action = 'challenge';
|
|
17870
|
+
}
|
|
17871
|
+
if (Array.isArray(rules.paths) && c.path) {
|
|
17872
|
+
for (const p of rules.paths) {
|
|
17873
|
+
if (p && p.pattern && c.path.indexOf(p.pattern) === 0) {
|
|
17874
|
+
if (p.action === 'block') return { code: 1, data: { ...out, action: 'block' } };
|
|
17875
|
+
if (p.action === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
|
|
17876
|
+
out.action = 'challenge';
|
|
17877
|
+
break;
|
|
17878
|
+
}
|
|
17879
|
+
}
|
|
17880
|
+
}
|
|
17881
|
+
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';
|
|
17882
|
+
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';
|
|
17883
|
+
if (rules.require_referer && !c.has_referer) out.action = 'challenge';
|
|
17884
|
+
if (['low', 'medium', 'high'].includes(rules.aggressiveness)) out.aggressiveness = rules.aggressiveness;
|
|
17885
|
+
if (Number.isFinite(Number(rules.clearance_ttl_ms))) out.clearance_ttl_ms = Number(rules.clearance_ttl_ms);
|
|
17886
|
+
}
|
|
17887
|
+
return { code: 1, data: out };
|
|
17888
|
+
} catch (err) {
|
|
17889
|
+
return { code: 1, data: { action: 'challenge', start_level: 0 } };
|
|
17890
|
+
}
|
|
17891
|
+
};
|
|
17892
|
+
|
|
17893
|
+
// Broker-only: the origin interstitial passes a captcha response token (from the
|
|
17894
|
+
// embedded widget) here. Resolves the profile by site_key, verifies and burns the
|
|
17895
|
+
// token, and returns the guarded target so the router can mint a clearance cookie.
|
|
17896
|
+
export const bp_consume_response = async function (req) {
|
|
17897
|
+
try {
|
|
17898
|
+
const { site_key, response } = req;
|
|
17899
|
+
const profile = await _bp_resolve_by_site_key(site_key);
|
|
17900
|
+
if (!profile || profile.enabled !== true) return { code: 1, data: { ok: false, reason: 'unknown_site' } };
|
|
17901
|
+
const v = verify_response_token(profile.site_key, response);
|
|
17902
|
+
if (!v.ok) return { code: 1, data: { ok: false, reason: v.reason } };
|
|
17903
|
+
const esc = profile.escalation || _bp_default_escalation();
|
|
17904
|
+
const d = _bp_defaults();
|
|
17905
|
+
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 } };
|
|
17906
|
+
} catch (err) {
|
|
17907
|
+
return { code: 1, data: { ok: false, reason: 'error' } };
|
|
17908
|
+
}
|
|
17909
|
+
};
|
|
17910
|
+
|
|
17911
|
+
// Broker-only: the router caches the set of armed targets (refreshed periodically)
|
|
17912
|
+
// so the enforcement hot path is a local lookup and unguarded traffic never hits
|
|
17913
|
+
// the broker.
|
|
17914
|
+
export const bp_armed_targets = async function () {
|
|
17915
|
+
try {
|
|
17916
|
+
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);
|
|
17917
|
+
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);
|
|
17918
|
+
return { code: 1, data: rows };
|
|
17919
|
+
} catch (err) {
|
|
17920
|
+
return { code: -1, data: err.message || String(err) };
|
|
17921
|
+
}
|
|
17922
|
+
};
|