fiptcha 0.1.0
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/LICENSE +201 -0
- package/README.md +243 -0
- package/index.js +12 -0
- package/package.json +32 -0
- package/scripts/captcha-companion-requirements.txt +8 -0
- package/scripts/captcha-companion.js +222 -0
- package/scripts/captcha-container-probe.js +27 -0
- package/scripts/captcha-mlx-worker.py +66 -0
- package/scripts/install-captcha-companion.js +47 -0
- package/src/captcha-benchmark.js +25 -0
- package/src/captcha-companion-client.js +74 -0
- package/src/captcha-grid-solver.js +158 -0
- package/src/captcha-interceptor.js +39 -0
- package/src/captcha-local-solver.js +168 -0
- package/src/captcha-mlx-runtime.js +140 -0
- package/src/captcha-model-downloader.js +162 -0
- package/src/captcha-model-manager.js +375 -0
- package/src/captcha-model-manifest.js +74 -0
- package/src/captcha-resources.js +128 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { captchaModelManager } = require('./captcha-model-manager');
|
|
2
|
+
const { solveImageGrid, PROVIDERS } = require('./captcha-grid-solver');
|
|
3
|
+
|
|
4
|
+
const TOKEN_SELECTORS = Object.freeze({
|
|
5
|
+
recaptcha_v2: ['#g-recaptcha-response', 'textarea[name="g-recaptcha-response"]'],
|
|
6
|
+
hcaptcha: ['textarea[name="h-captcha-response"]', 'textarea[name="g-recaptcha-response"]'],
|
|
7
|
+
turnstile: ['input[name="cf-turnstile-response"]', 'textarea[name="cf-turnstile-response"]']
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
async function readToken(page, captchaType) {
|
|
11
|
+
return page.evaluate(({ type, selectors }) => {
|
|
12
|
+
for (const selector of selectors[type] || []) {
|
|
13
|
+
for (const element of document.querySelectorAll(selector)) {
|
|
14
|
+
const value = String(element.value || element.getAttribute('value') || '').trim();
|
|
15
|
+
if (value) return value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (type === 'recaptcha_v2' && globalThis.grecaptcha?.getResponse) {
|
|
19
|
+
try { return globalThis.grecaptcha.getResponse() || ''; } catch { /* widget id may be required */ }
|
|
20
|
+
}
|
|
21
|
+
if (type === 'hcaptcha' && globalThis.hcaptcha?.getResponse) {
|
|
22
|
+
try { return globalThis.hcaptcha.getResponse() || ''; } catch { /* widget id may be required */ }
|
|
23
|
+
}
|
|
24
|
+
if (type === 'turnstile' && globalThis.turnstile?.getResponse) {
|
|
25
|
+
try { return globalThis.turnstile.getResponse() || ''; } catch { /* widget id may be required */ }
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}, { type: captchaType, selectors: TOKEN_SELECTORS }).catch(() => '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function waitForToken(page, captchaType, timeout) {
|
|
32
|
+
const deadline = Date.now() + Math.max(0, timeout);
|
|
33
|
+
do {
|
|
34
|
+
const token = await readToken(page, captchaType);
|
|
35
|
+
if (token) return token;
|
|
36
|
+
if (Date.now() >= deadline) break;
|
|
37
|
+
await page.waitForTimeout(Math.min(150, Math.max(1, deadline - Date.now())));
|
|
38
|
+
} while (Date.now() <= deadline);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function waitForTokenOrChallenge(page, captchaType, timeout) {
|
|
43
|
+
const adapter = PROVIDERS[captchaType];
|
|
44
|
+
const deadline = Date.now() + Math.max(0, timeout);
|
|
45
|
+
do {
|
|
46
|
+
const token = await readToken(page, captchaType);
|
|
47
|
+
if (token) return token;
|
|
48
|
+
if (adapter) {
|
|
49
|
+
for (const frame of page.frames?.() || []) {
|
|
50
|
+
if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
|
|
51
|
+
if (await frame.locator(adapter.grid).first().isVisible({ timeout: 100 }).catch(() => false)) return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() >= deadline) break;
|
|
55
|
+
await page.waitForTimeout(Math.min(150, Math.max(1, deadline - Date.now())));
|
|
56
|
+
} while (Date.now() <= deadline);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function clickCheckbox(page, captchaType) {
|
|
61
|
+
const patterns = captchaType === 'hcaptcha'
|
|
62
|
+
? ['hcaptcha.com/captcha', 'newassets.hcaptcha.com/captcha']
|
|
63
|
+
: captchaType === 'turnstile'
|
|
64
|
+
? ['challenges.cloudflare.com']
|
|
65
|
+
: ['/recaptcha/api2/anchor', '/recaptcha/enterprise/anchor'];
|
|
66
|
+
const selector = captchaType === 'hcaptcha'
|
|
67
|
+
? '#checkbox, [role="checkbox"], input[type="checkbox"], label'
|
|
68
|
+
: captchaType === 'turnstile'
|
|
69
|
+
? 'input[type="checkbox"], [role="checkbox"], button, label'
|
|
70
|
+
: '#recaptcha-anchor, [role="checkbox"]';
|
|
71
|
+
for (const frame of page.frames?.() || []) {
|
|
72
|
+
if (!patterns.some((pattern) => frame.url().includes(pattern))) continue;
|
|
73
|
+
const checkbox = frame.locator(selector).first();
|
|
74
|
+
if (!await checkbox.isVisible({ timeout: 1000 }).catch(() => false)) continue;
|
|
75
|
+
if (!await checkbox.isEnabled({ timeout: 250 }).catch(() => true)) continue;
|
|
76
|
+
const pointerReady = await checkbox.evaluate((element) => {
|
|
77
|
+
const style = getComputedStyle(element);
|
|
78
|
+
return style.pointerEvents !== 'none' && element.getAttribute('aria-disabled') !== 'true';
|
|
79
|
+
}).catch(() => true);
|
|
80
|
+
if (!pointerReady) continue;
|
|
81
|
+
const firstBox = await checkbox.boundingBox().catch(() => null);
|
|
82
|
+
await page.waitForTimeout(75);
|
|
83
|
+
const box = await checkbox.boundingBox().catch(() => null);
|
|
84
|
+
if (firstBox && box && (Math.abs(firstBox.x - box.x) >= 1 || Math.abs(firstBox.y - box.y) >= 1)) continue;
|
|
85
|
+
if (box && page.mouse) {
|
|
86
|
+
const x = box.x + box.width / 2;
|
|
87
|
+
const y = box.y + box.height / 2;
|
|
88
|
+
await page.mouse.move(x, y, { steps: 8 });
|
|
89
|
+
await page.mouse.down();
|
|
90
|
+
await page.waitForTimeout(45);
|
|
91
|
+
await page.mouse.up();
|
|
92
|
+
} else {
|
|
93
|
+
await checkbox.click({ timeout: 2000 });
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function executeHcaptchaWidget(page) {
|
|
101
|
+
return page.evaluate(() => {
|
|
102
|
+
if (typeof globalThis.hcaptcha?.execute !== 'function') return false;
|
|
103
|
+
try {
|
|
104
|
+
const pending = globalThis.hcaptcha.execute();
|
|
105
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {});
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}).catch(() => false);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function solveLocalCaptcha(page, { captchaType, timeout = 60_000, logs = [] } = {}) {
|
|
114
|
+
if (!['recaptcha_v2', 'hcaptcha', 'turnstile'].includes(captchaType)) {
|
|
115
|
+
throw new Error(`Local solver does not support ${captchaType}`);
|
|
116
|
+
}
|
|
117
|
+
const deadline = Date.now() + Math.max(1, timeout);
|
|
118
|
+
let usedVisionModel = false;
|
|
119
|
+
const modelReadiness = ['recaptcha_v2', 'hcaptcha'].includes(captchaType)
|
|
120
|
+
? captchaModelManager.reconcile().then((runtime) => ({ runtime }), (error) => ({ error }))
|
|
121
|
+
: null;
|
|
122
|
+
let token = await readToken(page, captchaType);
|
|
123
|
+
if (!token) {
|
|
124
|
+
await clickCheckbox(page, captchaType).catch((error) => logs.push(`Local ${captchaType} checkbox click failed: ${error.message}`));
|
|
125
|
+
const initialWait = captchaType === 'hcaptcha' ? 1500 : 7000;
|
|
126
|
+
token = await waitForTokenOrChallenge(page, captchaType, Math.min(initialWait, Math.max(0, deadline - Date.now())));
|
|
127
|
+
if (!token && captchaType === 'hcaptcha' && Date.now() < deadline) {
|
|
128
|
+
const executed = await executeHcaptchaWidget(page);
|
|
129
|
+
if (executed) logs.push('Local hcaptcha widget required programmatic execution after checkbox interaction');
|
|
130
|
+
token = await waitForTokenOrChallenge(page, captchaType, Math.min(5500, Math.max(0, deadline - Date.now())));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!token && captchaType === 'turnstile') {
|
|
134
|
+
throw new Error('Turnstile did not issue a token after the active-browser interaction');
|
|
135
|
+
}
|
|
136
|
+
if (!token) {
|
|
137
|
+
const adapter = PROVIDERS[captchaType];
|
|
138
|
+
const frameDeadline = Math.min(deadline, Date.now() + 4000);
|
|
139
|
+
let challengeVisible = false;
|
|
140
|
+
while (!challengeVisible && Date.now() < frameDeadline) {
|
|
141
|
+
for (const frame of page.frames?.() || []) {
|
|
142
|
+
if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
|
|
143
|
+
challengeVisible = await frame.locator(adapter.grid).first().isVisible({ timeout: 100 }).catch(() => false);
|
|
144
|
+
if (challengeVisible) break;
|
|
145
|
+
}
|
|
146
|
+
if (!challengeVisible) await page.waitForTimeout(100);
|
|
147
|
+
}
|
|
148
|
+
if (!challengeVisible) throw new Error(`${captchaType} did not expose an image challenge or token`);
|
|
149
|
+
if (modelReadiness) {
|
|
150
|
+
const readiness = await modelReadiness;
|
|
151
|
+
if (readiness.error) throw new Error(`vision backend unavailable: ${readiness.error.message}`);
|
|
152
|
+
if (!readiness.runtime) throw new Error(captchaModelManager.status().error || 'vision backend unavailable');
|
|
153
|
+
}
|
|
154
|
+
usedVisionModel = true;
|
|
155
|
+
logs.push(`Local ${captchaType} image challenge is ready; starting grid solver`);
|
|
156
|
+
token = await solveImageGrid(page, { captchaType, deadline, waitForToken, logs });
|
|
157
|
+
}
|
|
158
|
+
if (!token) throw new Error(`${captchaType} challenge ended without producing a token`);
|
|
159
|
+
const status = captchaModelManager.status();
|
|
160
|
+
return {
|
|
161
|
+
token,
|
|
162
|
+
provider: 'local',
|
|
163
|
+
...(usedVisionModel && status.activeTier ? { model: status.activeTier } : {}),
|
|
164
|
+
device: usedVisionModel ? (status.device || 'cpu') : 'browser'
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { TOKEN_SELECTORS, readToken, waitForToken, waitForTokenOrChallenge, clickCheckbox, executeHcaptchaWidget, solveLocalCaptcha };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const { APPLE_MLX_MANIFEST } = require('./captcha-model-manifest');
|
|
6
|
+
const { downloadModelArtifacts, verifyArtifactDirectory } = require('./captcha-model-downloader');
|
|
7
|
+
|
|
8
|
+
const PROBE_PNG = Buffer.from(
|
|
9
|
+
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAGUlEQVR4nO3BAQ0AAADCoPdPbQ43oAAAAAAAAAB4Gx4gAAE7Bq0AAAAASUVORK5CYII=',
|
|
10
|
+
'base64'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
class MlxFlorenceRuntime {
|
|
14
|
+
constructor(processHandle) {
|
|
15
|
+
this.process = processHandle;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
this.nextId = 1;
|
|
18
|
+
this.closed = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static async create(modelDir, timeout = 60_000) {
|
|
22
|
+
const python = path.resolve(process.cwd(), 'data', 'captcha-companion', 'venv', 'bin', 'python3');
|
|
23
|
+
const worker = path.resolve(__dirname, '..', 'scripts', 'captcha-mlx-worker.py');
|
|
24
|
+
if (!fs.existsSync(python)) throw new Error('Pinned MLX environment is not installed; run npm run companion:install');
|
|
25
|
+
const child = spawn(python, [worker, modelDir], {
|
|
26
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
+
env: { ...process.env, HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1', PYTHONUNBUFFERED: '1' }
|
|
28
|
+
});
|
|
29
|
+
const runtime = new MlxFlorenceRuntime(child);
|
|
30
|
+
const ready = new Promise((resolve, reject) => {
|
|
31
|
+
runtime.readyResolve = resolve;
|
|
32
|
+
runtime.readyReject = reject;
|
|
33
|
+
});
|
|
34
|
+
readline.createInterface({ input: child.stdout }).on('line', (line) => runtime._handleLine(line));
|
|
35
|
+
child.stderr.on('data', (chunk) => {
|
|
36
|
+
const message = chunk.toString().trim();
|
|
37
|
+
if (message) runtime.lastDiagnostic = message.slice(-1000);
|
|
38
|
+
});
|
|
39
|
+
child.once('error', (error) => runtime._close(error));
|
|
40
|
+
child.once('exit', (code, signal) => runtime._close(new Error(`MLX worker exited (${signal || code})${runtime.lastDiagnostic ? `: ${runtime.lastDiagnostic}` : ''}`)));
|
|
41
|
+
const timer = setTimeout(() => runtime.readyReject?.(new Error('MLX worker startup timed out')), timeout);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
try {
|
|
44
|
+
await ready;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
return runtime;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
await runtime.dispose();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_handleLine(line) {
|
|
55
|
+
let payload;
|
|
56
|
+
try { payload = JSON.parse(line); } catch { return; }
|
|
57
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'ready')) {
|
|
58
|
+
if (payload.ready) this.readyResolve?.(payload);
|
|
59
|
+
else this.readyReject?.(new Error(payload.error || 'MLX worker failed to load'));
|
|
60
|
+
this.readyResolve = null;
|
|
61
|
+
this.readyReject = null;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const pending = this.pending.get(payload.id);
|
|
65
|
+
if (!pending) return;
|
|
66
|
+
this.pending.delete(payload.id);
|
|
67
|
+
clearTimeout(pending.timer);
|
|
68
|
+
if (payload.error) pending.reject(new Error(payload.error));
|
|
69
|
+
else if (!Array.isArray(payload.detections)) pending.reject(new Error('MLX worker returned invalid detections'));
|
|
70
|
+
else pending.resolve(payload.detections);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_close(error) {
|
|
74
|
+
if (this.closed) return;
|
|
75
|
+
this.closed = true;
|
|
76
|
+
this.readyReject?.(error);
|
|
77
|
+
for (const pending of this.pending.values()) {
|
|
78
|
+
clearTimeout(pending.timer);
|
|
79
|
+
pending.reject(error);
|
|
80
|
+
}
|
|
81
|
+
this.pending.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async detect(imageBuffer, label, threshold = APPLE_MLX_MANIFEST.threshold, timeout = 45_000) {
|
|
85
|
+
if (this.closed) throw new Error('MLX worker is not running');
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
const result = new Promise((resolve, reject) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
this.pending.delete(id);
|
|
90
|
+
reject(new Error('MLX inference timed out'));
|
|
91
|
+
}, timeout);
|
|
92
|
+
timer.unref?.();
|
|
93
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
94
|
+
});
|
|
95
|
+
this.process.stdin.write(`${JSON.stringify({ id, label, threshold, imageBase64: imageBuffer.toString('base64') })}\n`);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async probe() {
|
|
100
|
+
await this.detect(PROBE_PNG, 'object', 0.99, 30_000);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async dispose() {
|
|
104
|
+
if (!this.closed) this.process.kill('SIGTERM');
|
|
105
|
+
this._close(new Error('MLX worker stopped'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function loadVerifiedMlxRuntime(modelRoot, options = {}) {
|
|
110
|
+
const staging = path.join(modelRoot, '.staging-florence2-mlx');
|
|
111
|
+
const target = path.join(modelRoot, 'florence2-mlx');
|
|
112
|
+
await fs.promises.rm(staging, { recursive: true, force: true });
|
|
113
|
+
let verified = await verifyArtifactDirectory('florence2_mlx', target, { manifest: APPLE_MLX_MANIFEST }).catch(() => false);
|
|
114
|
+
if (!verified) {
|
|
115
|
+
await fs.promises.rm(target, { recursive: true, force: true });
|
|
116
|
+
await fs.promises.mkdir(staging, { recursive: true });
|
|
117
|
+
try {
|
|
118
|
+
await downloadModelArtifacts('florence2_mlx', staging, { manifest: APPLE_MLX_MANIFEST, fetchImpl: options.fetchImpl });
|
|
119
|
+
verified = await verifyArtifactDirectory('florence2_mlx', staging, { manifest: APPLE_MLX_MANIFEST });
|
|
120
|
+
if (!verified) throw new Error('MLX Florence artifact verification failed');
|
|
121
|
+
await fs.promises.rename(staging, target);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await fs.promises.rm(staging, { recursive: true, force: true });
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let runtime;
|
|
128
|
+
try {
|
|
129
|
+
runtime = await MlxFlorenceRuntime.create(target);
|
|
130
|
+
await runtime.probe();
|
|
131
|
+
return runtime;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
await runtime?.dispose?.().catch?.(() => undefined);
|
|
134
|
+
await fs.promises.rm(staging, { recursive: true, force: true });
|
|
135
|
+
await fs.promises.rm(target, { recursive: true, force: true });
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { MlxFlorenceRuntime, loadVerifiedMlxRuntime };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { Readable } = require('stream');
|
|
5
|
+
const { pipeline } = require('stream/promises');
|
|
6
|
+
const { MODEL_MANIFESTS } = require('./captcha-model-manifest');
|
|
7
|
+
|
|
8
|
+
const APPROVED_DOWNLOAD_HOSTS = Object.freeze([
|
|
9
|
+
'huggingface.co',
|
|
10
|
+
'hf.co',
|
|
11
|
+
'xethub.hf.co'
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function isApprovedDownloadUrl(rawUrl) {
|
|
15
|
+
let hostname;
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(rawUrl);
|
|
18
|
+
if (url.protocol !== 'https:') return false;
|
|
19
|
+
hostname = url.hostname.toLowerCase();
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return APPROVED_DOWNLOAD_HOSTS.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeArtifactPath(root, relativePath) {
|
|
27
|
+
if (!relativePath || path.isAbsolute(relativePath) || relativePath.includes('..')) {
|
|
28
|
+
throw new Error(`Unsafe CAPTCHA model artifact path: ${relativePath}`);
|
|
29
|
+
}
|
|
30
|
+
const resolvedRoot = path.resolve(root);
|
|
31
|
+
const resolved = path.resolve(root, relativePath);
|
|
32
|
+
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Artifact escapes model root: ${relativePath}`);
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function checksumFile(filePath) {
|
|
37
|
+
const hash = crypto.createHash('sha256');
|
|
38
|
+
const input = fs.createReadStream(filePath);
|
|
39
|
+
for await (const chunk of input) hash.update(chunk);
|
|
40
|
+
return hash.digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchWithApprovedRedirects(url, options = {}, maxRedirects = 5) {
|
|
44
|
+
let current = url;
|
|
45
|
+
for (let redirect = 0; redirect <= maxRedirects; redirect += 1) {
|
|
46
|
+
if (!isApprovedDownloadUrl(current)) throw new Error(`Blocked model download redirect to unapproved host: ${current}`);
|
|
47
|
+
const response = await fetch(current, { ...options, redirect: 'manual' });
|
|
48
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
|
49
|
+
const location = response.headers.get('location');
|
|
50
|
+
if (!location) throw new Error('Model download redirect omitted Location header');
|
|
51
|
+
current = new URL(location, current).toString();
|
|
52
|
+
}
|
|
53
|
+
throw new Error('Model download exceeded redirect limit');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function verifyArtifact(filePath, expected) {
|
|
57
|
+
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
58
|
+
if (!stat?.isFile() || stat.size !== expected.size) return false;
|
|
59
|
+
return await checksumFile(filePath) === expected.sha256;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function downloadArtifact(manifest, artifact, root, fetchImpl = fetchWithApprovedRedirects) {
|
|
63
|
+
const target = safeArtifactPath(root, artifact.path);
|
|
64
|
+
if (await verifyArtifact(target, artifact)) return target;
|
|
65
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
66
|
+
const partial = `${target}.part`;
|
|
67
|
+
await fs.promises.rm(partial, { force: true });
|
|
68
|
+
const encodedPath = artifact.path.split('/').map(encodeURIComponent).join('/');
|
|
69
|
+
const url = `https://huggingface.co/${manifest.id}/resolve/${manifest.revision}/${encodedPath}`;
|
|
70
|
+
const response = await fetchImpl(url, {
|
|
71
|
+
headers: { 'Accept-Encoding': 'identity' },
|
|
72
|
+
signal: AbortSignal.timeout(10 * 60_000)
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok || !response.body) throw new Error(`Model download failed for ${artifact.path}: HTTP ${response.status}`);
|
|
75
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
76
|
+
const contentEncoding = response.headers.get('content-encoding');
|
|
77
|
+
if ((!contentEncoding || contentEncoding === 'identity') && Number.isFinite(declaredLength) && declaredLength !== artifact.size) {
|
|
78
|
+
throw new Error(`Model artifact size header mismatch for ${artifact.path}`);
|
|
79
|
+
}
|
|
80
|
+
const hash = crypto.createHash('sha256');
|
|
81
|
+
let received = 0;
|
|
82
|
+
const verifier = new (require('stream').Transform)({
|
|
83
|
+
transform(chunk, _encoding, callback) {
|
|
84
|
+
received += chunk.length;
|
|
85
|
+
hash.update(chunk);
|
|
86
|
+
callback(null, chunk);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(partial, { flags: 'wx', mode: 0o600 }));
|
|
91
|
+
const digest = hash.digest('hex');
|
|
92
|
+
if (received !== artifact.size || digest !== artifact.sha256) {
|
|
93
|
+
throw new Error(`Model artifact checksum mismatch for ${artifact.path} (received ${received} bytes, sha256 ${digest})`);
|
|
94
|
+
}
|
|
95
|
+
await fs.promises.rename(partial, target);
|
|
96
|
+
return target;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await fs.promises.rm(partial, { force: true }).catch(() => undefined);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function listFiles(root, current = root) {
|
|
104
|
+
const entries = await fs.promises.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
105
|
+
const files = [];
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const fullPath = path.join(current, entry.name);
|
|
108
|
+
if (entry.isDirectory()) files.push(...await listFiles(root, fullPath));
|
|
109
|
+
else files.push(path.relative(root, fullPath));
|
|
110
|
+
}
|
|
111
|
+
return files.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function verifyArtifactDirectory(tier, root, { rejectUnlisted = true, manifest: manifestOverride } = {}) {
|
|
115
|
+
const manifest = manifestOverride || MODEL_MANIFESTS[tier];
|
|
116
|
+
if (!manifest) throw new Error(`Unknown CAPTCHA model tier: ${tier}`);
|
|
117
|
+
for (const artifact of manifest.files) {
|
|
118
|
+
if (!await verifyArtifact(safeArtifactPath(root, artifact.path), artifact)) return false;
|
|
119
|
+
}
|
|
120
|
+
if (rejectUnlisted) {
|
|
121
|
+
const allowed = new Set(manifest.files.map((artifact) => artifact.path));
|
|
122
|
+
const actual = await listFiles(root);
|
|
123
|
+
const unexpected = actual.filter((file) => !allowed.has(file));
|
|
124
|
+
if (unexpected.length) throw new Error(`Unlisted CAPTCHA model artifact: ${unexpected[0]}`);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function downloadModelArtifacts(tier, root, options = {}) {
|
|
130
|
+
const manifest = options.manifest || MODEL_MANIFESTS[tier];
|
|
131
|
+
if (!manifest) throw new Error(`Unknown CAPTCHA model tier: ${tier}`);
|
|
132
|
+
await fs.promises.mkdir(root, { recursive: true });
|
|
133
|
+
const maxAttempts = Math.max(1, Number(options.maxAttempts) || 3);
|
|
134
|
+
for (const artifact of manifest.files) {
|
|
135
|
+
let lastError;
|
|
136
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
137
|
+
try {
|
|
138
|
+
await downloadArtifact(manifest, artifact, root, options.fetchImpl);
|
|
139
|
+
lastError = null;
|
|
140
|
+
break;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
lastError = error;
|
|
143
|
+
if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, attempt * 250));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (lastError) throw lastError;
|
|
147
|
+
}
|
|
148
|
+
if (!await verifyArtifactDirectory(tier, root, { manifest })) throw new Error(`CAPTCHA model verification failed for ${tier}`);
|
|
149
|
+
return manifest;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
APPROVED_DOWNLOAD_HOSTS,
|
|
154
|
+
isApprovedDownloadUrl,
|
|
155
|
+
safeArtifactPath,
|
|
156
|
+
checksumFile,
|
|
157
|
+
fetchWithApprovedRedirects,
|
|
158
|
+
verifyArtifact,
|
|
159
|
+
verifyArtifactDirectory,
|
|
160
|
+
downloadArtifact,
|
|
161
|
+
downloadModelArtifacts
|
|
162
|
+
};
|