icoa-cli 2.19.357 → 2.19.358
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/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/ctf.js +1 -787
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/demo2.js +1 -1502
- package/dist/commands/exam.js +1 -1
- package/dist/commands/files.js +1 -59
- package/dist/commands/lang.js +1 -202
- package/dist/commands/log.js +1 -171
- package/dist/commands/shell.js +1 -151
- package/dist/commands/sim.js +1 -389
- package/dist/index.js +1 -355
- package/dist/lib/access.js +1 -184
- package/dist/lib/aienv.js +1 -205
- package/dist/lib/arena-submit.js +1 -21
- package/dist/lib/banner.js +1 -31
- package/dist/lib/budget.js +1 -6
- package/dist/lib/challenge-dir.js +1 -16
- package/dist/lib/colors.js +1 -17
- package/dist/lib/comms.js +1 -212
- package/dist/lib/config.js +1 -93
- package/dist/lib/countdown.js +1 -43
- package/dist/lib/country-lang.js +1 -39
- package/dist/lib/ctfd-client.js +1 -417
- package/dist/lib/demo-exam.js +1 -478
- package/dist/lib/demo-flags.js +1 -27
- package/dist/lib/demo-stats.js +1 -62
- package/dist/lib/demo2-progress.js +1 -102
- package/dist/lib/docker-probe.js +1 -118
- package/dist/lib/editor-spawn.js +1 -53
- package/dist/lib/exam-client.js +1 -54
- package/dist/lib/exam-sandbox.js +1 -201
- package/dist/lib/exam-setup.js +1 -36
- package/dist/lib/exam-state.js +1 -273
- package/dist/lib/gemini.js +1 -247
- package/dist/lib/i18n.js +1 -302
- package/dist/lib/integrity-snapshot.js +1 -88
- package/dist/lib/interactive-spawn.js +1 -55
- package/dist/lib/ipynb-input.js +1 -65
- package/dist/lib/kernel-protocol.js +1 -88
- package/dist/lib/kernel.js +2 -146
- package/dist/lib/learn-curricula.js +1 -309
- package/dist/lib/learn-i18n.js +1 -184
- package/dist/lib/learn-input.js +1 -101
- package/dist/lib/learn-render.js +1 -863
- package/dist/lib/learn-state.js +1 -103
- package/dist/lib/log-sync.js +1 -155
- package/dist/lib/logger.js +1 -49
- package/dist/lib/main-rl.js +1 -7
- package/dist/lib/menu-nav.js +1 -105
- package/dist/lib/notebook-doc.js +1 -137
- package/dist/lib/open-file.js +1 -55
- package/dist/lib/paper-upgrade.js +1 -119
- package/dist/lib/platform.js +1 -99
- package/dist/lib/render-card.js +1 -112
- package/dist/lib/repl-asker.js +1 -67
- package/dist/lib/sample-runner.js +1 -227
- package/dist/lib/sandbox.js +1 -144
- package/dist/lib/shell-split.js +1 -69
- package/dist/lib/sim-cooldown.js +1 -75
- package/dist/lib/theme.js +1 -119
- package/dist/lib/token-format.js +1 -74
- package/dist/lib/tool-man.js +1 -418
- package/dist/lib/toolset-hash.js +1 -48
- package/dist/lib/translation.js +1 -80
- package/dist/lib/translations-fetcher.js +1 -95
- package/dist/lib/ui.js +1 -99
- package/dist/lib/update-check.js +1 -114
- package/dist/lib/version.js +1 -24
- package/dist/postinstall.js +1 -48
- package/dist/repl.js +1 -2391
- package/dist/types/index.js +1 -63
- package/package.json +1 -1
package/dist/lib/countdown.js
CHANGED
|
@@ -1,43 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* 5-4-3-2-1 countdown wrapper for in-flight fetches.
|
|
3
|
-
*
|
|
4
|
-
* Wraps a promise with a visible countdown so the user has something to
|
|
5
|
-
* watch instead of a spinner. If the promise resolves DURING countdown,
|
|
6
|
-
* the remaining seconds are skipped and the line is cleared. If the
|
|
7
|
-
* promise hasn't resolved by the time countdown ends, a "wait" message
|
|
8
|
-
* stays on screen until it finishes.
|
|
9
|
-
*
|
|
10
|
-
* Deliberately avoids mentioning servers, network, or any architectural
|
|
11
|
-
* detail — students should experience the operation as one unbroken
|
|
12
|
-
* "ICOA does it" magic moment.
|
|
13
|
-
*/
|
|
14
|
-
import chalk from 'chalk';
|
|
15
|
-
export async function fetchWithCountdown(fetchPromise, opts = {}) {
|
|
16
|
-
// 15s default — covers cold-start renders (model lazy-load + 90-frame
|
|
17
|
-
// baseline) without the "one more moment..." fallback firing too often.
|
|
18
|
-
// Warm-cache calls finish in 4-7s and the countdown exits early —
|
|
19
|
-
// no UX penalty.
|
|
20
|
-
const startSeconds = opts.from ?? 15;
|
|
21
|
-
const waitText = opts.waitText ?? ' one more moment...';
|
|
22
|
-
let resolved = false;
|
|
23
|
-
fetchPromise.then(() => {
|
|
24
|
-
resolved = true;
|
|
25
|
-
}, () => {
|
|
26
|
-
resolved = true;
|
|
27
|
-
});
|
|
28
|
-
for (let i = startSeconds; i >= 1; i--) {
|
|
29
|
-
if (resolved)
|
|
30
|
-
break;
|
|
31
|
-
// \r overwrites the same line. Pad with spaces to clear any prior
|
|
32
|
-
// longer text (e.g. on countdown from 10 → 9, the second char needs clearing).
|
|
33
|
-
process.stdout.write('\r' + chalk.bold.cyan(` ${i}`) + chalk.gray(' ...').padEnd(40, ' '));
|
|
34
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
35
|
-
}
|
|
36
|
-
// Clear the countdown line
|
|
37
|
-
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
38
|
-
if (!resolved) {
|
|
39
|
-
// Stay-and-wait message — never mention WHY it's slow
|
|
40
|
-
process.stdout.write(chalk.gray(waitText) + '\n');
|
|
41
|
-
}
|
|
42
|
-
return fetchPromise;
|
|
43
|
-
}
|
|
1
|
+
import chalk from"chalk";export async function fetchWithCountdown(t,e={}){const o=e.from??15,r=e.waitText??" one more moment...";let n=!1;t.then(()=>{n=!0},()=>{n=!0});for(let t=o;t>=1&&!n;t--)process.stdout.write("\r"+chalk.bold.cyan(` ${t}`)+chalk.gray(" ...").padEnd(40," ")),await new Promise(t=>setTimeout(t,1e3));return process.stdout.write("\r"+" ".repeat(40)+"\r"),n||process.stdout.write(chalk.gray(r)+"\n"),t}
|
package/dist/lib/country-lang.js
CHANGED
|
@@ -1,39 +1 @@
|
|
|
1
|
-
export const COUNTRY_LANG
|
|
2
|
-
UA: 'uk',
|
|
3
|
-
PE: 'es',
|
|
4
|
-
CN: 'zh',
|
|
5
|
-
AU: 'en',
|
|
6
|
-
JP: 'ja',
|
|
7
|
-
KR: 'ko',
|
|
8
|
-
BR: 'pt',
|
|
9
|
-
SA: 'ar',
|
|
10
|
-
FR: 'fr',
|
|
11
|
-
DE: 'de',
|
|
12
|
-
IN: 'hi',
|
|
13
|
-
ID: 'id',
|
|
14
|
-
TH: 'th',
|
|
15
|
-
VN: 'vi',
|
|
16
|
-
TR: 'tr',
|
|
17
|
-
RU: 'ru',
|
|
18
|
-
EG: 'ar',
|
|
19
|
-
HT: 'ht',
|
|
20
|
-
PH: 'en',
|
|
21
|
-
MY: 'en',
|
|
22
|
-
MM: 'en',
|
|
23
|
-
SG: 'en',
|
|
24
|
-
ZA: 'en',
|
|
25
|
-
KE: 'sw',
|
|
26
|
-
TZ: 'sw',
|
|
27
|
-
MO: 'zh',
|
|
28
|
-
UZ: 'uz',
|
|
29
|
-
GH: 'en',
|
|
30
|
-
LA: 'lo',
|
|
31
|
-
BD: 'bn',
|
|
32
|
-
BI: 'fr',
|
|
33
|
-
US: 'en',
|
|
34
|
-
UK: 'en',
|
|
35
|
-
NZ: 'en',
|
|
36
|
-
LK: 'si',
|
|
37
|
-
BW: 'en',
|
|
38
|
-
VE: 'es',
|
|
39
|
-
};
|
|
1
|
+
export const COUNTRY_LANG={UA:"uk",PE:"es",CN:"zh",AU:"en",JP:"ja",KR:"ko",BR:"pt",SA:"ar",FR:"fr",DE:"de",IN:"hi",ID:"id",TH:"th",VN:"vi",TR:"tr",RU:"ru",EG:"ar",HT:"ht",PH:"en",MY:"en",MM:"en",SG:"en",ZA:"en",KE:"sw",TZ:"sw",MO:"zh",UZ:"uz",GH:"en",LA:"lo",BD:"bn",BI:"fr",US:"en",UK:"en",NZ:"en",LK:"si",BW:"en",VE:"es"};
|
package/dist/lib/ctfd-client.js
CHANGED
|
@@ -1,417 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { pipeline } from 'node:stream/promises';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
5
|
-
export class CTFdClient {
|
|
6
|
-
baseUrl;
|
|
7
|
-
token;
|
|
8
|
-
sessionCookie;
|
|
9
|
-
csrfNonce;
|
|
10
|
-
constructor(baseUrl, token, sessionCookie, csrfNonce) {
|
|
11
|
-
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
12
|
-
this.token = token;
|
|
13
|
-
this.sessionCookie = sessionCookie || '';
|
|
14
|
-
this.csrfNonce = csrfNonce || '';
|
|
15
|
-
}
|
|
16
|
-
getAuthHeaders() {
|
|
17
|
-
if (this.token) {
|
|
18
|
-
return { Authorization: `Token ${this.token}` };
|
|
19
|
-
}
|
|
20
|
-
if (this.sessionCookie) {
|
|
21
|
-
const headers = { Cookie: this.sessionCookie };
|
|
22
|
-
if (this.csrfNonce) {
|
|
23
|
-
headers['CSRF-Token'] = this.csrfNonce;
|
|
24
|
-
}
|
|
25
|
-
return headers;
|
|
26
|
-
}
|
|
27
|
-
return {};
|
|
28
|
-
}
|
|
29
|
-
async fetchCsrfNonce() {
|
|
30
|
-
if (this.csrfNonce)
|
|
31
|
-
return this.csrfNonce;
|
|
32
|
-
try {
|
|
33
|
-
const res = await fetch(this.baseUrl, {
|
|
34
|
-
headers: this.sessionCookie ? { Cookie: this.sessionCookie } : {},
|
|
35
|
-
});
|
|
36
|
-
const html = await res.text();
|
|
37
|
-
const match = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
|
|
38
|
-
if (match) {
|
|
39
|
-
this.csrfNonce = match[1];
|
|
40
|
-
return this.csrfNonce;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
/* ignore */
|
|
45
|
-
}
|
|
46
|
-
return '';
|
|
47
|
-
}
|
|
48
|
-
async request(method, path, body) {
|
|
49
|
-
// Auto-fetch CSRF nonce for session-based auth
|
|
50
|
-
if (this.sessionCookie && !this.csrfNonce) {
|
|
51
|
-
await this.fetchCsrfNonce();
|
|
52
|
-
}
|
|
53
|
-
const url = `${this.baseUrl}/api/v1${path}`;
|
|
54
|
-
const headers = {
|
|
55
|
-
...this.getAuthHeaders(),
|
|
56
|
-
};
|
|
57
|
-
// Content-Type policy is auth-mode-specific:
|
|
58
|
-
// · Session-cookie mode: CTFd returns 403 for a bodyless GET that carries
|
|
59
|
-
// `Content-Type: application/json` (it tries to parse an empty body), so
|
|
60
|
-
// only set it when there's an actual body — otherwise every CTFd-account
|
|
61
|
-
// `join` breaks (challenges/open/scoreboard).
|
|
62
|
-
// · Token mode: the OPPOSITE — CTFd's before_request only honors the
|
|
63
|
-
// `Authorization: Token …` header when `request.is_json`, so a bodyless
|
|
64
|
-
// GET MUST carry `Content-Type: application/json` or CTFd ignores the
|
|
65
|
-
// token and 302-redirects to /login. (Token auth takes priority in
|
|
66
|
-
// getAuthHeaders, so key off this.token here.)
|
|
67
|
-
if (body !== undefined || this.token) {
|
|
68
|
-
headers['Content-Type'] = 'application/json';
|
|
69
|
-
}
|
|
70
|
-
const res = await fetch(url, {
|
|
71
|
-
method,
|
|
72
|
-
headers,
|
|
73
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
74
|
-
// Do NOT auto-follow redirects. When the token/session is invalid or
|
|
75
|
-
// expired, CTFd (account visibility = private) 302-redirects API calls to
|
|
76
|
-
// the HTML /login page instead of returning 403 JSON. fetch's default
|
|
77
|
-
// redirect:'follow' lands on that 200 HTML page, and res.json() then throws
|
|
78
|
-
// the cryptic `Unexpected token '<', "<!DOCTYPE "...`. Catch the 3xx here.
|
|
79
|
-
redirect: 'manual',
|
|
80
|
-
});
|
|
81
|
-
// A redirect (almost always → /login) means we were not authenticated.
|
|
82
|
-
if (res.status >= 300 && res.status < 400) {
|
|
83
|
-
throw new Error('Authentication failed: token invalid or expired. Re-run `join <url>` to sign in again.');
|
|
84
|
-
}
|
|
85
|
-
if (!res.ok) {
|
|
86
|
-
const text = await res.text().catch(() => 'Unknown error');
|
|
87
|
-
// CTFd (account visibility = private) answers an unauthenticated API call
|
|
88
|
-
// with the HTML login page at 401/403 instead of a JSON error. Dumping that
|
|
89
|
-
// raw `<!DOCTYPE html>...` page tells the user nothing — surface the same
|
|
90
|
-
// actionable message as the redirect / 2xx-HTML guards. Keep the phrase
|
|
91
|
-
// "Authentication failed" so testConnection's session-mode fallback (which
|
|
92
|
-
// matches on it) still fires. The previous code path leaked the whole login
|
|
93
|
-
// page on every API call when a join produced an unauthenticated session.
|
|
94
|
-
if (text.trimStart().startsWith('<')) {
|
|
95
|
-
// CTFd returns an HTML page (not JSON) for two distinct states. The ban
|
|
96
|
-
// page carries a recognizable banner ("You have been banned from this
|
|
97
|
-
// CTF") — when we see it, say so definitively. Otherwise we can't tell a
|
|
98
|
-
// banned account from an expired session apart from the body alone (a
|
|
99
|
-
// banned account's /login still 302-redirects, so login "succeeds" but
|
|
100
|
-
// every API call 403s with HTML), so name both real fixes. (A1)
|
|
101
|
-
if (/banned/i.test(text)) {
|
|
102
|
-
throw new Error('Account banned: this account has been banned from the competition. Contact the organizer if you believe this is an error.');
|
|
103
|
-
}
|
|
104
|
-
throw new Error('Access denied (403). Either your session expired — re-run `join <url>` — or this account is banned / not enabled for the competition (try a different account, or contact the organizer).');
|
|
105
|
-
}
|
|
106
|
-
throw new Error(`CTFd API error (${res.status}): ${text}`);
|
|
107
|
-
}
|
|
108
|
-
// Guard against an HTML body slipping through on a 2xx (proxy/maintenance
|
|
109
|
-
// page). Without this, res.json() emits the same opaque parse error.
|
|
110
|
-
const contentType = res.headers.get('content-type') || '';
|
|
111
|
-
if (!contentType.includes('application/json')) {
|
|
112
|
-
const text = await res.text().catch(() => '');
|
|
113
|
-
if (text.trimStart().startsWith('<')) {
|
|
114
|
-
throw new Error('Authentication failed: server returned a login page instead of data. Re-run `join <url>`.');
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
const json = (await res.json());
|
|
118
|
-
if (json.success === false) {
|
|
119
|
-
throw new Error(`CTFd error: ${json.errors?.join(', ') || 'Unknown error'}`);
|
|
120
|
-
}
|
|
121
|
-
return json.data;
|
|
122
|
-
}
|
|
123
|
-
async testConnection() {
|
|
124
|
-
try {
|
|
125
|
-
return await this.request('GET', '/users/me');
|
|
126
|
-
}
|
|
127
|
-
catch (err) {
|
|
128
|
-
// Session mode fallback: the API may 403 or (with redirect:'manual')
|
|
129
|
-
// surface as an "Authentication failed" redirect. Either way, try scraping
|
|
130
|
-
// the profile page before giving up on a session login.
|
|
131
|
-
if (this.sessionCookie && (err.message?.includes('403') || err.message?.includes('Authentication failed'))) {
|
|
132
|
-
return this.testConnectionViaProfile();
|
|
133
|
-
}
|
|
134
|
-
throw err;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
async testConnectionViaProfile() {
|
|
138
|
-
const res = await fetch(`${this.baseUrl}/settings`, {
|
|
139
|
-
headers: { Cookie: this.sessionCookie },
|
|
140
|
-
});
|
|
141
|
-
if (!res.ok) {
|
|
142
|
-
// A banned / disabled account authenticates (login 302-redirects) but every
|
|
143
|
-
// authenticated page — including /settings — comes back 403. Surface that as
|
|
144
|
-
// a distinct, actionable state so `join` doesn't mislabel it "limited API".
|
|
145
|
-
if (res.status === 403) {
|
|
146
|
-
throw new Error('ACCOUNT_BLOCKED: signed in, but this account is banned / disabled / not enabled for the competition. Use a different account, or contact the organizer.');
|
|
147
|
-
}
|
|
148
|
-
throw new Error('Session expired or invalid.');
|
|
149
|
-
}
|
|
150
|
-
const html = await res.text();
|
|
151
|
-
// Extract user name from settings page
|
|
152
|
-
const nameMatch = html.match(/name="name"[^>]*value="([^"]+)"/) || html.match(/<input[^>]*id="name"[^>]*value="([^"]+)"/);
|
|
153
|
-
// Extract user ID from page
|
|
154
|
-
const idMatch = html.match(/user_id['":\s]+(\d+)/) || html.match(/userId['":\s]+(\d+)/);
|
|
155
|
-
// When the session is NOT authenticated, /settings redirects to the login
|
|
156
|
-
// page (HTTP 200) where neither the name input nor a user id exists. The old
|
|
157
|
-
// code then returned a fabricated {id:0, name:'User'}, so `join` reported a
|
|
158
|
-
// bogus "Connected" success and the failure only surfaced later on the first
|
|
159
|
-
// real API call. Treat "no name AND no id" as the login page → fail at join
|
|
160
|
-
// time with an actionable message.
|
|
161
|
-
if (!nameMatch && !idMatch) {
|
|
162
|
-
throw new Error('Authentication failed: session is not signed in. Re-run `join <url>` and check your username/password.');
|
|
163
|
-
}
|
|
164
|
-
const name = nameMatch?.[1] || 'User';
|
|
165
|
-
const id = idMatch ? parseInt(idMatch[1], 10) : 0;
|
|
166
|
-
// Update CSRF nonce from settings page
|
|
167
|
-
const csrfMatch = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
|
|
168
|
-
if (csrfMatch)
|
|
169
|
-
this.csrfNonce = csrfMatch[1];
|
|
170
|
-
return { id, name, score: 0, team_id: 0, country: '' };
|
|
171
|
-
}
|
|
172
|
-
async getChallenges() {
|
|
173
|
-
return this.request('GET', '/challenges');
|
|
174
|
-
}
|
|
175
|
-
async getChallenge(id) {
|
|
176
|
-
return this.request('GET', `/challenges/${id}`);
|
|
177
|
-
}
|
|
178
|
-
async submitFlag(challengeId, submission) {
|
|
179
|
-
// Session-mode POSTs require a session-bound CSRF token. join() persists the
|
|
180
|
-
// cookie but not the nonce, so a client rebuilt from config has none → CTFd
|
|
181
|
-
// rejects the submission with 403. Fetch it on demand (same as request()).
|
|
182
|
-
if (this.sessionCookie && !this.csrfNonce) {
|
|
183
|
-
await this.fetchCsrfNonce();
|
|
184
|
-
}
|
|
185
|
-
const res = await fetch(`${this.baseUrl}/api/v1/challenges/attempt`, {
|
|
186
|
-
method: 'POST',
|
|
187
|
-
headers: {
|
|
188
|
-
...this.getAuthHeaders(),
|
|
189
|
-
'Content-Type': 'application/json',
|
|
190
|
-
},
|
|
191
|
-
body: JSON.stringify({ challenge_id: challengeId, submission }),
|
|
192
|
-
});
|
|
193
|
-
if (!res.ok) {
|
|
194
|
-
const text = await res.text().catch(() => 'Unknown error');
|
|
195
|
-
if (text.trimStart().startsWith('<')) {
|
|
196
|
-
// Distinguish a ban from a dead session — same as request(). (A1)
|
|
197
|
-
if (/banned/i.test(text)) {
|
|
198
|
-
throw new Error('Account banned: this account has been banned from the competition. Your submission was not recorded. Contact the organizer if you believe this is an error.');
|
|
199
|
-
}
|
|
200
|
-
throw new Error('Authentication failed: not signed in (server returned the login page). Re-run `join <url>` and check your username/password.');
|
|
201
|
-
}
|
|
202
|
-
throw new Error(`CTFd API error (${res.status}): ${text}`);
|
|
203
|
-
}
|
|
204
|
-
const json = (await res.json());
|
|
205
|
-
return json.data;
|
|
206
|
-
}
|
|
207
|
-
async getScoreboard() {
|
|
208
|
-
return this.request('GET', '/scoreboard');
|
|
209
|
-
}
|
|
210
|
-
async getTeam() {
|
|
211
|
-
return this.request('GET', '/teams/me');
|
|
212
|
-
}
|
|
213
|
-
async getCompetitionMeta() {
|
|
214
|
-
const res = await fetch(this.baseUrl);
|
|
215
|
-
const html = await res.text();
|
|
216
|
-
const startMatch = html.match(/'start'\s*:\s*(\d+)/);
|
|
217
|
-
const endMatch = html.match(/'end'\s*:\s*(\d+)/);
|
|
218
|
-
const modeMatch = html.match(/'userMode'\s*:\s*"([^"]+)"/);
|
|
219
|
-
const csrfMatch = html.match(/'csrfNonce'\s*:\s*"([^"]+)"/);
|
|
220
|
-
return {
|
|
221
|
-
start: startMatch ? parseInt(startMatch[1], 10) : null,
|
|
222
|
-
end: endMatch ? parseInt(endMatch[1], 10) : null,
|
|
223
|
-
userMode: modeMatch?.[1] || 'users',
|
|
224
|
-
csrfNonce: csrfMatch?.[1] || '',
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
async getChallengeFiles(id) {
|
|
228
|
-
const challenge = await this.getChallenge(id);
|
|
229
|
-
return challenge.files || [];
|
|
230
|
-
}
|
|
231
|
-
async downloadFile(filePath, destDir) {
|
|
232
|
-
mkdirSync(destDir, { recursive: true });
|
|
233
|
-
const url = filePath.startsWith('http') ? filePath : `${this.baseUrl}/${filePath.replace(/^\//, '')}`;
|
|
234
|
-
const res = await fetch(url, {
|
|
235
|
-
headers: this.getAuthHeaders(),
|
|
236
|
-
redirect: 'follow',
|
|
237
|
-
});
|
|
238
|
-
if (!res.ok || !res.body) {
|
|
239
|
-
throw new Error(`Failed to download: ${url}`);
|
|
240
|
-
}
|
|
241
|
-
const rawName = filePath.split('/').pop() || 'file';
|
|
242
|
-
const fileName = rawName.split('?')[0];
|
|
243
|
-
const destPath = join(destDir, fileName);
|
|
244
|
-
const fileStream = createWriteStream(destPath);
|
|
245
|
-
await pipeline(Readable.fromWeb(res.body), fileStream);
|
|
246
|
-
return destPath;
|
|
247
|
-
}
|
|
248
|
-
async getTokenViaIcoaApi(username, password) {
|
|
249
|
-
const body = JSON.stringify({ name: username, password });
|
|
250
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
251
|
-
// Try via nginx proxy first (same origin, no port)
|
|
252
|
-
try {
|
|
253
|
-
const res = await fetch(`${this.baseUrl}/api/icoa/token`, {
|
|
254
|
-
method: 'POST',
|
|
255
|
-
headers,
|
|
256
|
-
body,
|
|
257
|
-
signal: AbortSignal.timeout(5000),
|
|
258
|
-
});
|
|
259
|
-
if (res.ok) {
|
|
260
|
-
const json = (await res.json());
|
|
261
|
-
if (json.success && json.data?.token)
|
|
262
|
-
return json.data.token;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
/* proxy not available */
|
|
267
|
-
}
|
|
268
|
-
// Fallback: direct port 9090
|
|
269
|
-
try {
|
|
270
|
-
const res = await fetch(`${this.baseUrl}:9090/api/icoa/token`, {
|
|
271
|
-
method: 'POST',
|
|
272
|
-
headers,
|
|
273
|
-
body,
|
|
274
|
-
signal: AbortSignal.timeout(5000),
|
|
275
|
-
});
|
|
276
|
-
if (res.ok) {
|
|
277
|
-
const json = (await res.json());
|
|
278
|
-
if (json.success && json.data?.token)
|
|
279
|
-
return json.data.token;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
/* API not available */
|
|
284
|
-
}
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
// Verify a token actually authenticates against CTFd. The ICOA token API
|
|
288
|
-
// (handle_token) returns whatever token row exists for a user WITHOUT checking
|
|
289
|
-
// expiration, so a stale token comes back looking valid. Probe /users/me with
|
|
290
|
-
// it (manual redirect → a 302 to /login = not authenticated) before trusting
|
|
291
|
-
// it; otherwise the caller would short-circuit a working session login.
|
|
292
|
-
//
|
|
293
|
-
// CTFd's before_request token loader only honors an `Authorization: Token …`
|
|
294
|
-
// header when `request.is_json` is true — i.e. the request MUST carry
|
|
295
|
-
// `Content-Type: application/json`. Without it CTFd silently ignores the token
|
|
296
|
-
// and 302-redirects this @authed_only endpoint to /login, so a perfectly valid
|
|
297
|
-
// token looks unauthenticated and every join fell through to session mode.
|
|
298
|
-
async tokenAuthenticates(token) {
|
|
299
|
-
try {
|
|
300
|
-
const res = await fetch(`${this.baseUrl}/api/v1/users/me`, {
|
|
301
|
-
headers: { Authorization: `Token ${token}`, 'Content-Type': 'application/json' },
|
|
302
|
-
redirect: 'manual',
|
|
303
|
-
signal: AbortSignal.timeout(5000),
|
|
304
|
-
});
|
|
305
|
-
if (res.status !== 200)
|
|
306
|
-
return false;
|
|
307
|
-
if (!(res.headers.get('content-type') || '').includes('application/json'))
|
|
308
|
-
return false;
|
|
309
|
-
const json = (await res.json());
|
|
310
|
-
return json?.success === true;
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
async loginWithCredentials(username, password) {
|
|
317
|
-
// Try ICOA token API first (fastest path). Only trust the token if it really
|
|
318
|
-
// authenticates — a stale token row (e.g. account ppdd) would otherwise lock
|
|
319
|
-
// the user out, since we'd skip the standard session login that still works.
|
|
320
|
-
const icoaToken = await this.getTokenViaIcoaApi(username, password);
|
|
321
|
-
if (icoaToken && (await this.tokenAuthenticates(icoaToken))) {
|
|
322
|
-
return { token: icoaToken, session: '', csrf: '' };
|
|
323
|
-
}
|
|
324
|
-
// icoaToken missing or stale → fall through to the standard CTFd /login flow,
|
|
325
|
-
// which re-establishes a working session and mints a fresh API token.
|
|
326
|
-
// Fallback: standard CTFd login flow
|
|
327
|
-
// Step 1: GET /login to get nonce
|
|
328
|
-
const loginPageRes = await fetch(`${this.baseUrl}/login`);
|
|
329
|
-
const loginHtml = await loginPageRes.text();
|
|
330
|
-
const nonceMatch = loginHtml.match(/name="nonce"[^>]*value="([^"]+)"/) ||
|
|
331
|
-
loginHtml.match(/value="([^"]+)"[^>]*name="nonce"/) ||
|
|
332
|
-
loginHtml.match(/id="nonce"[^>]*value="([^"]+)"/) ||
|
|
333
|
-
loginHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
|
|
334
|
-
const nonce = nonceMatch?.[1] || '';
|
|
335
|
-
if (!nonce) {
|
|
336
|
-
throw new Error('Could not extract CSRF nonce from login page.');
|
|
337
|
-
}
|
|
338
|
-
const cookies = loginPageRes.headers.getSetCookie?.() || [];
|
|
339
|
-
const cookieStr = cookies.map((c) => c.split(';')[0]).join('; ');
|
|
340
|
-
// Step 2: POST /login with credentials
|
|
341
|
-
const loginRes = await fetch(`${this.baseUrl}/login`, {
|
|
342
|
-
method: 'POST',
|
|
343
|
-
headers: {
|
|
344
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
345
|
-
Cookie: cookieStr,
|
|
346
|
-
},
|
|
347
|
-
body: new URLSearchParams({ name: username, password, nonce, _submit: 'Submit' }),
|
|
348
|
-
redirect: 'manual',
|
|
349
|
-
});
|
|
350
|
-
const loginCookies = loginRes.headers.getSetCookie?.() || [];
|
|
351
|
-
// Dedupe by cookie name, letting the post-login Set-Cookie win. Otherwise
|
|
352
|
-
// we'd send `session=<pre-login>; session=<post-login>` and CTFd honours the
|
|
353
|
-
// first (unauthenticated) one, serving the login page (200 HTML) for every
|
|
354
|
-
// API call → "failed to load challenges" for CTFd-account logins.
|
|
355
|
-
const cookieJar = new Map();
|
|
356
|
-
for (const c of [...cookies, ...loginCookies]) {
|
|
357
|
-
const pair = c.split(';')[0];
|
|
358
|
-
const eq = pair.indexOf('=');
|
|
359
|
-
if (eq > 0)
|
|
360
|
-
cookieJar.set(pair.slice(0, eq), pair.slice(eq + 1));
|
|
361
|
-
}
|
|
362
|
-
const allCookies = [...cookieJar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
363
|
-
// A successful CTFd login is a 3xx redirect away from /login (→ /challenges
|
|
364
|
-
// or /). A wrong password re-renders the login page at HTTP 200 with NO
|
|
365
|
-
// redirect ("Your username or password is incorrect"). The old check only
|
|
366
|
-
// caught a /login redirect, so a typo'd password slipped through as a fake
|
|
367
|
-
// success: an unauthenticated session that then failed downstream with the
|
|
368
|
-
// cryptic "Connected (session mode — limited API)" + `<!DOCTYPE` on every
|
|
369
|
-
// API call. Require a real redirect that isn't back to /login.
|
|
370
|
-
const location = loginRes.headers.get('location') || '';
|
|
371
|
-
const redirected = loginRes.status >= 300 && loginRes.status < 400;
|
|
372
|
-
if (!redirected || location.includes('/login')) {
|
|
373
|
-
throw new Error('Invalid username or password.');
|
|
374
|
-
}
|
|
375
|
-
// Step 3: Try to generate API token (some CTFd instances allow it)
|
|
376
|
-
try {
|
|
377
|
-
const settingsRes = await fetch(`${this.baseUrl}/settings`, {
|
|
378
|
-
headers: { Cookie: allCookies },
|
|
379
|
-
});
|
|
380
|
-
const settingsHtml = await settingsRes.text();
|
|
381
|
-
const csrfMatch = settingsHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
|
|
382
|
-
const csrfNonce = csrfMatch?.[1] || nonce;
|
|
383
|
-
const tokenRes = await fetch(`${this.baseUrl}/api/v1/tokens`, {
|
|
384
|
-
method: 'POST',
|
|
385
|
-
headers: {
|
|
386
|
-
'Content-Type': 'application/json',
|
|
387
|
-
Cookie: allCookies,
|
|
388
|
-
'CSRF-Token': csrfNonce,
|
|
389
|
-
},
|
|
390
|
-
body: JSON.stringify({ expiration: '2026-12-31T23:59:59+00:00' }),
|
|
391
|
-
});
|
|
392
|
-
if (tokenRes.ok) {
|
|
393
|
-
const tokenJson = (await tokenRes.json());
|
|
394
|
-
if (tokenJson.success && tokenJson.data?.value) {
|
|
395
|
-
return { token: tokenJson.data.value, session: '', csrf: '' };
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
/* Token generation not available, use session */
|
|
401
|
-
}
|
|
402
|
-
// Fallback: use session cookie + CSRF nonce
|
|
403
|
-
// Get CSRF nonce from the main page
|
|
404
|
-
let csrf = '';
|
|
405
|
-
try {
|
|
406
|
-
const mainRes = await fetch(`${this.baseUrl}/challenges`, { headers: { Cookie: allCookies } });
|
|
407
|
-
const mainHtml = await mainRes.text();
|
|
408
|
-
const csrfMatch = mainHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
|
|
409
|
-
if (csrfMatch)
|
|
410
|
-
csrf = csrfMatch[1];
|
|
411
|
-
}
|
|
412
|
-
catch {
|
|
413
|
-
/* ignore */
|
|
414
|
-
}
|
|
415
|
-
return { token: '', session: allCookies, csrf };
|
|
416
|
-
}
|
|
417
|
-
}
|
|
1
|
+
import{createWriteStream as t,mkdirSync as e}from"node:fs";import{join as s}from"node:path";import{pipeline as n}from"node:stream/promises";import{Readable as o}from"node:stream";export class CTFdClient{baseUrl;token;sessionCookie;csrfNonce;constructor(t,e,s,n){this.baseUrl=t.replace(/\/+$/,""),this.token=e,this.sessionCookie=s||"",this.csrfNonce=n||""}getAuthHeaders(){if(this.token)return{Authorization:`Token ${this.token}`};if(this.sessionCookie){const t={Cookie:this.sessionCookie};return this.csrfNonce&&(t["CSRF-Token"]=this.csrfNonce),t}return{}}async fetchCsrfNonce(){if(this.csrfNonce)return this.csrfNonce;try{const t=await fetch(this.baseUrl,{headers:this.sessionCookie?{Cookie:this.sessionCookie}:{}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);if(e)return this.csrfNonce=e[1],this.csrfNonce}catch{}return""}async request(t,e,s){this.sessionCookie&&!this.csrfNonce&&await this.fetchCsrfNonce();const n=`${this.baseUrl}/api/v1${e}`,o={...this.getAuthHeaders()};(void 0!==s||this.token)&&(o["Content-Type"]="application/json");const i=await fetch(n,{method:t,headers:o,body:void 0!==s?JSON.stringify(s):void 0,redirect:"manual"});if(i.status>=300&&i.status<400)throw new Error("Authentication failed: token invalid or expired. Re-run `join <url>` to sign in again.");if(!i.ok){const t=await i.text().catch(()=>"Unknown error");if(t.trimStart().startsWith("<")){if(/banned/i.test(t))throw new Error("Account banned: this account has been banned from the competition. Contact the organizer if you believe this is an error.");throw new Error("Access denied (403). Either your session expired — re-run `join <url>` — or this account is banned / not enabled for the competition (try a different account, or contact the organizer).")}throw new Error(`CTFd API error (${i.status}): ${t}`)}if(!(i.headers.get("content-type")||"").includes("application/json")&&(await i.text().catch(()=>"")).trimStart().startsWith("<"))throw new Error("Authentication failed: server returned a login page instead of data. Re-run `join <url>`.");const r=await i.json();if(!1===r.success)throw new Error(`CTFd error: ${r.errors?.join(", ")||"Unknown error"}`);return r.data}async testConnection(){try{return await this.request("GET","/users/me")}catch(t){if(this.sessionCookie&&(t.message?.includes("403")||t.message?.includes("Authentication failed")))return this.testConnectionViaProfile();throw t}}async testConnectionViaProfile(){const t=await fetch(`${this.baseUrl}/settings`,{headers:{Cookie:this.sessionCookie}});if(!t.ok){if(403===t.status)throw new Error("ACCOUNT_BLOCKED: signed in, but this account is banned / disabled / not enabled for the competition. Use a different account, or contact the organizer.");throw new Error("Session expired or invalid.")}const e=await t.text(),s=e.match(/name="name"[^>]*value="([^"]+)"/)||e.match(/<input[^>]*id="name"[^>]*value="([^"]+)"/),n=e.match(/user_id['":\s]+(\d+)/)||e.match(/userId['":\s]+(\d+)/);if(!s&&!n)throw new Error("Authentication failed: session is not signed in. Re-run `join <url>` and check your username/password.");const o=s?.[1]||"User",i=n?parseInt(n[1],10):0,r=e.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);return r&&(this.csrfNonce=r[1]),{id:i,name:o,score:0,team_id:0,country:""}}async getChallenges(){return this.request("GET","/challenges")}async getChallenge(t){return this.request("GET",`/challenges/${t}`)}async submitFlag(t,e){this.sessionCookie&&!this.csrfNonce&&await this.fetchCsrfNonce();const s=await fetch(`${this.baseUrl}/api/v1/challenges/attempt`,{method:"POST",headers:{...this.getAuthHeaders(),"Content-Type":"application/json"},body:JSON.stringify({challenge_id:t,submission:e})});if(!s.ok){const t=await s.text().catch(()=>"Unknown error");if(t.trimStart().startsWith("<")){if(/banned/i.test(t))throw new Error("Account banned: this account has been banned from the competition. Your submission was not recorded. Contact the organizer if you believe this is an error.");throw new Error("Authentication failed: not signed in (server returned the login page). Re-run `join <url>` and check your username/password.")}throw new Error(`CTFd API error (${s.status}): ${t}`)}return(await s.json()).data}async getScoreboard(){return this.request("GET","/scoreboard")}async getTeam(){return this.request("GET","/teams/me")}async getCompetitionMeta(){const t=await fetch(this.baseUrl),e=await t.text(),s=e.match(/'start'\s*:\s*(\d+)/),n=e.match(/'end'\s*:\s*(\d+)/),o=e.match(/'userMode'\s*:\s*"([^"]+)"/),i=e.match(/'csrfNonce'\s*:\s*"([^"]+)"/);return{start:s?parseInt(s[1],10):null,end:n?parseInt(n[1],10):null,userMode:o?.[1]||"users",csrfNonce:i?.[1]||""}}async getChallengeFiles(t){return(await this.getChallenge(t)).files||[]}async downloadFile(i,r){e(r,{recursive:!0});const a=i.startsWith("http")?i:`${this.baseUrl}/${i.replace(/^\//,"")}`,c=await fetch(a,{headers:this.getAuthHeaders(),redirect:"follow"});if(!c.ok||!c.body)throw new Error(`Failed to download: ${a}`);const h=(i.split("/").pop()||"file").split("?")[0],d=s(r,h),u=t(d);return await n(o.fromWeb(c.body),u),d}async getTokenViaIcoaApi(t,e){const s=JSON.stringify({name:t,password:e}),n={"Content-Type":"application/json"};try{const t=await fetch(`${this.baseUrl}/api/icoa/token`,{method:"POST",headers:n,body:s,signal:AbortSignal.timeout(5e3)});if(t.ok){const e=await t.json();if(e.success&&e.data?.token)return e.data.token}}catch{}try{const t=await fetch(`${this.baseUrl}:9090/api/icoa/token`,{method:"POST",headers:n,body:s,signal:AbortSignal.timeout(5e3)});if(t.ok){const e=await t.json();if(e.success&&e.data?.token)return e.data.token}}catch{}return null}async tokenAuthenticates(t){try{const e=await fetch(`${this.baseUrl}/api/v1/users/me`,{headers:{Authorization:`Token ${t}`,"Content-Type":"application/json"},redirect:"manual",signal:AbortSignal.timeout(5e3)});if(200!==e.status)return!1;if(!(e.headers.get("content-type")||"").includes("application/json"))return!1;const s=await e.json();return!0===s?.success}catch{return!1}}async loginWithCredentials(t,e){const s=await this.getTokenViaIcoaApi(t,e);if(s&&await this.tokenAuthenticates(s))return{token:s,session:"",csrf:""};const n=await fetch(`${this.baseUrl}/login`),o=await n.text(),i=o.match(/name="nonce"[^>]*value="([^"]+)"/)||o.match(/value="([^"]+)"[^>]*name="nonce"/)||o.match(/id="nonce"[^>]*value="([^"]+)"/)||o.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/),r=i?.[1]||"";if(!r)throw new Error("Could not extract CSRF nonce from login page.");const a=n.headers.getSetCookie?.()||[],c=a.map(t=>t.split(";")[0]).join("; "),h=await fetch(`${this.baseUrl}/login`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Cookie:c},body:new URLSearchParams({name:t,password:e,nonce:r,_submit:"Submit"}),redirect:"manual"}),d=h.headers.getSetCookie?.()||[],u=new Map;for(const t of[...a,...d]){const e=t.split(";")[0],s=e.indexOf("=");s>0&&u.set(e.slice(0,s),e.slice(s+1))}const l=[...u.entries()].map(([t,e])=>`${t}=${e}`).join("; "),f=h.headers.get("location")||"";if(!(h.status>=300&&h.status<400)||f.includes("/login"))throw new Error("Invalid username or password.");try{const t=await fetch(`${this.baseUrl}/settings`,{headers:{Cookie:l}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/),s=e?.[1]||r,n=await fetch(`${this.baseUrl}/api/v1/tokens`,{method:"POST",headers:{"Content-Type":"application/json",Cookie:l,"CSRF-Token":s},body:JSON.stringify({expiration:"2026-12-31T23:59:59+00:00"})});if(n.ok){const t=await n.json();if(t.success&&t.data?.value)return{token:t.data.value,session:"",csrf:""}}}catch{}let w="";try{const t=await fetch(`${this.baseUrl}/challenges`,{headers:{Cookie:l}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);e&&(w=e[1])}catch{}return{token:"",session:l,csrf:w}}}
|