@doppelgangerdev/doppelganger 0.2.2

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.
Files changed (63) hide show
  1. package/.dockerignore +9 -0
  2. package/.github/workflows/docker-publish.yml +59 -0
  3. package/CODE_OF_CONDUCT.md +28 -0
  4. package/CONTRIBUTING.md +42 -0
  5. package/Dockerfile +44 -0
  6. package/LICENSE +163 -0
  7. package/README.md +133 -0
  8. package/TERMS.md +16 -0
  9. package/THIRD_PARTY_LICENSES.md +3502 -0
  10. package/agent.js +1240 -0
  11. package/headful.js +171 -0
  12. package/index.html +21 -0
  13. package/n8n-nodes-doppelganger/LICENSE +201 -0
  14. package/n8n-nodes-doppelganger/README.md +42 -0
  15. package/n8n-nodes-doppelganger/package-lock.json +6128 -0
  16. package/n8n-nodes-doppelganger/package.json +36 -0
  17. package/n8n-nodes-doppelganger/src/credentials/DoppelgangerApi.credentials.ts +35 -0
  18. package/n8n-nodes-doppelganger/src/index.ts +4 -0
  19. package/n8n-nodes-doppelganger/src/nodes/Doppelganger/Doppelganger.node.ts +147 -0
  20. package/n8n-nodes-doppelganger/src/nodes/Doppelganger/icon.png +0 -0
  21. package/n8n-nodes-doppelganger/tsconfig.json +14 -0
  22. package/package.json +45 -0
  23. package/postcss.config.js +6 -0
  24. package/public/icon.png +0 -0
  25. package/public/novnc.html +151 -0
  26. package/public/styles.css +86 -0
  27. package/scrape.js +389 -0
  28. package/server.js +875 -0
  29. package/src/App.tsx +722 -0
  30. package/src/components/AuthScreen.tsx +95 -0
  31. package/src/components/CodeEditor.tsx +70 -0
  32. package/src/components/DashboardScreen.tsx +133 -0
  33. package/src/components/EditorScreen.tsx +1519 -0
  34. package/src/components/ExecutionDetailScreen.tsx +115 -0
  35. package/src/components/ExecutionsScreen.tsx +156 -0
  36. package/src/components/LoadingScreen.tsx +26 -0
  37. package/src/components/NotFoundScreen.tsx +34 -0
  38. package/src/components/RichInput.tsx +68 -0
  39. package/src/components/SettingsScreen.tsx +228 -0
  40. package/src/components/Sidebar.tsx +61 -0
  41. package/src/components/app/CenterAlert.tsx +44 -0
  42. package/src/components/app/CenterConfirm.tsx +33 -0
  43. package/src/components/app/EditorLoader.tsx +89 -0
  44. package/src/components/editor/ActionPalette.tsx +79 -0
  45. package/src/components/editor/JsonEditorPane.tsx +71 -0
  46. package/src/components/editor/ResultsPane.tsx +641 -0
  47. package/src/components/editor/actionCatalog.ts +23 -0
  48. package/src/components/settings/AgentAiPanel.tsx +105 -0
  49. package/src/components/settings/ApiKeyPanel.tsx +68 -0
  50. package/src/components/settings/CookiesPanel.tsx +154 -0
  51. package/src/components/settings/LayoutPanel.tsx +46 -0
  52. package/src/components/settings/ScreenshotsPanel.tsx +64 -0
  53. package/src/components/settings/SettingsHeader.tsx +28 -0
  54. package/src/components/settings/StoragePanel.tsx +35 -0
  55. package/src/index.css +287 -0
  56. package/src/main.tsx +13 -0
  57. package/src/types.ts +114 -0
  58. package/src/utils/syntaxHighlight.ts +140 -0
  59. package/start-vnc.sh +52 -0
  60. package/tailwind.config.js +22 -0
  61. package/tsconfig.json +39 -0
  62. package/tsconfig.node.json +12 -0
  63. package/vite.config.mts +27 -0
package/server.js ADDED
@@ -0,0 +1,875 @@
1
+ const express = require('express');
2
+ const session = require('express-session');
3
+ const FileStore = require('session-file-store')(session);
4
+ const bcrypt = require('bcryptjs');
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const net = require('net');
9
+ const app = express();
10
+ const DEFAULT_PORT = 11345;
11
+ const port = Number(process.env.PORT) || DEFAULT_PORT;
12
+ const DIST_DIR = path.join(__dirname, 'dist');
13
+ const SESSION_SECRET_FILE = path.join(__dirname, 'data', 'session_secret.txt');
14
+ let SESSION_SECRET = process.env.SESSION_SECRET;
15
+
16
+ if (!SESSION_SECRET) {
17
+ try {
18
+ if (fs.existsSync(SESSION_SECRET_FILE)) {
19
+ SESSION_SECRET = fs.readFileSync(SESSION_SECRET_FILE, 'utf8').trim();
20
+ } else {
21
+ SESSION_SECRET = crypto.randomBytes(48).toString('hex');
22
+ fs.writeFileSync(SESSION_SECRET_FILE, SESSION_SECRET);
23
+ }
24
+ } catch (e) {
25
+ console.warn('Failed to load session secret from disk, falling back to process env only.');
26
+ }
27
+ }
28
+
29
+ if (!SESSION_SECRET) {
30
+ throw new Error('SESSION_SECRET environment variable is required');
31
+ }
32
+
33
+ const USERS_FILE = path.join(__dirname, 'data', 'users.json');
34
+
35
+ // Ensure data directory exists
36
+ if (!fs.existsSync(path.join(__dirname, 'data'))) {
37
+ fs.mkdirSync(path.join(__dirname, 'data'));
38
+ }
39
+
40
+ // Ensure sessions directory exists
41
+ const SESSIONS_DIR = path.join(__dirname, 'data', 'sessions');
42
+ if (!fs.existsSync(SESSIONS_DIR)) {
43
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
44
+ }
45
+
46
+ // Helper to load users
47
+ function loadUsers() {
48
+ if (!fs.existsSync(USERS_FILE)) return [];
49
+ try {
50
+ return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
51
+ } catch (e) {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ // Helper to save users
57
+ function saveUsers(users) {
58
+ fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
59
+ }
60
+
61
+ const TASKS_FILE = path.join(__dirname, 'data', 'tasks.json');
62
+ const API_KEY_FILE = path.join(__dirname, 'data', 'api_key.json');
63
+ const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
64
+ const STORAGE_STATE_FILE = (() => {
65
+ try {
66
+ if (fs.existsSync(STORAGE_STATE_PATH)) {
67
+ const stat = fs.statSync(STORAGE_STATE_PATH);
68
+ if (stat.isDirectory()) {
69
+ return path.join(STORAGE_STATE_PATH, 'storage_state.json');
70
+ }
71
+ }
72
+ } catch {}
73
+ return STORAGE_STATE_PATH;
74
+ })();
75
+ const MAX_TASK_VERSIONS = 30;
76
+ const EXECUTIONS_FILE = path.join(__dirname, 'data', 'executions.json');
77
+ const MAX_EXECUTIONS = 500;
78
+ const executionStreams = new Map();
79
+ const stopRequests = new Set();
80
+
81
+ const sendExecutionUpdate = (runId, payload) => {
82
+ if (!runId) return;
83
+ const clients = executionStreams.get(runId);
84
+ if (!clients || clients.size === 0) return;
85
+ const data = `data: ${JSON.stringify(payload)}\n\n`;
86
+ clients.forEach((res) => {
87
+ try {
88
+ res.write(data);
89
+ } catch {
90
+ // ignore
91
+ }
92
+ });
93
+ };
94
+
95
+ // Helper to load tasks
96
+ function loadTasks() {
97
+ if (!fs.existsSync(TASKS_FILE)) return [];
98
+ try {
99
+ return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8'));
100
+ } catch (e) {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ // Helper to save tasks
106
+ function saveTasks(tasks) {
107
+ fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
108
+ }
109
+
110
+ function cloneTaskForVersion(task) {
111
+ const copy = JSON.parse(JSON.stringify(task || {}));
112
+ if (copy.versions) delete copy.versions;
113
+ return copy;
114
+ }
115
+
116
+ function appendTaskVersion(task) {
117
+ if (!task) return;
118
+ if (!task.versions) task.versions = [];
119
+ const version = {
120
+ id: 'ver_' + Date.now(),
121
+ timestamp: Date.now(),
122
+ snapshot: cloneTaskForVersion(task)
123
+ };
124
+ task.versions.unshift(version);
125
+ if (task.versions.length > MAX_TASK_VERSIONS) {
126
+ task.versions = task.versions.slice(0, MAX_TASK_VERSIONS);
127
+ }
128
+ }
129
+
130
+ function loadExecutions() {
131
+ if (!fs.existsSync(EXECUTIONS_FILE)) return [];
132
+ try {
133
+ return JSON.parse(fs.readFileSync(EXECUTIONS_FILE, 'utf8'));
134
+ } catch (e) {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ function saveExecutions(executions) {
140
+ fs.writeFileSync(EXECUTIONS_FILE, JSON.stringify(executions, null, 2));
141
+ }
142
+
143
+ function appendExecution(entry) {
144
+ const executions = loadExecutions();
145
+ executions.unshift(entry);
146
+ if (executions.length > MAX_EXECUTIONS) {
147
+ executions.length = MAX_EXECUTIONS;
148
+ }
149
+ saveExecutions(executions);
150
+ }
151
+
152
+ function registerExecution(req, res, baseMeta = {}) {
153
+ const start = Date.now();
154
+ const requestId = 'exec_' + start + '_' + Math.floor(Math.random() * 1000);
155
+ res.locals.executionId = requestId;
156
+ const originalJson = res.json.bind(res);
157
+ res.json = (body) => {
158
+ res.locals.executionResult = body;
159
+ return originalJson(body);
160
+ };
161
+ res.on('finish', () => {
162
+ const durationMs = Date.now() - start;
163
+ const body = req.body || {};
164
+ const entry = {
165
+ id: requestId,
166
+ timestamp: start,
167
+ method: req.method,
168
+ path: req.path,
169
+ status: res.statusCode,
170
+ durationMs,
171
+ source: body.runSource || req.query.runSource || baseMeta.source || 'unknown',
172
+ mode: body.mode || baseMeta.mode || 'unknown',
173
+ taskId: body.taskId || baseMeta.taskId || null,
174
+ taskName: body.name || baseMeta.taskName || null,
175
+ url: body.url || req.query.url || null,
176
+ taskSnapshot: body.taskSnapshot || null,
177
+ result: res.locals.executionResult || null
178
+ };
179
+ appendExecution(entry);
180
+ });
181
+ }
182
+
183
+ // Helper to load API key
184
+ function loadApiKey() {
185
+ let apiKey = null;
186
+ if (fs.existsSync(API_KEY_FILE)) {
187
+ try {
188
+ const data = JSON.parse(fs.readFileSync(API_KEY_FILE, 'utf8'));
189
+ apiKey = data && data.apiKey ? data.apiKey : null;
190
+ } catch (e) {
191
+ apiKey = null;
192
+ }
193
+ }
194
+
195
+ if (!apiKey && fs.existsSync(USERS_FILE)) {
196
+ try {
197
+ const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
198
+ if (Array.isArray(users) && users.length > 0 && users[0].apiKey) {
199
+ apiKey = users[0].apiKey;
200
+ saveApiKey(apiKey);
201
+ }
202
+ } catch (e) {
203
+ // ignore
204
+ }
205
+ }
206
+
207
+ return apiKey;
208
+ }
209
+
210
+ // Helper to save API key
211
+ function saveApiKey(apiKey) {
212
+ fs.writeFileSync(API_KEY_FILE, JSON.stringify({ apiKey }, null, 2));
213
+ if (fs.existsSync(USERS_FILE)) {
214
+ try {
215
+ const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
216
+ if (Array.isArray(users) && users.length > 0) {
217
+ users[0].apiKey = apiKey;
218
+ fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
219
+ }
220
+ } catch (e) {
221
+ // ignore
222
+ }
223
+ }
224
+ }
225
+
226
+ function generateApiKey() {
227
+ return crypto.randomBytes(32).toString('hex');
228
+ }
229
+
230
+ const { handleScrape } = require('./scrape');
231
+ const { handleAgent, setProgressReporter, setStopChecker } = require('./agent');
232
+ const { handleHeadful, stopHeadful } = require('./headful');
233
+
234
+ setProgressReporter(sendExecutionUpdate);
235
+ setStopChecker((runId) => {
236
+ if (!runId) return false;
237
+ if (stopRequests.has(runId)) {
238
+ stopRequests.delete(runId);
239
+ return true;
240
+ }
241
+ return false;
242
+ });
243
+
244
+ app.use(express.json());
245
+ const SESSION_TTL_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years
246
+
247
+ app.use(session({
248
+ store: new FileStore({
249
+ path: SESSIONS_DIR,
250
+ ttl: SESSION_TTL_SECONDS,
251
+ retries: 0
252
+ }),
253
+ secret: SESSION_SECRET,
254
+ resave: false,
255
+ saveUninitialized: false,
256
+ cookie: {
257
+ secure: false,
258
+ maxAge: SESSION_TTL_SECONDS * 1000
259
+ }
260
+ }));
261
+
262
+ // Auth Middleware
263
+ const requireAuth = (req, res, next) => {
264
+ console.log(`[AUTH] Path: ${req.path}, Session: ${req.session.user ? 'YES' : 'NO'}`);
265
+ if (req.session.user) {
266
+ next();
267
+ } else {
268
+ if (req.xhr || req.path.startsWith('/api/')) {
269
+ res.status(401).json({ error: 'UNAUTHORIZED' });
270
+ } else {
271
+ res.redirect('/login');
272
+ }
273
+ }
274
+ };
275
+
276
+ const requireAuthForSettings = (req, res, next) => {
277
+ if (process.env.NODE_ENV !== 'production') return next();
278
+ return requireAuth(req, res, next);
279
+ };
280
+
281
+ const requireApiKey = (req, res, next) => {
282
+ const headerKey = req.get('x-api-key') || req.get('key');
283
+ const authHeader = req.get('authorization');
284
+ const bearerKey = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
285
+ const bodyKey = typeof req.body === 'string' ? req.body : null;
286
+ const providedKey =
287
+ headerKey ||
288
+ bearerKey ||
289
+ req.query.apiKey ||
290
+ req.query.key ||
291
+ (req.body && (req.body.apiKey || req.body.key)) ||
292
+ bodyKey;
293
+ const storedKey = loadApiKey();
294
+
295
+ if (!storedKey) {
296
+ return res.status(403).json({ error: 'API_KEY_NOT_SET' });
297
+ }
298
+ if (!providedKey || providedKey !== storedKey) {
299
+ return res.status(401).json({ error: 'INVALID_API_KEY' });
300
+ }
301
+ next();
302
+ };
303
+
304
+ // --- AUTH API ---
305
+ app.get('/api/auth/check-setup', (req, res) => {
306
+ console.log("DEBUG: Hit check-setup");
307
+ try {
308
+ const users = loadUsers();
309
+ console.log("DEBUG: check-setup users length:", users.length);
310
+ res.json({ setupRequired: users.length === 0 });
311
+ } catch (e) {
312
+ console.error("DEBUG: check-setup error", e);
313
+ res.status(500).json({ error: e.message });
314
+ }
315
+ });
316
+
317
+ app.post('/api/auth/setup', async (req, res) => {
318
+ const users = loadUsers();
319
+ if (users.length > 0) return res.status(403).json({ error: 'ALREADY_SETUP' });
320
+ const { name, email, password } = req.body;
321
+ const normalizedEmail = String(email || '').trim().toLowerCase();
322
+ if (!name || !normalizedEmail || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
323
+
324
+ const hashedPassword = await bcrypt.hash(password, 10);
325
+ const newUser = { id: Date.now(), name, email: normalizedEmail, password: hashedPassword };
326
+ saveUsers([newUser]);
327
+ req.session.user = { id: newUser.id, name: newUser.name, email: newUser.email };
328
+ res.json({ success: true });
329
+ });
330
+
331
+ app.post('/api/auth/login', async (req, res) => {
332
+ const { email, password } = req.body;
333
+ const normalizedEmail = String(email || '').trim().toLowerCase();
334
+ const users = loadUsers();
335
+ const user = users.find(u => String(u.email || '').toLowerCase() === normalizedEmail);
336
+ if (user && await bcrypt.compare(password, user.password)) {
337
+ req.session.user = { id: user.id, name: user.name, email: user.email };
338
+ res.json({ success: true });
339
+ } else {
340
+ res.status(401).json({ error: 'INVALID' });
341
+ }
342
+ });
343
+
344
+ app.post('/api/auth/logout', (req, res) => {
345
+ req.session.destroy(() => {
346
+ res.clearCookie('connect.sid');
347
+ res.json({ success: true });
348
+ });
349
+ });
350
+
351
+ app.get('/api/auth/me', (req, res) => {
352
+ res.json(req.session.user ? { authenticated: true, user: req.session.user } : { authenticated: false });
353
+ });
354
+
355
+ // --- SETTINGS API ---
356
+ app.get('/api/settings/api-key', requireAuthForSettings, (req, res) => {
357
+ try {
358
+ const apiKey = loadApiKey();
359
+ res.json({ apiKey: apiKey || null });
360
+ } catch (e) {
361
+ console.error('[API_KEY] Load failed:', e);
362
+ res.status(500).json({ error: 'API_KEY_LOAD_FAILED' });
363
+ }
364
+ });
365
+
366
+ app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
367
+ try {
368
+ const bodyKey = req.body && typeof req.body.apiKey === 'string' ? req.body.apiKey.trim() : '';
369
+ const apiKey = bodyKey || generateApiKey();
370
+ saveApiKey(apiKey);
371
+ res.json({ apiKey });
372
+ } catch (e) {
373
+ console.error('[API_KEY] Save failed:', e);
374
+ res.status(500).json({ error: 'API_KEY_SAVE_FAILED', message: e.message });
375
+ }
376
+ });
377
+
378
+
379
+ app.post('/api/clear-screenshots', requireAuth, (req, res) => {
380
+ const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
381
+ if (fs.existsSync(screenshotsDir)) {
382
+ for (const entry of fs.readdirSync(screenshotsDir)) {
383
+ const entryPath = path.join(screenshotsDir, entry);
384
+ if (fs.statSync(entryPath).isFile()) {
385
+ fs.unlinkSync(entryPath);
386
+ }
387
+ }
388
+ }
389
+ res.json({ success: true });
390
+ });
391
+
392
+ app.post('/api/clear-cookies', requireAuth, (req, res) => {
393
+ if (fs.existsSync(STORAGE_STATE_FILE)) {
394
+ fs.unlinkSync(STORAGE_STATE_FILE);
395
+ }
396
+ res.json({ success: true });
397
+ });
398
+
399
+ // --- TASKS API ---
400
+ app.get('/api/tasks', requireAuth, (req, res) => {
401
+ res.json(loadTasks());
402
+ });
403
+
404
+ app.post('/api/tasks', requireAuth, (req, res) => {
405
+ const tasks = loadTasks();
406
+ const newTask = req.body;
407
+ if (!newTask.id) newTask.id = 'task_' + Date.now();
408
+
409
+ const index = tasks.findIndex(t => t.id === newTask.id);
410
+ if (index > -1) {
411
+ appendTaskVersion(tasks[index]);
412
+ newTask.versions = tasks[index].versions || [];
413
+ tasks[index] = newTask;
414
+ } else {
415
+ newTask.versions = [];
416
+ tasks.push(newTask);
417
+ }
418
+
419
+ saveTasks(tasks);
420
+ res.json(newTask);
421
+ });
422
+
423
+ app.post('/api/tasks/:id/touch', requireAuth, (req, res) => {
424
+ const tasks = loadTasks();
425
+ const index = tasks.findIndex(t => t.id === req.params.id);
426
+ if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
427
+ tasks[index].last_opened = Date.now();
428
+ saveTasks(tasks);
429
+ res.json(tasks[index]);
430
+ });
431
+
432
+ app.delete('/api/tasks/:id', requireAuth, (req, res) => {
433
+ let tasks = loadTasks();
434
+ tasks = tasks.filter(t => t.id !== req.params.id);
435
+ saveTasks(tasks);
436
+ res.json({ success: true });
437
+ });
438
+
439
+ app.get('/api/executions', requireAuth, (req, res) => {
440
+ const executions = loadExecutions();
441
+ res.json({ executions });
442
+ });
443
+ app.get('/api/executions/stream', requireAuth, (req, res) => {
444
+ const runId = String(req.query.runId || '').trim();
445
+ if (!runId) return res.status(400).json({ error: 'MISSING_RUN_ID' });
446
+ res.setHeader('Content-Type', 'text/event-stream');
447
+ res.setHeader('Cache-Control', 'no-cache');
448
+ res.setHeader('Connection', 'keep-alive');
449
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
450
+ res.write('event: ready\ndata: {}\n\n');
451
+
452
+ let clients = executionStreams.get(runId);
453
+ if (!clients) {
454
+ clients = new Set();
455
+ executionStreams.set(runId, clients);
456
+ }
457
+ clients.add(res);
458
+
459
+ const keepAlive = setInterval(() => {
460
+ try {
461
+ res.write(':keep-alive\n\n');
462
+ } catch {
463
+ // ignore
464
+ }
465
+ }, 20000);
466
+
467
+ req.on('close', () => {
468
+ clearInterval(keepAlive);
469
+ clients.delete(res);
470
+ if (clients.size === 0) executionStreams.delete(runId);
471
+ });
472
+ });
473
+ app.get('/api/executions/:id', requireAuth, (req, res) => {
474
+ const executions = loadExecutions();
475
+ const exec = executions.find(e => e.id === req.params.id);
476
+ if (!exec) return res.status(404).json({ error: 'EXECUTION_NOT_FOUND' });
477
+ res.json({ execution: exec });
478
+ });
479
+
480
+ app.post('/api/executions/clear', requireAuth, (req, res) => {
481
+ saveExecutions([]);
482
+ res.json({ success: true });
483
+ try {
484
+ if (runId) sendExecutionUpdate(runId, { status: 'stop_requested' });
485
+ } catch {
486
+ // ignore
487
+ }
488
+ });
489
+
490
+ app.post('/api/executions/stop', requireAuth, (req, res) => {
491
+ const runId = String(req.body?.runId || '').trim();
492
+ if (!runId) return res.status(400).json({ error: 'MISSING_RUN_ID' });
493
+ stopRequests.add(runId);
494
+ res.json({ success: true });
495
+ });
496
+
497
+ app.delete('/api/executions/:id', requireAuth, (req, res) => {
498
+ const id = req.params.id;
499
+ const executions = loadExecutions().filter(e => e.id !== id);
500
+ saveExecutions(executions);
501
+ res.json({ success: true });
502
+ });
503
+
504
+
505
+ app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
506
+ const tasks = loadTasks();
507
+ const task = tasks.find(t => t.id === req.params.id);
508
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
509
+ const versions = (task.versions || []).map(v => ({
510
+ id: v.id,
511
+ timestamp: v.timestamp,
512
+ name: v.snapshot?.name || task.name,
513
+ mode: v.snapshot?.mode || task.mode
514
+ }));
515
+ res.json({ versions });
516
+ });
517
+ app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
518
+ const tasks = loadTasks();
519
+ const task = tasks.find(t => t.id === req.params.id);
520
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
521
+ const versions = task.versions || [];
522
+ const version = versions.find(v => v.id === req.params.versionId);
523
+ if (!version || !version.snapshot) return res.status(404).json({ error: 'VERSION_NOT_FOUND' });
524
+ res.json({ snapshot: version.snapshot, metadata: { id: version.id, timestamp: version.timestamp } });
525
+ });
526
+
527
+ app.post('/api/tasks/:id/versions/clear', requireAuth, (req, res) => {
528
+ const tasks = loadTasks();
529
+ const index = tasks.findIndex(t => t.id === req.params.id);
530
+ if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
531
+ tasks[index].versions = [];
532
+ saveTasks(tasks);
533
+ res.json({ success: true });
534
+ });
535
+
536
+ app.post('/api/tasks/:id/rollback', requireAuth, (req, res) => {
537
+ const { versionId } = req.body || {};
538
+ if (!versionId) return res.status(400).json({ error: 'MISSING_VERSION_ID' });
539
+ const tasks = loadTasks();
540
+ const index = tasks.findIndex(t => t.id === req.params.id);
541
+ if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
542
+ const task = tasks[index];
543
+ const versions = task.versions || [];
544
+ const version = versions.find(v => v.id === versionId);
545
+ if (!version || !version.snapshot) return res.status(404).json({ error: 'VERSION_NOT_FOUND' });
546
+
547
+ appendTaskVersion(task);
548
+ const restored = { ...cloneTaskForVersion(version.snapshot), id: task.id, versions: task.versions };
549
+ restored.last_opened = Date.now();
550
+ tasks[index] = restored;
551
+ saveTasks(tasks);
552
+ res.json(restored);
553
+ });
554
+
555
+ app.get('/api/data/screenshots', requireAuth, (req, res) => {
556
+ const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
557
+ if (!fs.existsSync(screenshotsDir)) return res.json({ screenshots: [] });
558
+ const entries = fs.readdirSync(screenshotsDir)
559
+ .filter(name => name.toLowerCase().endsWith('.png'))
560
+ .map((name) => {
561
+ const fullPath = path.join(screenshotsDir, name);
562
+ const stat = fs.statSync(fullPath);
563
+ return {
564
+ name,
565
+ url: `/screenshots/${name}`,
566
+ size: stat.size,
567
+ modified: stat.mtimeMs
568
+ };
569
+ })
570
+ .sort((a, b) => b.modified - a.modified);
571
+ res.json({ screenshots: entries });
572
+ });
573
+
574
+ app.delete('/api/data/screenshots/:name', requireAuth, (req, res) => {
575
+ const name = req.params.name;
576
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
577
+ return res.status(400).json({ error: 'INVALID_NAME' });
578
+ }
579
+ const targetPath = path.join(__dirname, 'public', 'screenshots', name);
580
+ if (fs.existsSync(targetPath)) {
581
+ fs.unlinkSync(targetPath);
582
+ }
583
+ res.json({ success: true });
584
+ });
585
+
586
+ app.get('/api/data/cookies', requireAuth, (req, res) => {
587
+ if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ cookies: [], origins: [] });
588
+ try {
589
+ const data = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
590
+ res.json({
591
+ cookies: Array.isArray(data.cookies) ? data.cookies : [],
592
+ origins: Array.isArray(data.origins) ? data.origins : []
593
+ });
594
+ } catch (e) {
595
+ res.json({ cookies: [], origins: [] });
596
+ }
597
+ });
598
+
599
+ app.post('/api/data/cookies/delete', requireAuth, (req, res) => {
600
+ const { name, domain, path: cookiePath } = req.body || {};
601
+ if (!name) return res.status(400).json({ error: 'MISSING_NAME' });
602
+ if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ success: true });
603
+ try {
604
+ const data = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
605
+ const cookies = Array.isArray(data.cookies) ? data.cookies : [];
606
+ const filtered = cookies.filter((cookie) => {
607
+ if (cookie.name !== name) return true;
608
+ if (domain && cookie.domain !== domain) return true;
609
+ if (cookiePath && cookie.path !== cookiePath) return true;
610
+ return false;
611
+ });
612
+ data.cookies = filtered;
613
+ fs.writeFileSync(STORAGE_STATE_FILE, JSON.stringify(data, null, 2));
614
+ res.json({ success: true });
615
+ } catch (e) {
616
+ res.status(500).json({ error: 'DELETE_FAILED' });
617
+ }
618
+ });
619
+
620
+ // --- TASK API EXECUTION ---
621
+ app.post('/tasks/:id/api', requireApiKey, async (req, res) => {
622
+ const tasks = loadTasks();
623
+ const task = tasks.find(t => String(t.id) === String(req.params.id));
624
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
625
+
626
+ const normalizeVariables = (vars) => {
627
+ const normalized = {};
628
+ if (!vars || typeof vars !== 'object') return normalized;
629
+ for (const [key, value] of Object.entries(vars)) {
630
+ if (value && typeof value === 'object' && 'value' in value) {
631
+ normalized[key] = value.value;
632
+ } else {
633
+ normalized[key] = value;
634
+ }
635
+ }
636
+ return normalized;
637
+ };
638
+
639
+ const baseVars = normalizeVariables(task.variables);
640
+ const overrideVars = normalizeVariables(req.body.variables || req.body.taskVariables || {});
641
+ const mergedVars = { ...baseVars, ...overrideVars };
642
+
643
+ const resolveTemplate = (input) => {
644
+ if (typeof input !== 'string') return input;
645
+ return input.replace(/\{\$(\w+)\}/g, (_match, name) => {
646
+ if (name === 'now') return new Date().toISOString();
647
+ const value = mergedVars[name];
648
+ if (value === undefined || value === null || value === '') return '';
649
+ return String(value);
650
+ });
651
+ };
652
+
653
+ const resolvedTask = {
654
+ ...task,
655
+ url: resolveTemplate(task.url || ''),
656
+ selector: resolveTemplate(task.selector),
657
+ extractionScript: resolveTemplate(task.extractionScript || ''),
658
+ extractionFormat: task.extractionFormat || 'json',
659
+ includeShadowDom: task.includeShadowDom !== undefined ? task.includeShadowDom : true,
660
+ actions: Array.isArray(task.actions)
661
+ ? task.actions.map((action) => ({
662
+ ...action,
663
+ selector: resolveTemplate(action.selector),
664
+ value: resolveTemplate(action.value),
665
+ key: resolveTemplate(action.key)
666
+ }))
667
+ : []
668
+ };
669
+
670
+ req.body = {
671
+ ...resolvedTask,
672
+ taskVariables: mergedVars,
673
+ variables: mergedVars,
674
+ runSource: 'api',
675
+ taskId: task.id,
676
+ taskName: task.name
677
+ };
678
+
679
+ const mode = resolvedTask.mode || 'scrape';
680
+ registerExecution(req, res, { source: 'api', mode, taskId: task.id, taskName: task.name });
681
+ if (mode === 'scrape') return handleScrape(req, res);
682
+ if (mode === 'agent') return handleAgent(req, res);
683
+ if (mode === 'headful') return handleHeadful(req, res);
684
+ return res.status(400).json({ error: 'UNSUPPORTED_MODE' });
685
+ });
686
+
687
+ // --- ROUTES ---
688
+ // Login page
689
+ app.get('/login', (req, res) => {
690
+ // Check if already logged in
691
+ if (req.session.user) {
692
+ return res.redirect('/');
693
+ }
694
+ // Check if setup is needed
695
+ const users = loadUsers();
696
+ if (users.length === 0) {
697
+ return res.redirect('/signup');
698
+ }
699
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
700
+ });
701
+
702
+ // Signup/setup page
703
+ app.get('/signup', (req, res) => {
704
+ const users = loadUsers();
705
+ if (users.length > 0) {
706
+ return res.redirect('/login');
707
+ }
708
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
709
+ });
710
+
711
+ // Dashboard (home)
712
+ app.get('/', requireAuth, (req, res) => {
713
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
714
+ });
715
+
716
+ // Dashboard alias
717
+ app.get('/dashboard', requireAuth, (req, res) => {
718
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
719
+ });
720
+
721
+ // Task editor - new task
722
+ app.get('/tasks/new', requireAuth, (req, res) => {
723
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
724
+ });
725
+
726
+ // Task editor - existing task
727
+ app.get('/tasks/:id', requireAuth, (req, res) => {
728
+ console.log(`[ROUTE] /tasks/:id matched with id: ${req.params.id}`);
729
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
730
+ });
731
+
732
+ // Settings
733
+ app.get('/settings', requireAuth, (req, res) => {
734
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
735
+ });
736
+
737
+ // Executions (SPA routes)
738
+ app.get('/executions', requireAuth, (req, res) => {
739
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
740
+ });
741
+ app.get('/executions/:id', requireAuth, (req, res) => {
742
+ res.sendFile(path.join(DIST_DIR, 'index.html'));
743
+ });
744
+
745
+ // Execution endpoints
746
+ app.all('/scrape', requireAuth, (req, res) => {
747
+ registerExecution(req, res, { mode: 'scrape' });
748
+ return handleScrape(req, res);
749
+ });
750
+ app.all('/scraper', requireAuth, (req, res) => {
751
+ registerExecution(req, res, { mode: 'scrape' });
752
+ return handleScrape(req, res);
753
+ });
754
+ app.all('/agent', requireAuth, (req, res) => {
755
+ registerExecution(req, res, { mode: 'agent' });
756
+ try {
757
+ const runId = String((req.body && req.body.runId) || req.query.runId || '').trim();
758
+ if (runId) {
759
+ sendExecutionUpdate(runId, { status: 'started' });
760
+ }
761
+ } catch {
762
+ // ignore
763
+ }
764
+ return handleAgent(req, res);
765
+ });
766
+ app.post('/headful', requireAuth, (req, res) => {
767
+ registerExecution(req, res, { mode: 'headful' });
768
+ if (req.body && typeof req.body.url === 'string') {
769
+ const vars = req.body.taskVariables || req.body.variables || {};
770
+ req.body.url = req.body.url.replace(/\{\$(\w+)\}/g, (_match, name) => {
771
+ const value = vars[name];
772
+ if (value === undefined || value === null) return '';
773
+ return String(value);
774
+ });
775
+ }
776
+ return handleHeadful(req, res);
777
+ });
778
+ app.post('/headful/stop', requireAuth, stopHeadful);
779
+
780
+ // Ensure public/screenshots directory exists
781
+ const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
782
+ if (!fs.existsSync(screenshotsDir)) {
783
+ fs.mkdirSync(screenshotsDir, { recursive: true });
784
+ }
785
+
786
+ const novncDirCandidates = [
787
+ '/opt/novnc',
788
+ '/usr/share/novnc'
789
+ ];
790
+ const novncDir = novncDirCandidates.find((candidate) => {
791
+ try {
792
+ return fs.existsSync(candidate);
793
+ } catch {
794
+ return false;
795
+ }
796
+ });
797
+ const novncEnabled = !!novncDir;
798
+ if (novncDir) {
799
+ app.use('/novnc', express.static(novncDir));
800
+ }
801
+
802
+ app.use('/screenshots', express.static(screenshotsDir));
803
+ app.use(express.static(DIST_DIR));
804
+
805
+ app.get('/api/headful/status', (req, res) => {
806
+ const useNovnc = !!process.env.DISPLAY && novncEnabled;
807
+ res.json({ useNovnc });
808
+ });
809
+
810
+ const tryBind = (host, port) => new Promise((resolve, reject) => {
811
+ const tester = net.createServer();
812
+ tester.unref();
813
+ tester.once('error', (err) => {
814
+ tester.close(() => reject(err));
815
+ });
816
+ tester.once('listening', () => {
817
+ tester.close(() => resolve(true));
818
+ });
819
+ tester.listen({ port, host });
820
+ });
821
+
822
+ const isPortAvailable = async (port) => {
823
+ try {
824
+ await tryBind('127.0.0.1', port);
825
+ } catch (err) {
826
+ if (err && err.code === 'EADDRINUSE') return false;
827
+ throw err;
828
+ }
829
+ try {
830
+ await tryBind('::1', port);
831
+ } catch (err) {
832
+ if (err && err.code === 'EADDRINUSE') return false;
833
+ if (err && (err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')) return true;
834
+ throw err;
835
+ }
836
+ return true;
837
+ };
838
+
839
+ const findAvailablePort = (startPort, maxAttempts = 20) => new Promise((resolve, reject) => {
840
+ let currentPort = startPort;
841
+ const tryPort = async () => {
842
+ try {
843
+ const available = await isPortAvailable(currentPort);
844
+ if (available) return resolve(currentPort);
845
+ } catch (err) {
846
+ return reject(err);
847
+ }
848
+ if (currentPort < startPort + maxAttempts) {
849
+ currentPort += 1;
850
+ return tryPort();
851
+ }
852
+ return reject(new Error('No available port found'));
853
+ };
854
+ tryPort();
855
+ });
856
+
857
+ findAvailablePort(port, 20)
858
+ .then((availablePort) => {
859
+ if (availablePort !== port) {
860
+ console.log(`Port ${port} in use, switched to ${availablePort}.`);
861
+ }
862
+ const server = app.listen(availablePort, '0.0.0.0', () => {
863
+ const address = server.address();
864
+ const displayPort = typeof address === 'object' && address ? address.port : availablePort;
865
+ console.log(`Server running at http://localhost:${displayPort}`);
866
+ });
867
+ server.on('error', (err) => {
868
+ console.error('Server failed to start:', err.message || err);
869
+ process.exit(1);
870
+ });
871
+ })
872
+ .catch((err) => {
873
+ console.error('Server failed to start:', err.message || err);
874
+ process.exit(1);
875
+ });