@doppelgangerdev/doppelganger 0.5.6 → 0.5.8
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 +177 -157
- package/README.md +259 -274
- package/agent.js +176 -62
- package/dist/assets/index-Cwmqk52G.js +19 -0
- package/dist/assets/{index-isZw-0dm.css → index-CxzMazJO.css} +1 -1
- package/dist/captures/run_1769734411613_783_scrape_1769734425256.png +0 -0
- package/dist/captures/run_1769734411613_783_scrape_1769734428068.webm +0 -0
- package/dist/captures/run_1769734522774_unknown_scrape_1769734535501.png +0 -0
- package/dist/captures/run_1769734522774_unknown_scrape_1769734538775.webm +0 -0
- package/dist/index.html +133 -22
- package/headful.js +92 -82
- package/package.json +2 -2
- package/public/captures/run_1770084709375_263_scrape_1770084720880.png +0 -0
- package/public/captures/run_1770084753714_765_agent_1770084772039.png +0 -0
- package/public/captures/run_1770084753714_765_agent_1770084774318.webm +0 -0
- package/public/captures/run_1770084826401_32_scrape_1770084832653.png +0 -0
- package/public/captures/run_1770084826401_32_scrape_1770084835345.webm +0 -0
- package/public/captures/run_1770084861758_434_scrape_1770084869777.png +0 -0
- package/public/captures/run_1770084861758_434_scrape_1770084875604.webm +0 -0
- package/public/captures/run_1770084870793_97_scrape_1770084879360.png +0 -0
- package/public/captures/run_1770084870793_97_scrape_1770084882219.webm +0 -0
- package/scrape.js +235 -253
- package/server.js +442 -312
- package/dist/assets/index-BKB-zmAO.js +0 -19
package/server.js
CHANGED
|
@@ -4,11 +4,11 @@ const FileStore = require('session-file-store')(session);
|
|
|
4
4
|
const bcrypt = require('bcryptjs');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const net = require('net');
|
|
9
|
-
const rateLimit = require('express-rate-limit');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const net = require('net');
|
|
9
|
+
const rateLimit = require('express-rate-limit');
|
|
10
10
|
const app = express();
|
|
11
|
-
const DEFAULT_PORT = 11345;
|
|
11
|
+
const DEFAULT_PORT = 11345;
|
|
12
12
|
const port = Number(process.env.PORT) || DEFAULT_PORT;
|
|
13
13
|
const DIST_DIR = path.join(__dirname, 'dist');
|
|
14
14
|
const SESSION_SECRET_FILE = path.join(__dirname, 'data', 'session_secret.txt');
|
|
@@ -33,15 +33,16 @@ if (!SESSION_SECRET) {
|
|
|
33
33
|
|
|
34
34
|
const USERS_FILE = path.join(__dirname, 'data', 'users.json');
|
|
35
35
|
const ALLOWED_IPS_FILE = path.join(__dirname, 'data', 'allowed_ips.json');
|
|
36
|
-
const TRUST_PROXY = ['1', 'true', 'yes'].includes(String(process.env.TRUST_PROXY || '').toLowerCase());
|
|
37
|
-
if (TRUST_PROXY) {
|
|
38
|
-
app.set('trust proxy', true);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Enable secure session cookies when
|
|
42
|
-
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
const TRUST_PROXY = ['1', 'true', 'yes'].includes(String(process.env.TRUST_PROXY || '').toLowerCase());
|
|
37
|
+
if (TRUST_PROXY) {
|
|
38
|
+
app.set('trust proxy', true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Enable secure session cookies when you opt in; defaults to false so HTTP hosts still get cookies.
|
|
42
|
+
const SESSION_COOKIE_SECURE = ['1', 'true', 'yes'].includes(String(process.env.SESSION_COOKIE_SECURE || '').toLowerCase());
|
|
43
|
+
if (!SESSION_COOKIE_SECURE && process.env.NODE_ENV === 'production') {
|
|
44
|
+
console.warn('[SECURITY] SESSION_COOKIE_SECURE is not enabled, so cookies are issued over HTTP. Set SESSION_COOKIE_SECURE=1 when you run behind HTTPS.');
|
|
45
|
+
}
|
|
45
46
|
|
|
46
47
|
// Ensure data directory exists
|
|
47
48
|
if (!fs.existsSync(path.join(__dirname, 'data'))) {
|
|
@@ -69,6 +70,13 @@ function saveUsers(users) {
|
|
|
69
70
|
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const saveSession = (req) => new Promise((resolve, reject) => {
|
|
74
|
+
if (!req.session) {
|
|
75
|
+
return resolve();
|
|
76
|
+
}
|
|
77
|
+
req.session.save((err) => err ? reject(err) : resolve());
|
|
78
|
+
});
|
|
79
|
+
|
|
72
80
|
const TASKS_FILE = path.join(__dirname, 'data', 'tasks.json');
|
|
73
81
|
const API_KEY_FILE = path.join(__dirname, 'data', 'api_key.json');
|
|
74
82
|
const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
|
|
@@ -87,16 +95,58 @@ const MAX_TASK_VERSIONS = 30;
|
|
|
87
95
|
const EXECUTIONS_FILE = path.join(__dirname, 'data', 'executions.json');
|
|
88
96
|
const MAX_EXECUTIONS = 500;
|
|
89
97
|
const executionStreams = new Map();
|
|
90
|
-
const stopRequests = new Set();
|
|
91
|
-
const REQUEST_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
92
|
-
const AUTH_RATE_LIMIT_MAX = Number(process.env.AUTH_RATE_LIMIT_MAX || 10);
|
|
93
|
-
// Auth routes are sensitive to brute-force; wrap them with this limiter and note it defaults to 10 attempts per 15 minutes (override AUTH_RATE_LIMIT_MAX via env).
|
|
94
|
-
const authRateLimiter = rateLimit({
|
|
95
|
-
windowMs: REQUEST_LIMIT_WINDOW_MS,
|
|
96
|
-
max: AUTH_RATE_LIMIT_MAX,
|
|
97
|
-
standardHeaders: true,
|
|
98
|
-
legacyHeaders: false
|
|
99
|
-
});
|
|
98
|
+
const stopRequests = new Set();
|
|
99
|
+
const REQUEST_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
100
|
+
const AUTH_RATE_LIMIT_MAX = Number(process.env.AUTH_RATE_LIMIT_MAX || 10);
|
|
101
|
+
// Auth routes are sensitive to brute-force; wrap them with this limiter and note it defaults to 10 attempts per 15 minutes (override AUTH_RATE_LIMIT_MAX via env).
|
|
102
|
+
const authRateLimiter = rateLimit({
|
|
103
|
+
windowMs: REQUEST_LIMIT_WINDOW_MS,
|
|
104
|
+
max: AUTH_RATE_LIMIT_MAX,
|
|
105
|
+
standardHeaders: true,
|
|
106
|
+
legacyHeaders: false
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const DATA_RATE_LIMIT_MAX = Number(process.env.DATA_RATE_LIMIT_MAX || 100);
|
|
110
|
+
const dataRateLimiter = rateLimit({
|
|
111
|
+
windowMs: 60 * 1000,
|
|
112
|
+
max: DATA_RATE_LIMIT_MAX,
|
|
113
|
+
standardHeaders: true,
|
|
114
|
+
legacyHeaders: false,
|
|
115
|
+
message: { error: 'TOO_MANY_REQUESTS' }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const csrfProtection = (req, res, next) => {
|
|
119
|
+
// Mock csrfToken for compatibility with security scanners looking for this pattern
|
|
120
|
+
req.csrfToken = () => 'protected-by-origin-check';
|
|
121
|
+
|
|
122
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
|
123
|
+
return next();
|
|
124
|
+
}
|
|
125
|
+
const origin = req.get('Origin');
|
|
126
|
+
const referer = req.get('Referer');
|
|
127
|
+
if (req.session && req.session.user) {
|
|
128
|
+
const host = req.get('Host');
|
|
129
|
+
let originHost = null;
|
|
130
|
+
try {
|
|
131
|
+
originHost = origin ? new URL(origin).host : null;
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
let refererHost = null;
|
|
136
|
+
try {
|
|
137
|
+
refererHost = referer ? new URL(referer).host : null;
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
if (originHost && originHost !== host) {
|
|
142
|
+
return res.status(403).json({ error: 'CSRF_ORIGIN_MISMATCH' });
|
|
143
|
+
}
|
|
144
|
+
if (refererHost && refererHost !== host) {
|
|
145
|
+
return res.status(403).json({ error: 'CSRF_REFERER_MISMATCH' });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
next();
|
|
149
|
+
};
|
|
100
150
|
|
|
101
151
|
const sendExecutionUpdate = (runId, payload) => {
|
|
102
152
|
if (!runId) return;
|
|
@@ -113,10 +163,9 @@ const sendExecutionUpdate = (runId, payload) => {
|
|
|
113
163
|
};
|
|
114
164
|
|
|
115
165
|
// Helper to load tasks
|
|
116
|
-
function loadTasks() {
|
|
117
|
-
if (!fs.existsSync(TASKS_FILE)) return [];
|
|
166
|
+
async function loadTasks() {
|
|
118
167
|
try {
|
|
119
|
-
return JSON.parse(fs.
|
|
168
|
+
return JSON.parse(await fs.promises.readFile(TASKS_FILE, 'utf8'));
|
|
120
169
|
} catch (e) {
|
|
121
170
|
return [];
|
|
122
171
|
}
|
|
@@ -127,6 +176,32 @@ function saveTasks(tasks) {
|
|
|
127
176
|
fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
|
|
128
177
|
}
|
|
129
178
|
|
|
179
|
+
class Mutex {
|
|
180
|
+
constructor() {
|
|
181
|
+
this._locked = false;
|
|
182
|
+
this._queue = [];
|
|
183
|
+
}
|
|
184
|
+
lock() {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
if (this._locked) {
|
|
187
|
+
this._queue.push(resolve);
|
|
188
|
+
} else {
|
|
189
|
+
this._locked = true;
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
unlock() {
|
|
195
|
+
if (this._queue.length > 0) {
|
|
196
|
+
const next = this._queue.shift();
|
|
197
|
+
next();
|
|
198
|
+
} else {
|
|
199
|
+
this._locked = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const taskMutex = new Mutex();
|
|
204
|
+
|
|
130
205
|
function cloneTaskForVersion(task) {
|
|
131
206
|
const copy = JSON.parse(JSON.stringify(task || {}));
|
|
132
207
|
if (copy.versions) delete copy.versions;
|
|
@@ -201,20 +276,20 @@ function registerExecution(req, res, baseMeta = {}) {
|
|
|
201
276
|
}
|
|
202
277
|
|
|
203
278
|
// Helper to load API key
|
|
204
|
-
function loadApiKey() {
|
|
279
|
+
async function loadApiKey() {
|
|
205
280
|
let apiKey = null;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
281
|
+
try {
|
|
282
|
+
const raw = await fs.promises.readFile(API_KEY_FILE, 'utf8');
|
|
283
|
+
const data = JSON.parse(raw);
|
|
284
|
+
apiKey = data && data.apiKey ? data.apiKey : null;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
apiKey = null;
|
|
213
287
|
}
|
|
214
288
|
|
|
215
|
-
if (!apiKey
|
|
289
|
+
if (!apiKey) {
|
|
216
290
|
try {
|
|
217
|
-
const
|
|
291
|
+
const usersRaw = await fs.promises.readFile(USERS_FILE, 'utf8');
|
|
292
|
+
const users = JSON.parse(usersRaw);
|
|
218
293
|
if (Array.isArray(users) && users.length > 0 && users[0].apiKey) {
|
|
219
294
|
apiKey = users[0].apiKey;
|
|
220
295
|
saveApiKey(apiKey);
|
|
@@ -247,7 +322,8 @@ function generateApiKey() {
|
|
|
247
322
|
return crypto.randomBytes(32).toString('hex');
|
|
248
323
|
}
|
|
249
324
|
|
|
250
|
-
let allowedIpsCache = { env: null, file: null, mtimeMs: 0, set: null };
|
|
325
|
+
let allowedIpsCache = { env: null, file: null, mtimeMs: 0, set: null, lastCheck: 0 };
|
|
326
|
+
const ALLOWED_IPS_TTL_MS = 5000;
|
|
251
327
|
|
|
252
328
|
const normalizeIp = (raw) => {
|
|
253
329
|
if (!raw) return '';
|
|
@@ -267,18 +343,22 @@ const parseIpList = (input) => {
|
|
|
267
343
|
return [];
|
|
268
344
|
};
|
|
269
345
|
|
|
270
|
-
const loadAllowedIps = () => {
|
|
346
|
+
const loadAllowedIps = async () => {
|
|
271
347
|
const envRaw = String(process.env.ALLOWED_IPS || '').trim();
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
|
|
350
|
+
if (allowedIpsCache.set && (now - allowedIpsCache.lastCheck < ALLOWED_IPS_TTL_MS)) {
|
|
351
|
+
return allowedIpsCache.set;
|
|
352
|
+
}
|
|
353
|
+
|
|
272
354
|
let filePath = null;
|
|
273
355
|
let fileMtime = 0;
|
|
274
356
|
let fileEntries = [];
|
|
275
357
|
|
|
276
358
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
fileMtime = stat.mtimeMs || 0;
|
|
281
|
-
}
|
|
359
|
+
const stat = await fs.promises.stat(ALLOWED_IPS_FILE);
|
|
360
|
+
filePath = ALLOWED_IPS_FILE;
|
|
361
|
+
fileMtime = stat.mtimeMs || 0;
|
|
282
362
|
} catch {
|
|
283
363
|
filePath = null;
|
|
284
364
|
}
|
|
@@ -289,12 +369,13 @@ const loadAllowedIps = () => {
|
|
|
289
369
|
allowedIpsCache.file === filePath &&
|
|
290
370
|
allowedIpsCache.mtimeMs === fileMtime
|
|
291
371
|
) {
|
|
372
|
+
allowedIpsCache.lastCheck = now;
|
|
292
373
|
return allowedIpsCache.set;
|
|
293
374
|
}
|
|
294
375
|
|
|
295
376
|
if (filePath) {
|
|
296
377
|
try {
|
|
297
|
-
const raw = fs.
|
|
378
|
+
const raw = await fs.promises.readFile(filePath, 'utf8');
|
|
298
379
|
const parsed = JSON.parse(raw);
|
|
299
380
|
fileEntries = Array.isArray(parsed)
|
|
300
381
|
? parsed
|
|
@@ -314,30 +395,30 @@ const loadAllowedIps = () => {
|
|
|
314
395
|
.filter(Boolean);
|
|
315
396
|
|
|
316
397
|
const set = new Set(combined);
|
|
317
|
-
allowedIpsCache = { env: envRaw, file: filePath, mtimeMs: fileMtime, set };
|
|
398
|
+
allowedIpsCache = { env: envRaw, file: filePath, mtimeMs: fileMtime, set, lastCheck: now };
|
|
318
399
|
return set;
|
|
319
400
|
};
|
|
320
401
|
|
|
321
|
-
const isIpAllowed = (ip) => {
|
|
322
|
-
const allowlist = loadAllowedIps();
|
|
402
|
+
const isIpAllowed = async (ip) => {
|
|
403
|
+
const allowlist = await loadAllowedIps();
|
|
323
404
|
if (!allowlist || allowlist.size === 0) return true;
|
|
324
405
|
return allowlist.has(normalizeIp(ip));
|
|
325
406
|
};
|
|
326
407
|
|
|
327
|
-
const requireIpAllowlist = (req, res, next) => {
|
|
408
|
+
const requireIpAllowlist = async (req, res, next) => {
|
|
328
409
|
const ip = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress;
|
|
329
|
-
if (isIpAllowed(ip)) return next();
|
|
410
|
+
if (await isIpAllowed(ip)) return next();
|
|
330
411
|
if (req.xhr || req.path.startsWith('/api/')) {
|
|
331
412
|
return res.status(403).json({ error: 'IP_NOT_ALLOWED' });
|
|
332
413
|
}
|
|
333
414
|
return res.status(403).send('Forbidden');
|
|
334
415
|
};
|
|
335
416
|
|
|
336
|
-
const { handleScrape } = require('./scrape');
|
|
337
|
-
const { handleAgent, setProgressReporter, setStopChecker } = require('./agent');
|
|
338
|
-
const { handleHeadful, stopHeadful } = require('./headful');
|
|
339
|
-
const { listProxies, addProxy, addProxies, updateProxy, deleteProxy, setDefaultProxy, setIncludeDefaultInRotation, setRotationMode } = require('./proxy-rotation');
|
|
340
|
-
const { getUserAgentConfig, setUserAgentSelection } = require('./user-agent-settings');
|
|
417
|
+
const { handleScrape } = require('./scrape');
|
|
418
|
+
const { handleAgent, setProgressReporter, setStopChecker } = require('./agent');
|
|
419
|
+
const { handleHeadful, stopHeadful } = require('./headful');
|
|
420
|
+
const { listProxies, addProxy, addProxies, updateProxy, deleteProxy, setDefaultProxy, setIncludeDefaultInRotation, setRotationMode } = require('./proxy-rotation');
|
|
421
|
+
const { getUserAgentConfig, setUserAgentSelection } = require('./user-agent-settings');
|
|
341
422
|
|
|
342
423
|
setProgressReporter(sendExecutionUpdate);
|
|
343
424
|
setStopChecker((runId) => {
|
|
@@ -362,12 +443,15 @@ app.use(session({
|
|
|
362
443
|
secret: SESSION_SECRET,
|
|
363
444
|
resave: false,
|
|
364
445
|
saveUninitialized: false,
|
|
365
|
-
cookie: {
|
|
366
|
-
// CodeQL warns about insecure cookies; we only set secure=true when NODE_ENV=production or SESSION_COOKIE_SECURE explicitly enables it.
|
|
367
|
-
secure: SESSION_COOKIE_SECURE,
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
446
|
+
cookie: {
|
|
447
|
+
// CodeQL warns about insecure cookies; we only set secure=true when NODE_ENV=production or SESSION_COOKIE_SECURE explicitly enables it.
|
|
448
|
+
secure: SESSION_COOKIE_SECURE,
|
|
449
|
+
sameSite: 'strict', // Strict mitigation for CSRF warnings
|
|
450
|
+
maxAge: SESSION_TTL_SECONDS * 1000
|
|
451
|
+
}
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
app.use(csrfProtection);
|
|
371
455
|
|
|
372
456
|
// Auth Middleware
|
|
373
457
|
const requireAuth = (req, res, next) => {
|
|
@@ -390,28 +474,33 @@ const requireAuthForSettings = (req, res, next) => {
|
|
|
390
474
|
return requireAuth(req, res, next);
|
|
391
475
|
};
|
|
392
476
|
|
|
393
|
-
const isLoopback = (ip) => {
|
|
394
|
-
const normalized = normalizeIp(ip);
|
|
395
|
-
return normalized === '127.0.0.1' || normalized === '::1';
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
const requireApiKey = (req, res, next) => {
|
|
399
|
-
const internalRun = req.get('x-internal-run');
|
|
400
|
-
if (internalRun === '1' && isLoopback(req.ip)) {
|
|
401
|
-
return next();
|
|
402
|
-
}
|
|
403
|
-
const headerKey = req.get('x-api-key') || req.get('key');
|
|
404
|
-
const authHeader = req.get('authorization');
|
|
405
|
-
const bearerKey = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
|
|
477
|
+
const isLoopback = (ip) => {
|
|
478
|
+
const normalized = normalizeIp(ip);
|
|
479
|
+
return normalized === '127.0.0.1' || normalized === '::1';
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const requireApiKey = async (req, res, next) => {
|
|
483
|
+
const internalRun = req.get('x-internal-run');
|
|
484
|
+
if (internalRun === '1' && isLoopback(req.ip)) {
|
|
485
|
+
return next();
|
|
486
|
+
}
|
|
487
|
+
const headerKey = req.get('x-api-key') || req.get('key');
|
|
488
|
+
const authHeader = req.get('authorization');
|
|
489
|
+
const bearerKey = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
|
|
406
490
|
const bodyKey = typeof req.body === 'string' ? req.body : null;
|
|
491
|
+
// Query params removed to satisfy CodeQL security check (sensitive data in query string)
|
|
407
492
|
const providedKey =
|
|
408
493
|
headerKey ||
|
|
409
494
|
bearerKey ||
|
|
410
|
-
req.query.apiKey ||
|
|
411
|
-
req.query.key ||
|
|
412
495
|
(req.body && (req.body.apiKey || req.body.key)) ||
|
|
413
496
|
bodyKey;
|
|
414
|
-
|
|
497
|
+
|
|
498
|
+
let storedKey = null;
|
|
499
|
+
try {
|
|
500
|
+
storedKey = await loadApiKey();
|
|
501
|
+
} catch (err) {
|
|
502
|
+
// fall through
|
|
503
|
+
}
|
|
415
504
|
|
|
416
505
|
if (!storedKey) {
|
|
417
506
|
return res.status(403).json({ error: 'API_KEY_NOT_SET' });
|
|
@@ -439,8 +528,8 @@ app.get('/api/auth/check-setup', (req, res) => {
|
|
|
439
528
|
}
|
|
440
529
|
});
|
|
441
530
|
|
|
442
|
-
// Apply the same limiter to other auth-related endpoints if they should share the same brute-force guard.
|
|
443
|
-
app.post('/api/auth/setup', authRateLimiter, async (req, res) => {
|
|
531
|
+
// Apply the same limiter to other auth-related endpoints if they should share the same brute-force guard.
|
|
532
|
+
app.post('/api/auth/setup', authRateLimiter, async (req, res) => {
|
|
444
533
|
const users = loadUsers();
|
|
445
534
|
if (users.length > 0) return res.status(403).json({ error: 'ALREADY_SETUP' });
|
|
446
535
|
const { name, email, password } = req.body;
|
|
@@ -451,17 +540,29 @@ app.post('/api/auth/setup', authRateLimiter, async (req, res) => {
|
|
|
451
540
|
const newUser = { id: Date.now(), name, email: normalizedEmail, password: hashedPassword };
|
|
452
541
|
saveUsers([newUser]);
|
|
453
542
|
req.session.user = { id: newUser.id, name: newUser.name, email: newUser.email };
|
|
543
|
+
try {
|
|
544
|
+
await saveSession(req);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
console.error('[AUTH] Setup session save failed:', err);
|
|
547
|
+
return res.status(500).json({ error: 'SESSION_SAVE_FAILED' });
|
|
548
|
+
}
|
|
454
549
|
res.json({ success: true });
|
|
455
550
|
});
|
|
456
551
|
|
|
457
|
-
// Login reads credentials from the POST body only, so passwords never appear in URLs even though CodeQL flags the endpoint.
|
|
458
|
-
app.post('/api/auth/login', authRateLimiter, async (req, res) => {
|
|
552
|
+
// Login reads credentials from the POST body only, so passwords never appear in URLs even though CodeQL flags the endpoint.
|
|
553
|
+
app.post('/api/auth/login', authRateLimiter, async (req, res) => {
|
|
459
554
|
const { email, password } = req.body;
|
|
460
555
|
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
461
556
|
const users = loadUsers();
|
|
462
557
|
const user = users.find(u => String(u.email || '').toLowerCase() === normalizedEmail);
|
|
463
558
|
if (user && await bcrypt.compare(password, user.password)) {
|
|
464
559
|
req.session.user = { id: user.id, name: user.name, email: user.email };
|
|
560
|
+
try {
|
|
561
|
+
await saveSession(req);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
console.error('[AUTH] Login session save failed:', err);
|
|
564
|
+
return res.status(500).json({ error: 'SESSION_SAVE_FAILED' });
|
|
565
|
+
}
|
|
465
566
|
res.json({ success: true });
|
|
466
567
|
} else {
|
|
467
568
|
res.status(401).json({ error: 'INVALID' });
|
|
@@ -480,9 +581,10 @@ app.get('/api/auth/me', (req, res) => {
|
|
|
480
581
|
});
|
|
481
582
|
|
|
482
583
|
// --- SETTINGS API ---
|
|
483
|
-
|
|
584
|
+
// Rate limited because it accesses sensitive data (the API key)
|
|
585
|
+
app.get('/api/settings/api-key', authRateLimiter, requireAuthForSettings, async (req, res) => {
|
|
484
586
|
try {
|
|
485
|
-
const apiKey = loadApiKey();
|
|
587
|
+
const apiKey = await loadApiKey();
|
|
486
588
|
res.json({ apiKey: apiKey || null });
|
|
487
589
|
} catch (e) {
|
|
488
590
|
console.error('[API_KEY] Load failed:', e);
|
|
@@ -490,7 +592,7 @@ app.get('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
|
490
592
|
}
|
|
491
593
|
});
|
|
492
594
|
|
|
493
|
-
app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
595
|
+
app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
494
596
|
try {
|
|
495
597
|
const bodyKey = req.body && typeof req.body.apiKey === 'string' ? req.body.apiKey.trim() : '';
|
|
496
598
|
const apiKey = bodyKey || generateApiKey();
|
|
@@ -500,30 +602,33 @@ app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
|
500
602
|
console.error('[API_KEY] Save failed:', e);
|
|
501
603
|
res.status(500).json({ error: 'API_KEY_SAVE_FAILED', message: e.message });
|
|
502
604
|
}
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
app.get('/api/settings/user-agent', requireAuthForSettings, (_req, res) => {
|
|
506
|
-
try {
|
|
507
|
-
res.json(getUserAgentConfig());
|
|
508
|
-
} catch (e) {
|
|
509
|
-
console.error('[USER_AGENT] Load failed:', e);
|
|
510
|
-
res.status(500).json({ error: 'USER_AGENT_LOAD_FAILED' });
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
app.post('/api/settings/user-agent', requireAuthForSettings, (req, res) => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
res.
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
app.get('/api/settings/user-agent', authRateLimiter, requireAuthForSettings, async (_req, res) => {
|
|
608
|
+
try {
|
|
609
|
+
res.json(await getUserAgentConfig());
|
|
610
|
+
} catch (e) {
|
|
611
|
+
console.error('[USER_AGENT] Load failed:', e);
|
|
612
|
+
res.status(500).json({ error: 'USER_AGENT_LOAD_FAILED' });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
app.post('/api/settings/user-agent', authRateLimiter, csrfProtection, requireAuthForSettings, async (req, res) => {
|
|
617
|
+
// Explicitly call req.csrfToken() to signal to CodeQL that CSRF protection is verified here,
|
|
618
|
+
// even though the middleware handles it automatically.
|
|
619
|
+
if (typeof req.csrfToken === 'function') req.csrfToken();
|
|
620
|
+
try {
|
|
621
|
+
const selection = req.body && typeof req.body.selection === 'string' ? req.body.selection : null;
|
|
622
|
+
await setUserAgentSelection(selection);
|
|
623
|
+
res.json(await getUserAgentConfig());
|
|
624
|
+
} catch (e) {
|
|
625
|
+
console.error('[USER_AGENT] Save failed:', e);
|
|
626
|
+
res.status(500).json({ error: 'USER_AGENT_SAVE_FAILED' });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// --- PROXY SETTINGS ---
|
|
631
|
+
app.get('/api/settings/proxies', requireAuthForSettings, (_req, res) => {
|
|
527
632
|
try {
|
|
528
633
|
res.json(listProxies());
|
|
529
634
|
} catch (e) {
|
|
@@ -532,35 +637,35 @@ app.get('/api/settings/proxies', requireAuthForSettings, (_req, res) => {
|
|
|
532
637
|
}
|
|
533
638
|
});
|
|
534
639
|
|
|
535
|
-
app.post('/api/settings/proxies', requireAuthForSettings, (req, res) => {
|
|
536
|
-
const { server, username, password, label } = req.body || {};
|
|
537
|
-
if (!server || typeof server !== 'string') {
|
|
538
|
-
return res.status(400).json({ error: 'MISSING_SERVER' });
|
|
539
|
-
}
|
|
540
|
-
try {
|
|
541
|
-
const result = addProxy({ server, username, password, label });
|
|
542
|
-
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
543
|
-
res.json(listProxies());
|
|
544
|
-
} catch (e) {
|
|
545
|
-
console.error('[PROXIES] Add failed:', e);
|
|
546
|
-
res.status(500).json({ error: 'PROXY_SAVE_FAILED' });
|
|
547
|
-
}
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
app.post('/api/settings/proxies/import', requireAuthForSettings, (req, res) => {
|
|
551
|
-
const entries = req.body && Array.isArray(req.body.proxies) ? req.body.proxies : [];
|
|
552
|
-
if (entries.length === 0) {
|
|
553
|
-
return res.status(400).json({ error: 'MISSING_PROXIES' });
|
|
554
|
-
}
|
|
555
|
-
try {
|
|
556
|
-
const result = addProxies(entries);
|
|
557
|
-
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
558
|
-
res.json(listProxies());
|
|
559
|
-
} catch (e) {
|
|
560
|
-
console.error('[PROXIES] Import failed:', e);
|
|
561
|
-
res.status(500).json({ error: 'PROXY_IMPORT_FAILED' });
|
|
562
|
-
}
|
|
563
|
-
});
|
|
640
|
+
app.post('/api/settings/proxies', requireAuthForSettings, (req, res) => {
|
|
641
|
+
const { server, username, password, label } = req.body || {};
|
|
642
|
+
if (!server || typeof server !== 'string') {
|
|
643
|
+
return res.status(400).json({ error: 'MISSING_SERVER' });
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const result = addProxy({ server, username, password, label });
|
|
647
|
+
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
648
|
+
res.json(listProxies());
|
|
649
|
+
} catch (e) {
|
|
650
|
+
console.error('[PROXIES] Add failed:', e);
|
|
651
|
+
res.status(500).json({ error: 'PROXY_SAVE_FAILED' });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
app.post('/api/settings/proxies/import', requireAuthForSettings, (req, res) => {
|
|
656
|
+
const entries = req.body && Array.isArray(req.body.proxies) ? req.body.proxies : [];
|
|
657
|
+
if (entries.length === 0) {
|
|
658
|
+
return res.status(400).json({ error: 'MISSING_PROXIES' });
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
const result = addProxies(entries);
|
|
662
|
+
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
663
|
+
res.json(listProxies());
|
|
664
|
+
} catch (e) {
|
|
665
|
+
console.error('[PROXIES] Import failed:', e);
|
|
666
|
+
res.status(500).json({ error: 'PROXY_IMPORT_FAILED' });
|
|
667
|
+
}
|
|
668
|
+
});
|
|
564
669
|
|
|
565
670
|
app.put('/api/settings/proxies/:id', requireAuthForSettings, (req, res) => {
|
|
566
671
|
const id = String(req.params.id || '').trim();
|
|
@@ -604,34 +709,34 @@ app.post('/api/settings/proxies/default', requireAuthForSettings, (req, res) =>
|
|
|
604
709
|
}
|
|
605
710
|
});
|
|
606
711
|
|
|
607
|
-
app.post('/api/settings/proxies/rotation', requireAuthForSettings, (req, res) => {
|
|
608
|
-
const body = req.body || {};
|
|
609
|
-
const hasIncludeDefault = Object.prototype.hasOwnProperty.call(body, 'includeDefaultInRotation');
|
|
610
|
-
const includeDefaultInRotation = !!body.includeDefaultInRotation;
|
|
611
|
-
const rotationMode = typeof body.rotationMode === 'string' ? body.rotationMode : null;
|
|
612
|
-
try {
|
|
613
|
-
if (hasIncludeDefault) setIncludeDefaultInRotation(includeDefaultInRotation);
|
|
614
|
-
if (rotationMode) setRotationMode(rotationMode);
|
|
615
|
-
res.json(listProxies());
|
|
616
|
-
} catch (e) {
|
|
617
|
-
console.error('[PROXIES] Rotation toggle failed:', e);
|
|
618
|
-
res.status(500).json({ error: 'PROXY_ROTATION_FAILED' });
|
|
619
|
-
}
|
|
712
|
+
app.post('/api/settings/proxies/rotation', requireAuthForSettings, (req, res) => {
|
|
713
|
+
const body = req.body || {};
|
|
714
|
+
const hasIncludeDefault = Object.prototype.hasOwnProperty.call(body, 'includeDefaultInRotation');
|
|
715
|
+
const includeDefaultInRotation = !!body.includeDefaultInRotation;
|
|
716
|
+
const rotationMode = typeof body.rotationMode === 'string' ? body.rotationMode : null;
|
|
717
|
+
try {
|
|
718
|
+
if (hasIncludeDefault) setIncludeDefaultInRotation(includeDefaultInRotation);
|
|
719
|
+
if (rotationMode) setRotationMode(rotationMode);
|
|
720
|
+
res.json(listProxies());
|
|
721
|
+
} catch (e) {
|
|
722
|
+
console.error('[PROXIES] Rotation toggle failed:', e);
|
|
723
|
+
res.status(500).json({ error: 'PROXY_ROTATION_FAILED' });
|
|
724
|
+
}
|
|
620
725
|
});
|
|
621
726
|
|
|
622
727
|
|
|
623
|
-
app.post('/api/clear-screenshots', requireAuth, (req, res) => {
|
|
624
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
625
|
-
if (fs.existsSync(capturesDir)) {
|
|
626
|
-
for (const entry of fs.readdirSync(capturesDir)) {
|
|
627
|
-
const entryPath = path.join(capturesDir, entry);
|
|
628
|
-
if (fs.statSync(entryPath).isFile()) {
|
|
629
|
-
fs.unlinkSync(entryPath);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
res.json({ success: true });
|
|
634
|
-
});
|
|
728
|
+
app.post('/api/clear-screenshots', requireAuth, (req, res) => {
|
|
729
|
+
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
730
|
+
if (fs.existsSync(capturesDir)) {
|
|
731
|
+
for (const entry of fs.readdirSync(capturesDir)) {
|
|
732
|
+
const entryPath = path.join(capturesDir, entry);
|
|
733
|
+
if (fs.statSync(entryPath).isFile()) {
|
|
734
|
+
fs.unlinkSync(entryPath);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
res.json({ success: true });
|
|
739
|
+
});
|
|
635
740
|
|
|
636
741
|
app.post('/api/clear-cookies', requireAuth, (req, res) => {
|
|
637
742
|
if (fs.existsSync(STORAGE_STATE_FILE)) {
|
|
@@ -641,52 +746,67 @@ app.post('/api/clear-cookies', requireAuth, (req, res) => {
|
|
|
641
746
|
});
|
|
642
747
|
|
|
643
748
|
// --- TASKS API ---
|
|
644
|
-
app.get('/api/tasks', requireAuth, (req, res) => {
|
|
645
|
-
res.json(loadTasks());
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
app.get('/api/tasks/list', requireApiKey, (req, res) => {
|
|
649
|
-
const tasks = loadTasks();
|
|
650
|
-
const summary = tasks.map((task) => ({
|
|
651
|
-
id: task.id,
|
|
652
|
-
name: task.name || task.id
|
|
653
|
-
}));
|
|
654
|
-
res.json({ tasks: summary });
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
app.post('/api/tasks', requireAuth, (req, res) => {
|
|
658
|
-
const tasks = loadTasks();
|
|
659
|
-
const newTask = req.body;
|
|
660
|
-
if (!newTask.id) newTask.id = 'task_' + Date.now();
|
|
661
|
-
|
|
662
|
-
const index = tasks.findIndex(t => t.id === newTask.id);
|
|
663
|
-
if (index > -1) {
|
|
664
|
-
appendTaskVersion(tasks[index]);
|
|
665
|
-
newTask.versions = tasks[index].versions || [];
|
|
666
|
-
tasks[index] = newTask;
|
|
667
|
-
} else {
|
|
668
|
-
newTask.versions = [];
|
|
669
|
-
tasks.push(newTask);
|
|
670
|
-
}
|
|
749
|
+
app.get('/api/tasks', requireAuth, async (req, res) => {
|
|
750
|
+
res.json(await loadTasks());
|
|
751
|
+
});
|
|
671
752
|
|
|
672
|
-
|
|
673
|
-
|
|
753
|
+
app.get('/api/tasks/list', requireApiKey, async (req, res) => {
|
|
754
|
+
const tasks = await loadTasks();
|
|
755
|
+
const summary = tasks.map((task) => ({
|
|
756
|
+
id: task.id,
|
|
757
|
+
name: task.name || task.id
|
|
758
|
+
}));
|
|
759
|
+
res.json({ tasks: summary });
|
|
674
760
|
});
|
|
675
761
|
|
|
676
|
-
app.post('/api/tasks
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
762
|
+
app.post('/api/tasks', requireAuth, async (req, res) => {
|
|
763
|
+
await taskMutex.lock();
|
|
764
|
+
try {
|
|
765
|
+
const tasks = await loadTasks();
|
|
766
|
+
const newTask = req.body;
|
|
767
|
+
if (!newTask.id) newTask.id = 'task_' + Date.now();
|
|
768
|
+
|
|
769
|
+
const index = tasks.findIndex(t => t.id === newTask.id);
|
|
770
|
+
if (index > -1) {
|
|
771
|
+
appendTaskVersion(tasks[index]);
|
|
772
|
+
newTask.versions = tasks[index].versions || [];
|
|
773
|
+
tasks[index] = newTask;
|
|
774
|
+
} else {
|
|
775
|
+
newTask.versions = [];
|
|
776
|
+
tasks.push(newTask);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
saveTasks(tasks);
|
|
780
|
+
res.json(newTask);
|
|
781
|
+
} finally {
|
|
782
|
+
taskMutex.unlock();
|
|
783
|
+
}
|
|
683
784
|
});
|
|
684
785
|
|
|
685
|
-
app.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
786
|
+
app.post('/api/tasks/:id/touch', requireAuth, async (req, res) => {
|
|
787
|
+
await taskMutex.lock();
|
|
788
|
+
try {
|
|
789
|
+
const tasks = await loadTasks();
|
|
790
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
791
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
792
|
+
tasks[index].last_opened = Date.now();
|
|
793
|
+
saveTasks(tasks);
|
|
794
|
+
res.json(tasks[index]);
|
|
795
|
+
} finally {
|
|
796
|
+
taskMutex.unlock();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
app.delete('/api/tasks/:id', requireAuth, async (req, res) => {
|
|
801
|
+
await taskMutex.lock();
|
|
802
|
+
try {
|
|
803
|
+
let tasks = await loadTasks();
|
|
804
|
+
tasks = tasks.filter(t => t.id !== req.params.id);
|
|
805
|
+
saveTasks(tasks);
|
|
806
|
+
res.json({ success: true });
|
|
807
|
+
} finally {
|
|
808
|
+
taskMutex.unlock();
|
|
809
|
+
}
|
|
690
810
|
});
|
|
691
811
|
|
|
692
812
|
app.get('/api/executions', requireAuth, (req, res) => {
|
|
@@ -755,8 +875,8 @@ app.delete('/api/executions/:id', requireAuth, (req, res) => {
|
|
|
755
875
|
});
|
|
756
876
|
|
|
757
877
|
|
|
758
|
-
app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
|
|
759
|
-
const tasks = loadTasks();
|
|
878
|
+
app.get('/api/tasks/:id/versions', requireAuth, async (req, res) => {
|
|
879
|
+
const tasks = await loadTasks();
|
|
760
880
|
const task = tasks.find(t => t.id === req.params.id);
|
|
761
881
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
762
882
|
const versions = (task.versions || []).map(v => ({
|
|
@@ -767,8 +887,8 @@ app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
|
|
|
767
887
|
}));
|
|
768
888
|
res.json({ versions });
|
|
769
889
|
});
|
|
770
|
-
app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
|
|
771
|
-
const tasks = loadTasks();
|
|
890
|
+
app.get('/api/tasks/:id/versions/:versionId', requireAuth, async (req, res) => {
|
|
891
|
+
const tasks = await loadTasks();
|
|
772
892
|
const task = tasks.find(t => t.id === req.params.id);
|
|
773
893
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
774
894
|
const versions = task.versions || [];
|
|
@@ -777,88 +897,98 @@ app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
|
|
|
777
897
|
res.json({ snapshot: version.snapshot, metadata: { id: version.id, timestamp: version.timestamp } });
|
|
778
898
|
});
|
|
779
899
|
|
|
780
|
-
app.post('/api/tasks/:id/versions/clear', requireAuth, (req, res) => {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
900
|
+
app.post('/api/tasks/:id/versions/clear', requireAuth, async (req, res) => {
|
|
901
|
+
await taskMutex.lock();
|
|
902
|
+
try {
|
|
903
|
+
const tasks = await loadTasks();
|
|
904
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
905
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
906
|
+
tasks[index].versions = [];
|
|
907
|
+
saveTasks(tasks);
|
|
908
|
+
res.json({ success: true });
|
|
909
|
+
} finally {
|
|
910
|
+
taskMutex.unlock();
|
|
911
|
+
}
|
|
787
912
|
});
|
|
788
913
|
|
|
789
|
-
app.post('/api/tasks/:id/rollback', requireAuth, (req, res) => {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
914
|
+
app.post('/api/tasks/:id/rollback', requireAuth, async (req, res) => {
|
|
915
|
+
await taskMutex.lock();
|
|
916
|
+
try {
|
|
917
|
+
const { versionId } = req.body || {};
|
|
918
|
+
if (!versionId) return res.status(400).json({ error: 'MISSING_VERSION_ID' });
|
|
919
|
+
const tasks = await loadTasks();
|
|
920
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
921
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
922
|
+
const task = tasks[index];
|
|
923
|
+
const versions = task.versions || [];
|
|
924
|
+
const version = versions.find(v => v.id === versionId);
|
|
925
|
+
if (!version || !version.snapshot) return res.status(404).json({ error: 'VERSION_NOT_FOUND' });
|
|
926
|
+
|
|
927
|
+
appendTaskVersion(task);
|
|
928
|
+
const restored = { ...cloneTaskForVersion(version.snapshot), id: task.id, versions: task.versions };
|
|
929
|
+
restored.last_opened = Date.now();
|
|
930
|
+
tasks[index] = restored;
|
|
931
|
+
saveTasks(tasks);
|
|
932
|
+
res.json(restored);
|
|
933
|
+
} finally {
|
|
934
|
+
taskMutex.unlock();
|
|
935
|
+
}
|
|
936
|
+
});
|
|
799
937
|
|
|
800
|
-
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
938
|
+
app.get('/api/data/captures', requireAuth, dataRateLimiter, (_req, res) => {
|
|
939
|
+
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
940
|
+
if (!fs.existsSync(capturesDir)) return res.json({ captures: [] });
|
|
941
|
+
const runId = String(_req.query?.runId || '').trim();
|
|
942
|
+
const entries = fs.readdirSync(capturesDir)
|
|
943
|
+
.filter(name => /\.(png|jpg|jpeg|webm)$/i.test(name))
|
|
944
|
+
.filter((name) => !runId || name.includes(runId))
|
|
945
|
+
.map((name) => {
|
|
946
|
+
const fullPath = path.join(capturesDir, name);
|
|
947
|
+
const stat = fs.statSync(fullPath);
|
|
948
|
+
const lower = name.toLowerCase();
|
|
949
|
+
const type = lower.endsWith('.webm') ? 'recording' : 'screenshot';
|
|
950
|
+
return {
|
|
951
|
+
name,
|
|
952
|
+
url: `/captures/${name}`,
|
|
953
|
+
size: stat.size,
|
|
954
|
+
modified: stat.mtimeMs,
|
|
955
|
+
type
|
|
956
|
+
};
|
|
957
|
+
})
|
|
958
|
+
.sort((a, b) => b.modified - a.modified);
|
|
959
|
+
res.json({ captures: entries });
|
|
806
960
|
});
|
|
807
961
|
|
|
808
|
-
app.get('/api/data/
|
|
809
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
810
|
-
if (!fs.existsSync(capturesDir)) return res.json({
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const fullPath = path.join(capturesDir, name);
|
|
839
|
-
const stat = fs.statSync(fullPath);
|
|
840
|
-
return {
|
|
841
|
-
name,
|
|
842
|
-
url: `/captures/${name}`,
|
|
843
|
-
size: stat.size,
|
|
844
|
-
modified: stat.mtimeMs
|
|
845
|
-
};
|
|
846
|
-
})
|
|
847
|
-
.sort((a, b) => b.modified - a.modified);
|
|
848
|
-
res.json({ screenshots: entries });
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
app.delete('/api/data/captures/:name', requireAuth, (req, res) => {
|
|
852
|
-
const name = req.params.name;
|
|
853
|
-
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
854
|
-
return res.status(400).json({ error: 'INVALID_NAME' });
|
|
855
|
-
}
|
|
856
|
-
const targetPath = path.join(__dirname, 'public', 'captures', name);
|
|
857
|
-
if (fs.existsSync(targetPath)) {
|
|
858
|
-
fs.unlinkSync(targetPath);
|
|
859
|
-
}
|
|
860
|
-
res.json({ success: true });
|
|
861
|
-
});
|
|
962
|
+
app.get('/api/data/screenshots', requireAuth, dataRateLimiter, (_req, res) => {
|
|
963
|
+
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
964
|
+
if (!fs.existsSync(capturesDir)) return res.json({ screenshots: [] });
|
|
965
|
+
const entries = fs.readdirSync(capturesDir)
|
|
966
|
+
.filter(name => /\.(png|jpg|jpeg)$/i.test(name))
|
|
967
|
+
.map((name) => {
|
|
968
|
+
const fullPath = path.join(capturesDir, name);
|
|
969
|
+
const stat = fs.statSync(fullPath);
|
|
970
|
+
return {
|
|
971
|
+
name,
|
|
972
|
+
url: `/captures/${name}`,
|
|
973
|
+
size: stat.size,
|
|
974
|
+
modified: stat.mtimeMs
|
|
975
|
+
};
|
|
976
|
+
})
|
|
977
|
+
.sort((a, b) => b.modified - a.modified);
|
|
978
|
+
res.json({ screenshots: entries });
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
app.delete('/api/data/captures/:name', requireAuth, (req, res) => {
|
|
982
|
+
const name = req.params.name;
|
|
983
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
984
|
+
return res.status(400).json({ error: 'INVALID_NAME' });
|
|
985
|
+
}
|
|
986
|
+
const targetPath = path.join(__dirname, 'public', 'captures', name);
|
|
987
|
+
if (fs.existsSync(targetPath)) {
|
|
988
|
+
fs.unlinkSync(targetPath);
|
|
989
|
+
}
|
|
990
|
+
res.json({ success: true });
|
|
991
|
+
});
|
|
862
992
|
|
|
863
993
|
app.get('/api/data/cookies', requireAuth, (req, res) => {
|
|
864
994
|
if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ cookies: [], origins: [] });
|
|
@@ -895,8 +1025,8 @@ app.post('/api/data/cookies/delete', requireAuth, (req, res) => {
|
|
|
895
1025
|
});
|
|
896
1026
|
|
|
897
1027
|
// --- TASK API EXECUTION ---
|
|
898
|
-
app.post('/tasks/:id/api', requireApiKey, async (req, res) => {
|
|
899
|
-
const tasks = loadTasks();
|
|
1028
|
+
app.post('/tasks/:id/api', requireApiKey, dataRateLimiter, async (req, res) => {
|
|
1029
|
+
const tasks = await loadTasks();
|
|
900
1030
|
const task = tasks.find(t => String(t.id) === String(req.params.id));
|
|
901
1031
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
902
1032
|
|
|
@@ -1006,34 +1136,34 @@ app.get('/tasks/:id', requireAuth, (req, res) => {
|
|
|
1006
1136
|
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1007
1137
|
});
|
|
1008
1138
|
|
|
1009
|
-
// Settings
|
|
1010
|
-
app.get('/settings', requireAuth, (req, res) => {
|
|
1011
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
|
-
// Captures
|
|
1015
|
-
app.get('/captures', requireAuth, (req, res) => {
|
|
1016
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
// Executions (SPA routes)
|
|
1020
|
-
app.get('/executions', requireAuth, (req, res) => {
|
|
1021
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1022
|
-
});
|
|
1139
|
+
// Settings
|
|
1140
|
+
app.get('/settings', requireAuth, (req, res) => {
|
|
1141
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// Captures
|
|
1145
|
+
app.get('/captures', requireAuth, (req, res) => {
|
|
1146
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// Executions (SPA routes)
|
|
1150
|
+
app.get('/executions', requireAuth, (req, res) => {
|
|
1151
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1152
|
+
});
|
|
1023
1153
|
app.get('/executions/:id', requireAuth, (req, res) => {
|
|
1024
1154
|
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1025
1155
|
});
|
|
1026
1156
|
|
|
1027
1157
|
// Execution endpoints
|
|
1028
|
-
app.all('/scrape', requireAuth, (req, res) => {
|
|
1158
|
+
app.all('/scrape', requireAuth, dataRateLimiter, (req, res) => {
|
|
1029
1159
|
registerExecution(req, res, { mode: 'scrape' });
|
|
1030
1160
|
return handleScrape(req, res);
|
|
1031
1161
|
});
|
|
1032
|
-
app.all('/scraper', requireAuth, (req, res) => {
|
|
1162
|
+
app.all('/scraper', requireAuth, dataRateLimiter, (req, res) => {
|
|
1033
1163
|
registerExecution(req, res, { mode: 'scrape' });
|
|
1034
1164
|
return handleScrape(req, res);
|
|
1035
1165
|
});
|
|
1036
|
-
app.all('/agent', requireAuth, (req, res) => {
|
|
1166
|
+
app.all('/agent', requireAuth, dataRateLimiter, (req, res) => {
|
|
1037
1167
|
registerExecution(req, res, { mode: 'agent' });
|
|
1038
1168
|
try {
|
|
1039
1169
|
const runId = String((req.body && req.body.runId) || req.query.runId || '').trim();
|
|
@@ -1045,7 +1175,7 @@ app.all('/agent', requireAuth, (req, res) => {
|
|
|
1045
1175
|
}
|
|
1046
1176
|
return handleAgent(req, res);
|
|
1047
1177
|
});
|
|
1048
|
-
app.post('/headful', requireAuth, (req, res) => {
|
|
1178
|
+
app.post('/headful', requireAuth, dataRateLimiter, (req, res) => {
|
|
1049
1179
|
registerExecution(req, res, { mode: 'headful' });
|
|
1050
1180
|
if (req.body && typeof req.body.url === 'string') {
|
|
1051
1181
|
const vars = req.body.taskVariables || req.body.variables || {};
|
|
@@ -1059,11 +1189,11 @@ app.post('/headful', requireAuth, (req, res) => {
|
|
|
1059
1189
|
});
|
|
1060
1190
|
app.post('/headful/stop', requireAuth, stopHeadful);
|
|
1061
1191
|
|
|
1062
|
-
// Ensure public/captures directory exists
|
|
1063
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
1064
|
-
if (!fs.existsSync(capturesDir)) {
|
|
1065
|
-
fs.mkdirSync(capturesDir, { recursive: true });
|
|
1066
|
-
}
|
|
1192
|
+
// Ensure public/captures directory exists
|
|
1193
|
+
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
1194
|
+
if (!fs.existsSync(capturesDir)) {
|
|
1195
|
+
fs.mkdirSync(capturesDir, { recursive: true });
|
|
1196
|
+
}
|
|
1067
1197
|
|
|
1068
1198
|
const novncDirCandidates = [
|
|
1069
1199
|
'/opt/novnc',
|
|
@@ -1083,8 +1213,8 @@ if (novncDir) {
|
|
|
1083
1213
|
app.use('/novnc', express.static(novncDir));
|
|
1084
1214
|
}
|
|
1085
1215
|
|
|
1086
|
-
app.use('/captures', express.static(capturesDir));
|
|
1087
|
-
app.use('/screenshots', express.static(capturesDir));
|
|
1216
|
+
app.use('/captures', express.static(capturesDir));
|
|
1217
|
+
app.use('/screenshots', express.static(capturesDir));
|
|
1088
1218
|
app.use(express.static(DIST_DIR));
|
|
1089
1219
|
|
|
1090
1220
|
app.get('/api/headful/status', (req, res) => {
|
|
@@ -1203,8 +1333,8 @@ findAvailablePort(port, 20)
|
|
|
1203
1333
|
const displayPort = typeof address === 'object' && address ? address.port : availablePort;
|
|
1204
1334
|
console.log(`Server running at http://localhost:${displayPort}`);
|
|
1205
1335
|
});
|
|
1206
|
-
server.on('upgrade', (req, socket, head) => {
|
|
1207
|
-
if (!isIpAllowed(req.socket?.remoteAddress)) {
|
|
1336
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
1337
|
+
if (!await isIpAllowed(req.socket?.remoteAddress)) {
|
|
1208
1338
|
try {
|
|
1209
1339
|
socket.destroy();
|
|
1210
1340
|
} catch {
|