@doppelgangerdev/doppelganger 0.5.7 → 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 +2 -2
- package/README.md +9 -29
- package/agent.js +200 -101
- package/headful.js +126 -126
- package/package.json +2 -2
- package/scrape.js +249 -284
- package/server.js +469 -359
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,16 +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 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
|
-
}
|
|
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
|
+
}
|
|
46
46
|
|
|
47
47
|
// Ensure data directory exists
|
|
48
48
|
if (!fs.existsSync(path.join(__dirname, 'data'))) {
|
|
@@ -66,16 +66,16 @@ function loadUsers() {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Helper to save users
|
|
69
|
-
function saveUsers(users) {
|
|
70
|
-
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
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
|
-
});
|
|
69
|
+
function saveUsers(users) {
|
|
70
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
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
79
|
|
|
80
80
|
const TASKS_FILE = path.join(__dirname, 'data', 'tasks.json');
|
|
81
81
|
const API_KEY_FILE = path.join(__dirname, 'data', 'api_key.json');
|
|
@@ -95,16 +95,58 @@ const MAX_TASK_VERSIONS = 30;
|
|
|
95
95
|
const EXECUTIONS_FILE = path.join(__dirname, 'data', 'executions.json');
|
|
96
96
|
const MAX_EXECUTIONS = 500;
|
|
97
97
|
const executionStreams = new Map();
|
|
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
|
-
});
|
|
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
|
+
};
|
|
108
150
|
|
|
109
151
|
const sendExecutionUpdate = (runId, payload) => {
|
|
110
152
|
if (!runId) return;
|
|
@@ -121,10 +163,9 @@ const sendExecutionUpdate = (runId, payload) => {
|
|
|
121
163
|
};
|
|
122
164
|
|
|
123
165
|
// Helper to load tasks
|
|
124
|
-
function loadTasks() {
|
|
125
|
-
if (!fs.existsSync(TASKS_FILE)) return [];
|
|
166
|
+
async function loadTasks() {
|
|
126
167
|
try {
|
|
127
|
-
return JSON.parse(fs.
|
|
168
|
+
return JSON.parse(await fs.promises.readFile(TASKS_FILE, 'utf8'));
|
|
128
169
|
} catch (e) {
|
|
129
170
|
return [];
|
|
130
171
|
}
|
|
@@ -135,6 +176,32 @@ function saveTasks(tasks) {
|
|
|
135
176
|
fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
|
|
136
177
|
}
|
|
137
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
|
+
|
|
138
205
|
function cloneTaskForVersion(task) {
|
|
139
206
|
const copy = JSON.parse(JSON.stringify(task || {}));
|
|
140
207
|
if (copy.versions) delete copy.versions;
|
|
@@ -209,20 +276,20 @@ function registerExecution(req, res, baseMeta = {}) {
|
|
|
209
276
|
}
|
|
210
277
|
|
|
211
278
|
// Helper to load API key
|
|
212
|
-
function loadApiKey() {
|
|
279
|
+
async function loadApiKey() {
|
|
213
280
|
let apiKey = null;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
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;
|
|
221
287
|
}
|
|
222
288
|
|
|
223
|
-
if (!apiKey
|
|
289
|
+
if (!apiKey) {
|
|
224
290
|
try {
|
|
225
|
-
const
|
|
291
|
+
const usersRaw = await fs.promises.readFile(USERS_FILE, 'utf8');
|
|
292
|
+
const users = JSON.parse(usersRaw);
|
|
226
293
|
if (Array.isArray(users) && users.length > 0 && users[0].apiKey) {
|
|
227
294
|
apiKey = users[0].apiKey;
|
|
228
295
|
saveApiKey(apiKey);
|
|
@@ -255,7 +322,8 @@ function generateApiKey() {
|
|
|
255
322
|
return crypto.randomBytes(32).toString('hex');
|
|
256
323
|
}
|
|
257
324
|
|
|
258
|
-
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;
|
|
259
327
|
|
|
260
328
|
const normalizeIp = (raw) => {
|
|
261
329
|
if (!raw) return '';
|
|
@@ -275,18 +343,22 @@ const parseIpList = (input) => {
|
|
|
275
343
|
return [];
|
|
276
344
|
};
|
|
277
345
|
|
|
278
|
-
const loadAllowedIps = () => {
|
|
346
|
+
const loadAllowedIps = async () => {
|
|
279
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
|
+
|
|
280
354
|
let filePath = null;
|
|
281
355
|
let fileMtime = 0;
|
|
282
356
|
let fileEntries = [];
|
|
283
357
|
|
|
284
358
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
fileMtime = stat.mtimeMs || 0;
|
|
289
|
-
}
|
|
359
|
+
const stat = await fs.promises.stat(ALLOWED_IPS_FILE);
|
|
360
|
+
filePath = ALLOWED_IPS_FILE;
|
|
361
|
+
fileMtime = stat.mtimeMs || 0;
|
|
290
362
|
} catch {
|
|
291
363
|
filePath = null;
|
|
292
364
|
}
|
|
@@ -297,12 +369,13 @@ const loadAllowedIps = () => {
|
|
|
297
369
|
allowedIpsCache.file === filePath &&
|
|
298
370
|
allowedIpsCache.mtimeMs === fileMtime
|
|
299
371
|
) {
|
|
372
|
+
allowedIpsCache.lastCheck = now;
|
|
300
373
|
return allowedIpsCache.set;
|
|
301
374
|
}
|
|
302
375
|
|
|
303
376
|
if (filePath) {
|
|
304
377
|
try {
|
|
305
|
-
const raw = fs.
|
|
378
|
+
const raw = await fs.promises.readFile(filePath, 'utf8');
|
|
306
379
|
const parsed = JSON.parse(raw);
|
|
307
380
|
fileEntries = Array.isArray(parsed)
|
|
308
381
|
? parsed
|
|
@@ -322,30 +395,30 @@ const loadAllowedIps = () => {
|
|
|
322
395
|
.filter(Boolean);
|
|
323
396
|
|
|
324
397
|
const set = new Set(combined);
|
|
325
|
-
allowedIpsCache = { env: envRaw, file: filePath, mtimeMs: fileMtime, set };
|
|
398
|
+
allowedIpsCache = { env: envRaw, file: filePath, mtimeMs: fileMtime, set, lastCheck: now };
|
|
326
399
|
return set;
|
|
327
400
|
};
|
|
328
401
|
|
|
329
|
-
const isIpAllowed = (ip) => {
|
|
330
|
-
const allowlist = loadAllowedIps();
|
|
402
|
+
const isIpAllowed = async (ip) => {
|
|
403
|
+
const allowlist = await loadAllowedIps();
|
|
331
404
|
if (!allowlist || allowlist.size === 0) return true;
|
|
332
405
|
return allowlist.has(normalizeIp(ip));
|
|
333
406
|
};
|
|
334
407
|
|
|
335
|
-
const requireIpAllowlist = (req, res, next) => {
|
|
408
|
+
const requireIpAllowlist = async (req, res, next) => {
|
|
336
409
|
const ip = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress;
|
|
337
|
-
if (isIpAllowed(ip)) return next();
|
|
410
|
+
if (await isIpAllowed(ip)) return next();
|
|
338
411
|
if (req.xhr || req.path.startsWith('/api/')) {
|
|
339
412
|
return res.status(403).json({ error: 'IP_NOT_ALLOWED' });
|
|
340
413
|
}
|
|
341
414
|
return res.status(403).send('Forbidden');
|
|
342
415
|
};
|
|
343
416
|
|
|
344
|
-
const { handleScrape } = require('./scrape');
|
|
345
|
-
const { handleAgent, setProgressReporter, setStopChecker } = require('./agent');
|
|
346
|
-
const { handleHeadful, stopHeadful } = require('./headful');
|
|
347
|
-
const { listProxies, addProxy, addProxies, updateProxy, deleteProxy, setDefaultProxy, setIncludeDefaultInRotation, setRotationMode } = require('./proxy-rotation');
|
|
348
|
-
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');
|
|
349
422
|
|
|
350
423
|
setProgressReporter(sendExecutionUpdate);
|
|
351
424
|
setStopChecker((runId) => {
|
|
@@ -370,12 +443,15 @@ app.use(session({
|
|
|
370
443
|
secret: SESSION_SECRET,
|
|
371
444
|
resave: false,
|
|
372
445
|
saveUninitialized: false,
|
|
373
|
-
cookie: {
|
|
374
|
-
// CodeQL warns about insecure cookies; we only set secure=true when NODE_ENV=production or SESSION_COOKIE_SECURE explicitly enables it.
|
|
375
|
-
secure: SESSION_COOKIE_SECURE,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
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);
|
|
379
455
|
|
|
380
456
|
// Auth Middleware
|
|
381
457
|
const requireAuth = (req, res, next) => {
|
|
@@ -398,28 +474,33 @@ const requireAuthForSettings = (req, res, next) => {
|
|
|
398
474
|
return requireAuth(req, res, next);
|
|
399
475
|
};
|
|
400
476
|
|
|
401
|
-
const isLoopback = (ip) => {
|
|
402
|
-
const normalized = normalizeIp(ip);
|
|
403
|
-
return normalized === '127.0.0.1' || normalized === '::1';
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
const requireApiKey = (req, res, next) => {
|
|
407
|
-
const internalRun = req.get('x-internal-run');
|
|
408
|
-
if (internalRun === '1' && isLoopback(req.ip)) {
|
|
409
|
-
return next();
|
|
410
|
-
}
|
|
411
|
-
const headerKey = req.get('x-api-key') || req.get('key');
|
|
412
|
-
const authHeader = req.get('authorization');
|
|
413
|
-
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, '') : '';
|
|
414
490
|
const bodyKey = typeof req.body === 'string' ? req.body : null;
|
|
491
|
+
// Query params removed to satisfy CodeQL security check (sensitive data in query string)
|
|
415
492
|
const providedKey =
|
|
416
493
|
headerKey ||
|
|
417
494
|
bearerKey ||
|
|
418
|
-
req.query.apiKey ||
|
|
419
|
-
req.query.key ||
|
|
420
495
|
(req.body && (req.body.apiKey || req.body.key)) ||
|
|
421
496
|
bodyKey;
|
|
422
|
-
|
|
497
|
+
|
|
498
|
+
let storedKey = null;
|
|
499
|
+
try {
|
|
500
|
+
storedKey = await loadApiKey();
|
|
501
|
+
} catch (err) {
|
|
502
|
+
// fall through
|
|
503
|
+
}
|
|
423
504
|
|
|
424
505
|
if (!storedKey) {
|
|
425
506
|
return res.status(403).json({ error: 'API_KEY_NOT_SET' });
|
|
@@ -447,46 +528,46 @@ app.get('/api/auth/check-setup', (req, res) => {
|
|
|
447
528
|
}
|
|
448
529
|
});
|
|
449
530
|
|
|
450
|
-
// Apply the same limiter to other auth-related endpoints if they should share the same brute-force guard.
|
|
451
|
-
app.post('/api/auth/setup', authRateLimiter, async (req, res) => {
|
|
452
|
-
const users = loadUsers();
|
|
453
|
-
if (users.length > 0) return res.status(403).json({ error: 'ALREADY_SETUP' });
|
|
454
|
-
const { name, email, password } = req.body;
|
|
455
|
-
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
456
|
-
if (!name || !normalizedEmail || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
|
457
|
-
|
|
458
|
-
const hashedPassword = await bcrypt.hash(password, 10);
|
|
459
|
-
const newUser = { id: Date.now(), name, email: normalizedEmail, password: hashedPassword };
|
|
460
|
-
saveUsers([newUser]);
|
|
461
|
-
req.session.user = { id: newUser.id, name: newUser.name, email: newUser.email };
|
|
462
|
-
try {
|
|
463
|
-
await saveSession(req);
|
|
464
|
-
} catch (err) {
|
|
465
|
-
console.error('[AUTH] Setup session save failed:', err);
|
|
466
|
-
return res.status(500).json({ error: 'SESSION_SAVE_FAILED' });
|
|
467
|
-
}
|
|
468
|
-
res.json({ success: true });
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
// Login reads credentials from the POST body only, so passwords never appear in URLs even though CodeQL flags the endpoint.
|
|
472
|
-
app.post('/api/auth/login', authRateLimiter, async (req, res) => {
|
|
473
|
-
const { email, password } = req.body;
|
|
474
|
-
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
475
|
-
const users = loadUsers();
|
|
476
|
-
const user = users.find(u => String(u.email || '').toLowerCase() === normalizedEmail);
|
|
477
|
-
if (user && await bcrypt.compare(password, user.password)) {
|
|
478
|
-
req.session.user = { id: user.id, name: user.name, email: user.email };
|
|
479
|
-
try {
|
|
480
|
-
await saveSession(req);
|
|
481
|
-
} catch (err) {
|
|
482
|
-
console.error('[AUTH] Login session save failed:', err);
|
|
483
|
-
return res.status(500).json({ error: 'SESSION_SAVE_FAILED' });
|
|
484
|
-
}
|
|
485
|
-
res.json({ success: true });
|
|
486
|
-
} else {
|
|
487
|
-
res.status(401).json({ error: 'INVALID' });
|
|
488
|
-
}
|
|
489
|
-
});
|
|
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) => {
|
|
533
|
+
const users = loadUsers();
|
|
534
|
+
if (users.length > 0) return res.status(403).json({ error: 'ALREADY_SETUP' });
|
|
535
|
+
const { name, email, password } = req.body;
|
|
536
|
+
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
537
|
+
if (!name || !normalizedEmail || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
|
538
|
+
|
|
539
|
+
const hashedPassword = await bcrypt.hash(password, 10);
|
|
540
|
+
const newUser = { id: Date.now(), name, email: normalizedEmail, password: hashedPassword };
|
|
541
|
+
saveUsers([newUser]);
|
|
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
|
+
}
|
|
549
|
+
res.json({ success: true });
|
|
550
|
+
});
|
|
551
|
+
|
|
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) => {
|
|
554
|
+
const { email, password } = req.body;
|
|
555
|
+
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
556
|
+
const users = loadUsers();
|
|
557
|
+
const user = users.find(u => String(u.email || '').toLowerCase() === normalizedEmail);
|
|
558
|
+
if (user && await bcrypt.compare(password, user.password)) {
|
|
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
|
+
}
|
|
566
|
+
res.json({ success: true });
|
|
567
|
+
} else {
|
|
568
|
+
res.status(401).json({ error: 'INVALID' });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
490
571
|
|
|
491
572
|
app.post('/api/auth/logout', (req, res) => {
|
|
492
573
|
req.session.destroy(() => {
|
|
@@ -500,9 +581,10 @@ app.get('/api/auth/me', (req, res) => {
|
|
|
500
581
|
});
|
|
501
582
|
|
|
502
583
|
// --- SETTINGS API ---
|
|
503
|
-
|
|
584
|
+
// Rate limited because it accesses sensitive data (the API key)
|
|
585
|
+
app.get('/api/settings/api-key', authRateLimiter, requireAuthForSettings, async (req, res) => {
|
|
504
586
|
try {
|
|
505
|
-
const apiKey = loadApiKey();
|
|
587
|
+
const apiKey = await loadApiKey();
|
|
506
588
|
res.json({ apiKey: apiKey || null });
|
|
507
589
|
} catch (e) {
|
|
508
590
|
console.error('[API_KEY] Load failed:', e);
|
|
@@ -510,7 +592,7 @@ app.get('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
|
510
592
|
}
|
|
511
593
|
});
|
|
512
594
|
|
|
513
|
-
app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
595
|
+
app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
514
596
|
try {
|
|
515
597
|
const bodyKey = req.body && typeof req.body.apiKey === 'string' ? req.body.apiKey.trim() : '';
|
|
516
598
|
const apiKey = bodyKey || generateApiKey();
|
|
@@ -520,30 +602,33 @@ app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
|
520
602
|
console.error('[API_KEY] Save failed:', e);
|
|
521
603
|
res.status(500).json({ error: 'API_KEY_SAVE_FAILED', message: e.message });
|
|
522
604
|
}
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
app.get('/api/settings/user-agent', requireAuthForSettings, (_req, res) => {
|
|
526
|
-
try {
|
|
527
|
-
res.json(getUserAgentConfig());
|
|
528
|
-
} catch (e) {
|
|
529
|
-
console.error('[USER_AGENT] Load failed:', e);
|
|
530
|
-
res.status(500).json({ error: 'USER_AGENT_LOAD_FAILED' });
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
app.post('/api/settings/user-agent', requireAuthForSettings, (req, res) => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
res.
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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) => {
|
|
547
632
|
try {
|
|
548
633
|
res.json(listProxies());
|
|
549
634
|
} catch (e) {
|
|
@@ -552,35 +637,35 @@ app.get('/api/settings/proxies', requireAuthForSettings, (_req, res) => {
|
|
|
552
637
|
}
|
|
553
638
|
});
|
|
554
639
|
|
|
555
|
-
app.post('/api/settings/proxies', requireAuthForSettings, (req, res) => {
|
|
556
|
-
const { server, username, password, label } = req.body || {};
|
|
557
|
-
if (!server || typeof server !== 'string') {
|
|
558
|
-
return res.status(400).json({ error: 'MISSING_SERVER' });
|
|
559
|
-
}
|
|
560
|
-
try {
|
|
561
|
-
const result = addProxy({ server, username, password, label });
|
|
562
|
-
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
563
|
-
res.json(listProxies());
|
|
564
|
-
} catch (e) {
|
|
565
|
-
console.error('[PROXIES] Add failed:', e);
|
|
566
|
-
res.status(500).json({ error: 'PROXY_SAVE_FAILED' });
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
app.post('/api/settings/proxies/import', requireAuthForSettings, (req, res) => {
|
|
571
|
-
const entries = req.body && Array.isArray(req.body.proxies) ? req.body.proxies : [];
|
|
572
|
-
if (entries.length === 0) {
|
|
573
|
-
return res.status(400).json({ error: 'MISSING_PROXIES' });
|
|
574
|
-
}
|
|
575
|
-
try {
|
|
576
|
-
const result = addProxies(entries);
|
|
577
|
-
if (!result) return res.status(400).json({ error: 'INVALID_PROXY' });
|
|
578
|
-
res.json(listProxies());
|
|
579
|
-
} catch (e) {
|
|
580
|
-
console.error('[PROXIES] Import failed:', e);
|
|
581
|
-
res.status(500).json({ error: 'PROXY_IMPORT_FAILED' });
|
|
582
|
-
}
|
|
583
|
-
});
|
|
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
|
+
});
|
|
584
669
|
|
|
585
670
|
app.put('/api/settings/proxies/:id', requireAuthForSettings, (req, res) => {
|
|
586
671
|
const id = String(req.params.id || '').trim();
|
|
@@ -624,34 +709,34 @@ app.post('/api/settings/proxies/default', requireAuthForSettings, (req, res) =>
|
|
|
624
709
|
}
|
|
625
710
|
});
|
|
626
711
|
|
|
627
|
-
app.post('/api/settings/proxies/rotation', requireAuthForSettings, (req, res) => {
|
|
628
|
-
const body = req.body || {};
|
|
629
|
-
const hasIncludeDefault = Object.prototype.hasOwnProperty.call(body, 'includeDefaultInRotation');
|
|
630
|
-
const includeDefaultInRotation = !!body.includeDefaultInRotation;
|
|
631
|
-
const rotationMode = typeof body.rotationMode === 'string' ? body.rotationMode : null;
|
|
632
|
-
try {
|
|
633
|
-
if (hasIncludeDefault) setIncludeDefaultInRotation(includeDefaultInRotation);
|
|
634
|
-
if (rotationMode) setRotationMode(rotationMode);
|
|
635
|
-
res.json(listProxies());
|
|
636
|
-
} catch (e) {
|
|
637
|
-
console.error('[PROXIES] Rotation toggle failed:', e);
|
|
638
|
-
res.status(500).json({ error: 'PROXY_ROTATION_FAILED' });
|
|
639
|
-
}
|
|
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
|
+
}
|
|
640
725
|
});
|
|
641
726
|
|
|
642
727
|
|
|
643
|
-
app.post('/api/clear-screenshots', requireAuth, (req, res) => {
|
|
644
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
645
|
-
if (fs.existsSync(capturesDir)) {
|
|
646
|
-
for (const entry of fs.readdirSync(capturesDir)) {
|
|
647
|
-
const entryPath = path.join(capturesDir, entry);
|
|
648
|
-
if (fs.statSync(entryPath).isFile()) {
|
|
649
|
-
fs.unlinkSync(entryPath);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
res.json({ success: true });
|
|
654
|
-
});
|
|
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
|
+
});
|
|
655
740
|
|
|
656
741
|
app.post('/api/clear-cookies', requireAuth, (req, res) => {
|
|
657
742
|
if (fs.existsSync(STORAGE_STATE_FILE)) {
|
|
@@ -661,52 +746,67 @@ app.post('/api/clear-cookies', requireAuth, (req, res) => {
|
|
|
661
746
|
});
|
|
662
747
|
|
|
663
748
|
// --- TASKS API ---
|
|
664
|
-
app.get('/api/tasks', requireAuth, (req, res) => {
|
|
665
|
-
res.json(loadTasks());
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
app.get('/api/tasks/list', requireApiKey, (req, res) => {
|
|
669
|
-
const tasks = loadTasks();
|
|
670
|
-
const summary = tasks.map((task) => ({
|
|
671
|
-
id: task.id,
|
|
672
|
-
name: task.name || task.id
|
|
673
|
-
}));
|
|
674
|
-
res.json({ tasks: summary });
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
app.post('/api/tasks', requireAuth, (req, res) => {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
749
|
+
app.get('/api/tasks', requireAuth, async (req, res) => {
|
|
750
|
+
res.json(await loadTasks());
|
|
751
|
+
});
|
|
752
|
+
|
|
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 });
|
|
760
|
+
});
|
|
761
|
+
|
|
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
|
+
}
|
|
691
778
|
|
|
692
|
-
|
|
693
|
-
|
|
779
|
+
saveTasks(tasks);
|
|
780
|
+
res.json(newTask);
|
|
781
|
+
} finally {
|
|
782
|
+
taskMutex.unlock();
|
|
783
|
+
}
|
|
694
784
|
});
|
|
695
785
|
|
|
696
|
-
app.post('/api/tasks/:id/touch', requireAuth, (req, res) => {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
+
}
|
|
703
798
|
});
|
|
704
799
|
|
|
705
|
-
app.delete('/api/tasks/:id', requireAuth, (req, res) => {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
+
}
|
|
710
810
|
});
|
|
711
811
|
|
|
712
812
|
app.get('/api/executions', requireAuth, (req, res) => {
|
|
@@ -775,8 +875,8 @@ app.delete('/api/executions/:id', requireAuth, (req, res) => {
|
|
|
775
875
|
});
|
|
776
876
|
|
|
777
877
|
|
|
778
|
-
app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
|
|
779
|
-
const tasks = loadTasks();
|
|
878
|
+
app.get('/api/tasks/:id/versions', requireAuth, async (req, res) => {
|
|
879
|
+
const tasks = await loadTasks();
|
|
780
880
|
const task = tasks.find(t => t.id === req.params.id);
|
|
781
881
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
782
882
|
const versions = (task.versions || []).map(v => ({
|
|
@@ -787,8 +887,8 @@ app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
|
|
|
787
887
|
}));
|
|
788
888
|
res.json({ versions });
|
|
789
889
|
});
|
|
790
|
-
app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
|
|
791
|
-
const tasks = loadTasks();
|
|
890
|
+
app.get('/api/tasks/:id/versions/:versionId', requireAuth, async (req, res) => {
|
|
891
|
+
const tasks = await loadTasks();
|
|
792
892
|
const task = tasks.find(t => t.id === req.params.id);
|
|
793
893
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
794
894
|
const versions = task.versions || [];
|
|
@@ -797,88 +897,98 @@ app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
|
|
|
797
897
|
res.json({ snapshot: version.snapshot, metadata: { id: version.id, timestamp: version.timestamp } });
|
|
798
898
|
});
|
|
799
899
|
|
|
800
|
-
app.post('/api/tasks/:id/versions/clear', requireAuth, (req, res) => {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
}
|
|
807
912
|
});
|
|
808
913
|
|
|
809
|
-
app.post('/api/tasks/:id/rollback', requireAuth, (req, res) => {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
+
});
|
|
937
|
+
|
|
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 });
|
|
960
|
+
});
|
|
819
961
|
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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 });
|
|
826
979
|
});
|
|
827
980
|
|
|
828
|
-
app.
|
|
829
|
-
const
|
|
830
|
-
if (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
.
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const type = lower.endsWith('.webm') ? 'recording' : 'screenshot';
|
|
840
|
-
return {
|
|
841
|
-
name,
|
|
842
|
-
url: `/captures/${name}`,
|
|
843
|
-
size: stat.size,
|
|
844
|
-
modified: stat.mtimeMs,
|
|
845
|
-
type
|
|
846
|
-
};
|
|
847
|
-
})
|
|
848
|
-
.sort((a, b) => b.modified - a.modified);
|
|
849
|
-
res.json({ captures: entries });
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
app.get('/api/data/screenshots', requireAuth, (_req, res) => {
|
|
853
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
854
|
-
if (!fs.existsSync(capturesDir)) return res.json({ screenshots: [] });
|
|
855
|
-
const entries = fs.readdirSync(capturesDir)
|
|
856
|
-
.filter(name => /\.(png|jpg|jpeg)$/i.test(name))
|
|
857
|
-
.map((name) => {
|
|
858
|
-
const fullPath = path.join(capturesDir, name);
|
|
859
|
-
const stat = fs.statSync(fullPath);
|
|
860
|
-
return {
|
|
861
|
-
name,
|
|
862
|
-
url: `/captures/${name}`,
|
|
863
|
-
size: stat.size,
|
|
864
|
-
modified: stat.mtimeMs
|
|
865
|
-
};
|
|
866
|
-
})
|
|
867
|
-
.sort((a, b) => b.modified - a.modified);
|
|
868
|
-
res.json({ screenshots: entries });
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
app.delete('/api/data/captures/:name', requireAuth, (req, res) => {
|
|
872
|
-
const name = req.params.name;
|
|
873
|
-
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
874
|
-
return res.status(400).json({ error: 'INVALID_NAME' });
|
|
875
|
-
}
|
|
876
|
-
const targetPath = path.join(__dirname, 'public', 'captures', name);
|
|
877
|
-
if (fs.existsSync(targetPath)) {
|
|
878
|
-
fs.unlinkSync(targetPath);
|
|
879
|
-
}
|
|
880
|
-
res.json({ success: true });
|
|
881
|
-
});
|
|
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
|
+
});
|
|
882
992
|
|
|
883
993
|
app.get('/api/data/cookies', requireAuth, (req, res) => {
|
|
884
994
|
if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ cookies: [], origins: [] });
|
|
@@ -915,8 +1025,8 @@ app.post('/api/data/cookies/delete', requireAuth, (req, res) => {
|
|
|
915
1025
|
});
|
|
916
1026
|
|
|
917
1027
|
// --- TASK API EXECUTION ---
|
|
918
|
-
app.post('/tasks/:id/api', requireApiKey, async (req, res) => {
|
|
919
|
-
const tasks = loadTasks();
|
|
1028
|
+
app.post('/tasks/:id/api', requireApiKey, dataRateLimiter, async (req, res) => {
|
|
1029
|
+
const tasks = await loadTasks();
|
|
920
1030
|
const task = tasks.find(t => String(t.id) === String(req.params.id));
|
|
921
1031
|
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
922
1032
|
|
|
@@ -1026,34 +1136,34 @@ app.get('/tasks/:id', requireAuth, (req, res) => {
|
|
|
1026
1136
|
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1027
1137
|
});
|
|
1028
1138
|
|
|
1029
|
-
// Settings
|
|
1030
|
-
app.get('/settings', requireAuth, (req, res) => {
|
|
1031
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
// Captures
|
|
1035
|
-
app.get('/captures', requireAuth, (req, res) => {
|
|
1036
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
// Executions (SPA routes)
|
|
1040
|
-
app.get('/executions', requireAuth, (req, res) => {
|
|
1041
|
-
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1042
|
-
});
|
|
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
|
+
});
|
|
1043
1153
|
app.get('/executions/:id', requireAuth, (req, res) => {
|
|
1044
1154
|
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
1045
1155
|
});
|
|
1046
1156
|
|
|
1047
1157
|
// Execution endpoints
|
|
1048
|
-
app.all('/scrape', requireAuth, (req, res) => {
|
|
1158
|
+
app.all('/scrape', requireAuth, dataRateLimiter, (req, res) => {
|
|
1049
1159
|
registerExecution(req, res, { mode: 'scrape' });
|
|
1050
1160
|
return handleScrape(req, res);
|
|
1051
1161
|
});
|
|
1052
|
-
app.all('/scraper', requireAuth, (req, res) => {
|
|
1162
|
+
app.all('/scraper', requireAuth, dataRateLimiter, (req, res) => {
|
|
1053
1163
|
registerExecution(req, res, { mode: 'scrape' });
|
|
1054
1164
|
return handleScrape(req, res);
|
|
1055
1165
|
});
|
|
1056
|
-
app.all('/agent', requireAuth, (req, res) => {
|
|
1166
|
+
app.all('/agent', requireAuth, dataRateLimiter, (req, res) => {
|
|
1057
1167
|
registerExecution(req, res, { mode: 'agent' });
|
|
1058
1168
|
try {
|
|
1059
1169
|
const runId = String((req.body && req.body.runId) || req.query.runId || '').trim();
|
|
@@ -1065,7 +1175,7 @@ app.all('/agent', requireAuth, (req, res) => {
|
|
|
1065
1175
|
}
|
|
1066
1176
|
return handleAgent(req, res);
|
|
1067
1177
|
});
|
|
1068
|
-
app.post('/headful', requireAuth, (req, res) => {
|
|
1178
|
+
app.post('/headful', requireAuth, dataRateLimiter, (req, res) => {
|
|
1069
1179
|
registerExecution(req, res, { mode: 'headful' });
|
|
1070
1180
|
if (req.body && typeof req.body.url === 'string') {
|
|
1071
1181
|
const vars = req.body.taskVariables || req.body.variables || {};
|
|
@@ -1079,11 +1189,11 @@ app.post('/headful', requireAuth, (req, res) => {
|
|
|
1079
1189
|
});
|
|
1080
1190
|
app.post('/headful/stop', requireAuth, stopHeadful);
|
|
1081
1191
|
|
|
1082
|
-
// Ensure public/captures directory exists
|
|
1083
|
-
const capturesDir = path.join(__dirname, 'public', 'captures');
|
|
1084
|
-
if (!fs.existsSync(capturesDir)) {
|
|
1085
|
-
fs.mkdirSync(capturesDir, { recursive: true });
|
|
1086
|
-
}
|
|
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
|
+
}
|
|
1087
1197
|
|
|
1088
1198
|
const novncDirCandidates = [
|
|
1089
1199
|
'/opt/novnc',
|
|
@@ -1103,8 +1213,8 @@ if (novncDir) {
|
|
|
1103
1213
|
app.use('/novnc', express.static(novncDir));
|
|
1104
1214
|
}
|
|
1105
1215
|
|
|
1106
|
-
app.use('/captures', express.static(capturesDir));
|
|
1107
|
-
app.use('/screenshots', express.static(capturesDir));
|
|
1216
|
+
app.use('/captures', express.static(capturesDir));
|
|
1217
|
+
app.use('/screenshots', express.static(capturesDir));
|
|
1108
1218
|
app.use(express.static(DIST_DIR));
|
|
1109
1219
|
|
|
1110
1220
|
app.get('/api/headful/status', (req, res) => {
|
|
@@ -1223,8 +1333,8 @@ findAvailablePort(port, 20)
|
|
|
1223
1333
|
const displayPort = typeof address === 'object' && address ? address.port : availablePort;
|
|
1224
1334
|
console.log(`Server running at http://localhost:${displayPort}`);
|
|
1225
1335
|
});
|
|
1226
|
-
server.on('upgrade', (req, socket, head) => {
|
|
1227
|
-
if (!isIpAllowed(req.socket?.remoteAddress)) {
|
|
1336
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
1337
|
+
if (!await isIpAllowed(req.socket?.remoteAddress)) {
|
|
1228
1338
|
try {
|
|
1229
1339
|
socket.destroy();
|
|
1230
1340
|
} catch {
|