-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.js
More file actions
1493 lines (1331 loc) · 62.3 KB
/
db.js
File metadata and controls
1493 lines (1331 loc) · 62.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
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
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use strict';
const Database = require('better-sqlite3');
const path = require('path');
const { app } = require('electron');
const { networkPolicy, computeBackoffMs } = require('./network-policy');
const { safeCatch } = require('./sys-log');
let db = null;
let _writeQueueHigh = [];
let _writeQueueLow = [];
let _writeQueueProcessing = false;
let _writeQueueDroppedLow = 0;
let _writeQueueDroppedHigh = 0;
let _writeQueueBusyRetries = 0;
/** Fair scheduling: after 3 high-priority tasks, take one low if available (M5). */
let _fairHighStreak = 0;
function isBusyError(e) {
const msg = String(e?.message || e || '');
return e?.code === 'SQLITE_BUSY' || msg.includes('SQLITE_BUSY') || msg.includes('database is locked');
}
function _busyDelayMs(attempt) {
const jitter = computeBackoffMs(attempt);
return Math.min(
networkPolicy.db.busyMaxDelayMs,
networkPolicy.db.busyBaseDelayMs + jitter
);
}
function _scheduleWriteProcessing() {
if (_writeQueueProcessing) return;
_writeQueueProcessing = true;
setImmediate(_processWriteQueue);
}
function enqueueWrite(task, priority = 'high') {
return new Promise((resolve, reject) => {
const q = priority === 'low' ? _writeQueueLow : _writeQueueHigh;
const limit = priority === 'low'
? networkPolicy.db.writeQueueMaxLow
: networkPolicy.db.writeQueueMaxHigh;
if (q.length >= limit) {
if (priority === 'low') {
_writeQueueDroppedLow++;
} else {
_writeQueueDroppedHigh++;
}
const overflowErr = new Error(`DB write queue overflow (${priority})`);
safeCatch({
module: 'db',
eventCode: 'db.queue.overflow',
context: { priority, limit, queueDepth: q.length },
}, overflowErr, 'warn');
reject(overflowErr);
return;
}
q.push({
fn: task,
resolve,
reject,
attempt: 0,
priority,
});
_scheduleWriteProcessing();
});
}
function _takeNextWriteTask() {
if (_writeQueueLow.length > 0 && _fairHighStreak >= 3) {
_fairHighStreak = 0;
return _writeQueueLow.shift();
}
if (_writeQueueHigh.length > 0) {
_fairHighStreak++;
return _writeQueueHigh.shift();
}
_fairHighStreak = 0;
if (_writeQueueLow.length > 0) return _writeQueueLow.shift();
return null;
}
function _requeueWriteTask(task, delayMs) {
setTimeout(() => {
const q = task.priority === 'low' ? _writeQueueLow : _writeQueueHigh;
q.unshift(task);
_scheduleWriteProcessing();
}, delayMs);
}
function _processWriteQueue() {
const task = _takeNextWriteTask();
if (!task) {
_writeQueueProcessing = false;
return;
}
try {
const result = task.fn();
task.resolve(result);
setImmediate(_processWriteQueue);
} catch (e) {
if (isBusyError(e) && task.attempt < networkPolicy.db.busyRetries) {
task.attempt++;
_writeQueueBusyRetries++;
const delay = _busyDelayMs(task.attempt);
_requeueWriteTask(task, delay);
} else {
safeCatch({
module: 'db',
eventCode: 'db.write.failed',
context: { priority: task.priority, attempt: task.attempt },
}, e, 'warn');
task.reject(e);
setImmediate(_processWriteQueue);
}
}
}
function getDbPath() {
return path.join(app.getPath('userData'), 'cupnet.db');
}
/**
* Initialize with an explicit file path.
* Used by unit tests to avoid requiring Electron's app.getPath.
*/
function initWithPath(dbPath) {
if (db) return db;
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('synchronous = NORMAL');
createSchema();
migrateSchema();
_prepareStmts();
return db;
}
function init() {
return initWithPath(getDbPath());
}
function migrateSchema() {
// Add new columns to proxy_profiles if they don't exist (for existing DBs)
const cols = db.pragma('table_info(proxy_profiles)').map(c => c.name);
if (!cols.includes('is_template')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN is_template INTEGER NOT NULL DEFAULT 0`);
if (!cols.includes('variables')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN variables TEXT`);
if (!cols.includes('notes')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN notes TEXT`);
if (!cols.includes('last_ip')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN last_ip TEXT`);
if (!cols.includes('last_geo')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN last_geo TEXT`);
if (!cols.includes('sort_order')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`);
// Fingerprint / Identity fields
if (!cols.includes('user_agent')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN user_agent TEXT`);
if (!cols.includes('timezone')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN timezone TEXT`);
if (!cols.includes('language')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN language TEXT`);
// TLS fingerprint fields
if (!cols.includes('tls_profile')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN tls_profile TEXT DEFAULT 'chrome'`);
if (!cols.includes('tls_ja3_mode')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN tls_ja3_mode TEXT DEFAULT 'template'`);
if (!cols.includes('tls_ja3_custom')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN tls_ja3_custom TEXT`);
if (!cols.includes('traffic_mode')) db.exec(`ALTER TABLE proxy_profiles ADD COLUMN traffic_mode TEXT NOT NULL DEFAULT 'mitm'`);
db.exec(`UPDATE proxy_profiles SET traffic_mode='mitm' WHERE traffic_mode IS NULL OR traffic_mode='' OR traffic_mode='browser_proxy'`);
// Screenshots: add BLOB column for binary storage (33% smaller than base64 TEXT)
const ssCols = db.pragma('table_info(screenshots)').map(c => c.name);
if (!ssCols.includes('data_blob')) db.exec(`ALTER TABLE screenshots ADD COLUMN data_blob BLOB`);
if (!ssCols.includes('screenshot_meta')) db.exec(`ALTER TABLE screenshots ADD COLUMN screenshot_meta TEXT`);
// External proxy ports: track session source
const sessCols = db.pragma('table_info(sessions)').map(c => c.name);
if (!sessCols.includes('source')) db.exec(`ALTER TABLE sessions ADD COLUMN source TEXT DEFAULT 'browser'`);
if (!sessCols.includes('ext_port')) db.exec(`ALTER TABLE sessions ADD COLUMN ext_port INTEGER`);
// Requests: manual tags/notes + cached host
const reqCols = db.pragma('table_info(requests)').map(c => c.name);
if (!reqCols.includes('host')) db.exec(`ALTER TABLE requests ADD COLUMN host TEXT`);
if (!reqCols.includes('tag')) db.exec(`ALTER TABLE requests ADD COLUMN tag TEXT`);
if (!reqCols.includes('note')) db.exec(`ALTER TABLE requests ADD COLUMN note TEXT`);
if (!reqCols.includes('has_note')) db.exec(`ALTER TABLE requests ADD COLUMN has_note INTEGER NOT NULL DEFAULT 0`);
if (!reqCols.includes('ws_message_count')) db.exec(`ALTER TABLE requests ADD COLUMN ws_message_count INTEGER NOT NULL DEFAULT 0`);
const dnsCols = db.pragma('table_info(dns_overrides)').map(c => c.name);
if (!dnsCols.includes('mitm_inject_cors')) {
db.exec(`ALTER TABLE dns_overrides ADD COLUMN mitm_inject_cors INTEGER NOT NULL DEFAULT 0`);
}
if (!dnsCols.includes('rewrite_host')) {
db.exec(`ALTER TABLE dns_overrides ADD COLUMN rewrite_host TEXT`);
}
// ws_events.connection_id: must run AFTER createSchema; index only if column exists (new DB or post-ALTER).
const wsCols = db.pragma('table_info(ws_events)').map(c => c.name);
if (wsCols.length && !wsCols.includes('connection_id')) {
db.exec(`ALTER TABLE ws_events ADD COLUMN connection_id TEXT`);
}
if (db.pragma('table_info(ws_events)').map(c => c.name).includes('connection_id')) {
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_ws_events_conn ON ws_events(session_id, tab_id, url, connection_id)`);
} catch { /* ignore */ }
}
// intercept_rules: SQLite не меняет CHECK на ALTER — пересоздаём таблицу, если нет типа script
const irMaster = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='intercept_rules'`).get();
if (irMaster?.sql && !irMaster.sql.includes("'script'")) {
db.exec(`
BEGIN IMMEDIATE;
CREATE TABLE intercept_rules__cupnet_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
url_pattern TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('block','modifyHeaders','mock','script')),
params TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO intercept_rules__cupnet_new (id, name, enabled, url_pattern, type, params, created_at)
SELECT id, name, enabled, url_pattern, type, params, created_at FROM intercept_rules;
DROP TABLE intercept_rules;
ALTER TABLE intercept_rules__cupnet_new RENAME TO intercept_rules;
COMMIT;
`);
}
// cookie_groups: ensure table exists for DBs created before this feature
const hasCookieGroups = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='cookie_groups'`).get();
if (!hasCookieGroups) {
db.exec(`
CREATE TABLE cookie_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO cookie_groups (id, name) VALUES (1, 'Default');
`);
}
const noteCols = db.pragma('table_info(user_notes)').map(c => c.name);
if (noteCols.length && !noteCols.includes('url_match')) {
db.exec(`ALTER TABLE user_notes ADD COLUMN url_match TEXT NOT NULL DEFAULT ''`);
db.exec(`UPDATE user_notes SET url_match = domain WHERE url_match = '' OR url_match IS NULL`);
}
}
function createSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
ended_at TEXT,
proxy_info TEXT,
tab_id TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
tab_id TEXT,
request_id TEXT,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
status INTEGER,
type TEXT,
duration_ms INTEGER,
request_headers TEXT,
response_headers TEXT,
request_body TEXT,
response_body TEXT,
error TEXT,
host TEXT,
tag TEXT,
note TEXT,
has_note INTEGER NOT NULL DEFAULT 0,
ws_message_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);
CREATE INDEX IF NOT EXISTS idx_requests_tab ON requests(tab_id);
CREATE INDEX IF NOT EXISTS idx_requests_url ON requests(url);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_created ON requests(created_at);
CREATE INDEX IF NOT EXISTS idx_requests_duration ON requests(duration_ms);
CREATE TABLE IF NOT EXISTS ws_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
tab_id TEXT,
url TEXT NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('send','recv')),
payload TEXT,
connection_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_ws_events_session ON ws_events(session_id);
CREATE INDEX IF NOT EXISTS idx_ws_events_tab ON ws_events(tab_id);
CREATE TABLE IF NOT EXISTS screenshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
tab_id TEXT,
url TEXT,
data_b64 TEXT,
screenshot_meta TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_screenshots_session ON screenshots(session_id);
CREATE TABLE IF NOT EXISTS proxy_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url_encrypted BLOB,
url_display TEXT,
country TEXT,
is_template INTEGER NOT NULL DEFAULT 0,
variables TEXT,
notes TEXT,
last_tested_at TEXT,
last_latency_ms INTEGER,
last_ip TEXT,
last_geo TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
traffic_mode TEXT NOT NULL DEFAULT 'mitm',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS cookie_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT OR IGNORE INTO cookie_groups (id, name) VALUES (1, 'Default');
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
conditions TEXT NOT NULL,
actions TEXT NOT NULL,
hit_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS intercept_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
url_pattern TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('block','modifyHeaders','mock','script')),
params TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS dns_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
ip TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dns_overrides_host ON dns_overrides(host);
CREATE TABLE IF NOT EXISTS trace_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
url TEXT NOT NULL,
request_headers TEXT,
request_body TEXT,
status INTEGER,
response_headers TEXT,
response_body TEXT,
duration_ms INTEGER,
tab_id TEXT,
session_id INTEGER,
browser TEXT,
proxy TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_trace_created ON trace_entries(created_at);
CREATE INDEX IF NOT EXISTS idx_trace_session ON trace_entries(session_id);
CREATE TABLE IF NOT EXISTS user_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
url_match TEXT NOT NULL DEFAULT '',
page_url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
body_plain TEXT,
body_encrypted BLOB,
is_encrypted INTEGER NOT NULL DEFAULT 0 CHECK(is_encrypted IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_user_notes_domain ON user_notes(domain);
CREATE INDEX IF NOT EXISTS idx_user_notes_created ON user_notes(created_at);
CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5(
url,
response_body,
request_id UNINDEXED,
content='requests',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS requests_fts_insert AFTER INSERT ON requests BEGIN
INSERT INTO requests_fts(rowid, url, response_body, request_id)
VALUES (new.id, new.url, COALESCE(new.response_body,''), new.request_id);
END;
CREATE TRIGGER IF NOT EXISTS requests_fts_delete AFTER DELETE ON requests BEGIN
INSERT INTO requests_fts(requests_fts, rowid, url, response_body, request_id)
VALUES ('delete', old.id, old.url, COALESCE(old.response_body,''), old.request_id);
END;
CREATE TRIGGER IF NOT EXISTS requests_fts_update AFTER UPDATE ON requests BEGIN
INSERT INTO requests_fts(requests_fts, rowid, url, response_body, request_id)
VALUES ('delete', old.id, old.url, COALESCE(old.response_body,''), old.request_id);
INSERT INTO requests_fts(rowid, url, response_body, request_id)
VALUES (new.id, new.url, COALESCE(new.response_body,''), new.request_id);
END;
`);
}
// ─── Cached prepared statements (initialized after db.init()) ────────────────
let _stmtCreateSession = null;
let _stmtCreateExtSession = null;
let _stmtInsertRequest = null;
let _stmtInsertWsEvent = null;
let _stmtInsertSS = null;
let _stmtInsertTrace = null;
let _stmtEndSession = null;
let _stmtCountReqs = null;
let _stmtGetSession = null;
function _prepareStmts() {
_stmtCreateSession = db.prepare(`INSERT INTO sessions (proxy_info, tab_id) VALUES (?, ?) RETURNING *`);
_stmtCreateExtSession = db.prepare(`INSERT INTO sessions (proxy_info, tab_id, source, ext_port) VALUES (?, ?, 'external', ?) RETURNING *`);
_stmtInsertRequest = db.prepare(`
INSERT INTO requests
(session_id, tab_id, request_id, url, method, status, type, duration_ms,
request_headers, response_headers, request_body, response_body, error, host, tag, note, has_note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id`);
_stmtInsertWsEvent = db.prepare(`INSERT INTO ws_events (session_id, tab_id, url, direction, payload, connection_id) VALUES (?,?,?,?,?,?)`);
_stmtInsertSS = db.prepare(`INSERT INTO screenshots (session_id, tab_id, url, data_blob, screenshot_meta) VALUES (?,?,?,?,?) RETURNING id`);
_stmtInsertTrace = db.prepare(`
INSERT INTO trace_entries (ts, method, url, request_headers, request_body, status, response_headers, response_body, duration_ms, tab_id, session_id, browser, proxy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
`);
_stmtEndSession = db.prepare(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`);
_stmtCountReqs = db.prepare(`SELECT COUNT(*) as cnt FROM requests WHERE session_id = ?`);
_stmtGetSession = db.prepare(`SELECT * FROM sessions WHERE id = ?`);
}
// ─── Sessions ────────────────────────────────────────────────────────────────
function createSession(proxyInfo, tabId) {
return _stmtCreateSession.get(proxyInfo || null, tabId || null);
}
function createExternalSession(proxyInfo, tabId, extPort) {
return _stmtCreateExtSession.get(proxyInfo || null, tabId || null, extPort);
}
function endSession(sessionId) {
_stmtEndSession.run(sessionId);
}
function getSessions(limit = 100, offset = 0) {
return db.prepare(`SELECT * FROM sessions ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?`)
.all(limit, offset);
}
function getSessionsWithStats(limit = 200, offset = 0) {
return db.prepare(`
SELECT s.id, s.started_at, s.ended_at, s.proxy_info, s.notes,
s.source, s.ext_port,
COUNT(r.id) AS request_count
FROM sessions s
LEFT JOIN requests r ON r.session_id = s.id
GROUP BY s.id
HAVING COUNT(r.id) > 0
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset);
}
function renameSession(id, name) {
db.prepare(`UPDATE sessions SET notes = ? WHERE id = ?`).run(name || null, id);
}
function getSession(id) {
return _stmtGetSession.get(id);
}
function deleteSession(id) {
// Cascade: remove related requests first, then the session itself
db.prepare(`DELETE FROM requests WHERE session_id = ?`).run(id);
db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
}
function listUnnamedSessionIds(keepSessionId) {
const keep = keepSessionId != null ? Number(keepSessionId) : -1;
return db.prepare(`
SELECT id FROM sessions
WHERE (notes IS NULL OR TRIM(notes) = '')
AND id != ?
`).all(keep).map(r => r.id);
}
/** All sessions with empty/whitespace notes, except keepSessionId (e.g. current LIVE). */
function deleteUnnamedSessions(keepSessionId) {
const ids = listUnnamedSessionIds(keepSessionId);
for (const id of ids) deleteSession(id);
return { deleted: ids.length };
}
function requestRowToInsertEntry(row) {
let reqH = null;
let respH = null;
if (row.request_headers) {
try { reqH = JSON.parse(row.request_headers); } catch { reqH = null; }
}
if (row.response_headers) {
try { respH = JSON.parse(row.response_headers); } catch { respH = null; }
}
return {
requestId: row.request_id,
url: row.url,
method: row.method,
status: row.status,
type: row.type,
duration: row.duration_ms,
requestHeaders: reqH,
responseHeaders: respH,
requestBody: row.request_body,
responseBody: row.response_body,
error: row.error,
tag: row.tag,
note: row.note,
};
}
/** Copy listed request rows (by DB id, ascending = chronological) into a new named session. */
function createSessionFromRequestIds(requestIds, name) {
const ids = [...new Set((requestIds || []).map(Number))]
.filter(n => Number.isFinite(n) && n > 0)
.sort((a, b) => a - b);
if (!ids.length) return null;
const sess = createSession(null, null);
const trimmed = typeof name === 'string' ? name.trim() : '';
if (trimmed) renameSession(sess.id, trimmed);
for (const rid of ids) {
const row = getRequest(rid);
if (!row) continue;
const entry = requestRowToInsertEntry(row);
insertRequest(sess.id, row.tab_id, entry);
}
return getSession(sess.id);
}
function deleteEmptySessions(keepId) {
// Remove sessions with no requests, except the one currently active
db.prepare(`
DELETE FROM sessions
WHERE id NOT IN (SELECT DISTINCT session_id FROM requests WHERE session_id IS NOT NULL)
${keepId ? 'AND id != ?' : ''}
`).run(...(keepId ? [keepId] : []));
}
// ─── Requests ────────────────────────────────────────────────────────────────
const MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB
function _truncBody(body) {
if (!body) return null;
if (typeof body === 'string' && body.length > MAX_BODY_BYTES) return body.slice(0, MAX_BODY_BYTES);
if (Buffer.isBuffer(body) && body.length > MAX_BODY_BYTES) return body.slice(0, MAX_BODY_BYTES);
return body;
}
function _extractHost(url) {
try {
return new URL(String(url || '')).host || null;
} catch {
return null;
}
}
function insertRequest(sessionId, tabId, entry) {
const host = entry.host || _extractHost(entry.url);
const row = _stmtInsertRequest.get(
sessionId,
tabId || null,
entry.requestId || null,
entry.url || '',
entry.method || 'GET',
entry.status || null,
entry.type || null,
entry.duration != null ? Math.round(entry.duration) : null,
entry.requestHeaders ? JSON.stringify(entry.requestHeaders) : null,
entry.responseHeaders ? JSON.stringify(entry.responseHeaders) : null,
_truncBody(entry.requestBody),
_truncBody(entry.responseBody),
entry.error || null,
host,
entry.tag || null,
entry.note || null,
entry.note ? 1 : 0
);
return row ? row.id : null;
}
function updateRequest(id, fields) {
const allowed = ['status', 'type', 'duration_ms', 'response_headers', 'response_body', 'error', 'host', 'tag', 'note', 'has_note'];
const updates = [];
const values = [];
for (const [k, v] of Object.entries(fields)) {
if (allowed.includes(k)) {
updates.push(`${k} = ?`);
values.push(typeof v === 'object' ? JSON.stringify(v) : v);
}
}
if (!updates.length) return;
values.push(id);
db.prepare(`UPDATE requests SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
function setRequestAnnotation(id, { tag, note }) {
const normalizedNote = typeof note === 'string' ? note.trim() : '';
db.prepare(`UPDATE requests SET tag = ?, note = ?, has_note = ? WHERE id = ?`)
.run(tag || null, normalizedNote || null, normalizedNote ? 1 : 0, id);
}
function queryRequests(filters = {}, limit = 100, offset = 0) {
const conditions = ['1=1'];
const params = [];
if (filters.sessionId) { conditions.push('session_id = ?'); params.push(filters.sessionId); }
if (filters.tabId) { conditions.push('tab_id = ?'); params.push(filters.tabId); }
if (filters.method) { conditions.push('method = ?'); params.push(filters.method.toUpperCase()); }
if (filters.status) { conditions.push('status = ?'); params.push(Number(filters.status)); }
if (filters.type) { conditions.push('type = ?'); params.push(filters.type); }
if (filters.url) { conditions.push('url LIKE ?'); params.push(`%${filters.url}%`); }
if (filters.minDuration != null) { conditions.push('duration_ms >= ?'); params.push(filters.minDuration); }
if (filters.maxDuration != null) { conditions.push('duration_ms <= ?'); params.push(filters.maxDuration); }
if (filters.since) { conditions.push('created_at >= ?'); params.push(filters.since); }
const sql = `SELECT id, session_id, tab_id, request_id, url, method, status, type,
duration_ms, error, response_headers, host, tag, has_note, note, created_at, ws_message_count,
CASE WHEN lower(COALESCE(type, '')) = 'cupnet'
AND url IN ('cupnet://session/proxy', 'cupnet://session/direct')
THEN response_body ELSE NULL END AS response_body
FROM requests
WHERE ${conditions.join(' AND ')}
ORDER BY id DESC
LIMIT ? OFFSET ?`;
return db.prepare(sql).all(...params, limit, offset);
}
function countRequests(filters = {}) {
const conditions = ['1=1'];
const params = [];
if (filters.sessionId) { conditions.push('session_id = ?'); params.push(filters.sessionId); }
if (filters.tabId) { conditions.push('tab_id = ?'); params.push(filters.tabId); }
if (filters.url) { conditions.push('url LIKE ?'); params.push(`%${filters.url}%`); }
if (filters.status) { conditions.push('status = ?'); params.push(Number(filters.status)); }
return db.prepare(`SELECT COUNT(*) as cnt FROM requests WHERE ${conditions.join(' AND ')}`)
.get(...params).cnt;
}
function getRequest(id) {
return db.prepare(`SELECT * FROM requests WHERE id = ?`).get(id);
}
function queryRequestsFull(filters = {}, limit = 100, offset = 0) {
const conditions = ['1=1'];
const params = [];
if (filters.sessionId) { conditions.push('session_id = ?'); params.push(filters.sessionId); }
if (filters.tabId) { conditions.push('tab_id = ?'); params.push(filters.tabId); }
if (filters.method) { conditions.push('method = ?'); params.push(filters.method.toUpperCase()); }
if (filters.status) { conditions.push('status = ?'); params.push(Number(filters.status)); }
if (filters.type) { conditions.push('type = ?'); params.push(filters.type); }
if (filters.url) { conditions.push('url LIKE ?'); params.push(`%${filters.url}%`); }
const sql = `SELECT * FROM requests WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT ? OFFSET ?`;
return db.prepare(sql).all(...params, limit, offset);
}
// ─── Trace entries ────────────────────────────────────────────────────────────
function insertTraceEntry(entry) {
const row = _stmtInsertTrace.get(
entry.ts || new Date().toISOString(),
entry.method || 'GET',
entry.url || '',
entry.requestHeaders ? JSON.stringify(entry.requestHeaders) : null,
entry.requestBody || null,
entry.status ?? null,
entry.responseHeaders ? JSON.stringify(entry.responseHeaders) : null,
entry.responseBody != null ? String(entry.responseBody).slice(0, 50000) : null,
entry.duration != null ? Math.round(entry.duration) : null,
entry.tabId || null,
entry.sessionId ?? null,
entry.browser || null,
entry.proxy || null
);
return row ? row.id : null;
}
function insertTraceEntryQueued(entry) {
if (!networkPolicy.featureFlags.dbTraceQueue) {
return Promise.resolve(insertTraceEntry(entry));
}
return enqueueWrite(() => insertTraceEntry(entry), 'low').catch(() => null);
}
function queryTraceEntries(limit = 200, offset = 0) {
return db.prepare(`
SELECT id, ts, method, url, status, duration_ms, tab_id, session_id, browser, created_at
FROM trace_entries ORDER BY id DESC LIMIT ? OFFSET ?
`).all(limit, offset);
}
function getTraceEntriesBySession(sessionId, limit = 2000, offset = 0) {
return db.prepare(`
SELECT *
FROM trace_entries
WHERE session_id = ?
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(sessionId, limit, offset);
}
function getTraceEntry(id) {
return db.prepare(`SELECT * FROM trace_entries WHERE id = ?`).get(id);
}
function countTraceEntries() {
return db.prepare(`SELECT COUNT(*) as cnt FROM trace_entries`).get().cnt;
}
function clearTraceEntries() {
db.prepare(`DELETE FROM trace_entries`).run();
}
function ftsSearch(query, sessionId, limit = 100, offset = 0) {
const ftsQuery = query.split(/\s+/).map(t => `"${t.replace(/"/g, '')}"`).join(' OR ');
const sql = sessionId
? `SELECT r.id, r.session_id, r.tab_id, r.url, r.method, r.status, r.type, r.duration_ms, r.response_headers,
r.host, r.tag, r.has_note, r.note, r.created_at
FROM requests_fts fts JOIN requests r ON fts.rowid = r.id
WHERE requests_fts MATCH ? AND r.session_id = ?
ORDER BY r.id DESC LIMIT ? OFFSET ?`
: `SELECT r.id, r.session_id, r.tab_id, r.url, r.method, r.status, r.type, r.duration_ms, r.response_headers,
r.host, r.tag, r.has_note, r.note, r.created_at
FROM requests_fts fts JOIN requests r ON fts.rowid = r.id
WHERE requests_fts MATCH ?
ORDER BY r.id DESC LIMIT ? OFFSET ?`;
try {
return sessionId
? db.prepare(sql).all(ftsQuery, sessionId, limit, offset)
: db.prepare(sql).all(ftsQuery, limit, offset);
} catch {
return [];
}
}
// ─── WebSocket events ────────────────────────────────────────────────────────
function bumpWsHandshakeMessageCount(sessionId, connectionId) {
if (!connectionId) return null;
const sid = parseInt(String(sessionId), 10);
if (!sid) return null;
const rid = String(connectionId);
db.prepare(`
UPDATE requests SET ws_message_count = COALESCE(ws_message_count, 0) + 1
WHERE session_id = ? AND request_id = ? AND LOWER(COALESCE(type, '')) = 'websocket'
`).run(sid, rid);
const row = db.prepare(`
SELECT id, ws_message_count FROM requests
WHERE session_id = ? AND request_id = ? AND LOWER(COALESCE(type, '')) = 'websocket'
LIMIT 1
`).get(sid, rid);
return row ? { handshakeDbId: row.id, ws_message_count: row.ws_message_count } : null;
}
function insertWsEvent(sessionId, tabId, url, direction, payload, connectionId = null) {
_stmtInsertWsEvent.run(sessionId, tabId || null, url, direction, payload || null, connectionId || null);
return bumpWsHandshakeMessageCount(sessionId, connectionId);
}
/**
* MITM handshake логирует как https://host/path; CDP кладёт wss://host/path — совпадаем по обоим.
*/
function _wsUrlVariants(url) {
const s = String(url || '').trim();
if (!s) return [];
const v = new Set([s]);
try {
const u = new URL(s);
const host = u.hostname + (u.port ? `:${u.port}` : '');
const rest = u.pathname + (u.search || '');
if (u.protocol === 'https:' || u.protocol === 'wss:') {
v.add(`wss://${host}${rest}`);
v.add(`https://${host}${rest}`);
}
if (u.protocol === 'http:' || u.protocol === 'ws:') {
v.add(`ws://${host}${rest}`);
v.add(`http://${host}${rest}`);
}
} catch { /* ignore */ }
return [...v];
}
/** Hard ceiling for WS frame queries (export / log viewer pass explicit limit). */
const WS_EVENTS_QUERY_MAX = 500_000_000;
/** WebSocket frames for Log Viewer Messages tab (chronological). */
function queryWsEvents(sessionId, tabId, url, connectionId = null, limit = 10000) {
const sid = parseInt(String(sessionId), 10);
if (!sid || !url) return [];
const lim = Math.min(Math.max(1, Number(limit) || 10000), WS_EVENTS_QUERY_MAX);
const tid = tabId != null ? String(tabId) : null;
const variants = _wsUrlVariants(url);
const inList = variants.map(() => '?').join(', ');
if (connectionId) {
return db.prepare(`
SELECT id, direction, payload, connection_id, created_at
FROM ws_events
WHERE session_id = ?
AND url IN (${inList})
AND COALESCE(tab_id, '') = COALESCE(?, '')
AND COALESCE(connection_id, '') = COALESCE(?, '')
ORDER BY id ASC
LIMIT ?
`).all(sid, ...variants, tid, String(connectionId), lim);
}
return db.prepare(`
SELECT id, direction, payload, connection_id, created_at
FROM ws_events
WHERE session_id = ?
AND url IN (${inList})
AND COALESCE(tab_id, '') = COALESCE(?, '')
ORDER BY id ASC
LIMIT ?
`).all(sid, ...variants, tid, lim);
}
/** Все WebSocket-фреймы сессии (экспорт HAR / bundle). */
function queryWsEventsBySession(sessionId, limit = 50000) {
const sid = parseInt(String(sessionId), 10);
if (!sid) return [];
const lim = Math.min(Math.max(1, Number(limit) || 50000), WS_EVENTS_QUERY_MAX);
return db.prepare(`
SELECT id, session_id, tab_id, url, direction, payload, connection_id, created_at
FROM ws_events
WHERE session_id = ?
ORDER BY id ASC
LIMIT ?
`).all(sid, lim);
}
// ─── Screenshots ─────────────────────────────────────────────────────────────
function insertScreenshot(sessionId, tabId, url, dataB64, screenshotMeta = null) {
if (!dataB64 || typeof dataB64 !== 'string') return null;
const buf = Buffer.from(dataB64, 'base64');
const metaJson = (screenshotMeta && typeof screenshotMeta === 'object')
? JSON.stringify(screenshotMeta)
: null;
const row = _stmtInsertSS.get(sessionId, tabId || null, url || null, buf, metaJson);
return row ? row.id : null;
}
function getScreenshotsForSession(sessionId) {
return db.prepare(`SELECT id, tab_id, url, created_at FROM screenshots WHERE session_id = ?`)
.all(sessionId);
}
/** Returns screenshot metadata (NO image data) formatted as log entries. Image data is lazy-loaded on demand. */
function getScreenshotEntriesForSession(sessionId) {
return db.prepare(`SELECT id, session_id, tab_id, url, screenshot_meta, created_at FROM screenshots WHERE session_id = ? ORDER BY id ASC`)
.all(sessionId)
.map(row => ({
id: 'ss-' + row.id,
ssDbId: row.id, // numeric DB id for lazy image fetch
type: 'screenshot',
url: row.url || '',
screenshotMeta: (() => {
try { return row.screenshot_meta ? JSON.parse(row.screenshot_meta) : null; }
catch { return null; }
})(),
tabId: row.tab_id,
tab_id: row.tab_id,
session_id: row.session_id,
created_at: row.created_at,
// imageData intentionally omitted — fetched on demand via getScreenshotData()
}));
}
function getScreenshotData(id) {
const row = db.prepare(`SELECT data_blob, data_b64 FROM screenshots WHERE id = ?`).get(id);
if (!row) return null;
if (row.data_blob) return Buffer.from(row.data_blob).toString('base64');
return row.data_b64 || null;
}
// ─── Proxy profiles ──────────────────────────────────────────────────────────
function normalizeTrafficMode(_mode) {
return 'mitm';
}
function getProxyProfiles() {
return db.prepare(`
SELECT id, name, url_display, country, is_template, variables, notes,
last_tested_at, last_latency_ms, last_ip, last_geo, sort_order,
user_agent, timezone, language,
tls_profile, tls_ja3_mode, tls_ja3_custom, traffic_mode,
created_at
FROM proxy_profiles ORDER BY sort_order ASC, name ASC LIMIT 1000
`).all().map(r => ({
...r,
traffic_mode: normalizeTrafficMode(r.traffic_mode),
variables: r.variables ? JSON.parse(r.variables) : {}
}));
}
function saveProxyProfile(name, urlEncrypted, urlDisplay, opts = {}) {
const existing = db.prepare(`SELECT id FROM proxy_profiles WHERE name = ?`).get(name);
const vars = opts.variables ? JSON.stringify(opts.variables) : null;
if (existing) {
db.prepare(`UPDATE proxy_profiles
SET url_encrypted=?, url_display=?, country=?,
is_template=?, variables=?, notes=?, sort_order=?,
user_agent=?, timezone=?, language=?,
tls_profile=?, tls_ja3_mode=?, tls_ja3_custom=?,
traffic_mode=?
WHERE id=?`)
.run(urlEncrypted, urlDisplay, opts.country || null,
opts.isTemplate ? 1 : 0, vars, opts.notes || null,
opts.sortOrder ?? 0, opts.user_agent || null, opts.timezone || null,
opts.language || null,
opts.tls_profile || 'chrome', opts.tls_ja3_mode || 'template', opts.tls_ja3_custom || null,
normalizeTrafficMode(opts.traffic_mode),
existing.id);
return existing.id;
}
const row = db.prepare(`
INSERT INTO proxy_profiles (name, url_encrypted, url_display, country, is_template, variables, notes, sort_order,
user_agent, timezone, language, tls_profile, tls_ja3_mode, tls_ja3_custom, traffic_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id
`).get(name, urlEncrypted, urlDisplay, opts.country || null,
opts.isTemplate ? 1 : 0, vars, opts.notes || null, opts.sortOrder ?? 0,
opts.user_agent || null, opts.timezone || null, opts.language || null,
opts.tls_profile || 'chrome', opts.tls_ja3_mode || 'template', opts.tls_ja3_custom || null,
normalizeTrafficMode(opts.traffic_mode));
return row ? row.id : null;
}
function updateProxyProfileById(id, fields) {
const allowed = ['name','url_encrypted','url_display','country','is_template','variables','notes','sort_order',
'user_agent','timezone','language','tls_profile','tls_ja3_mode','tls_ja3_custom','traffic_mode'];
const sets = [], vals = [];
for (const [k, v] of Object.entries(fields)) {
if (!allowed.includes(k)) continue;
sets.push(`${k}=?`);
if (k === 'variables' && typeof v === 'object') vals.push(JSON.stringify(v));
else if (k === 'traffic_mode') vals.push(normalizeTrafficMode(v));
else vals.push(v);
}
if (!sets.length) return;
vals.push(id);
db.prepare(`UPDATE proxy_profiles SET ${sets.join(',')} WHERE id=?`).run(...vals);
}
function getProxyProfileEncrypted(id) {
const row = db.prepare(`SELECT name, url_encrypted, variables, user_agent, timezone, language,
tls_profile, tls_ja3_mode, tls_ja3_custom, traffic_mode
FROM proxy_profiles WHERE id = ?`).get(id);
if (!row) return null;
row.traffic_mode = normalizeTrafficMode(row.traffic_mode);
return row;
}
function updateProxyProfileTest(id, latencyMs, ip, geo) {
db.prepare(`UPDATE proxy_profiles
SET last_tested_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'),
last_latency_ms=?, last_ip=?, last_geo=?
WHERE id=?`)
.run(latencyMs, ip || null, geo || null, id);
}
function updateProxyProfileGeo(id, ip, geo) {
db.prepare(`UPDATE proxy_profiles SET last_ip=?, last_geo=? WHERE id=?`).run(ip||null, geo||null, id);
}
function deleteProxyProfile(id) {
db.prepare(`DELETE FROM proxy_profiles WHERE id = ?`).run(id);
}
// ─── Rules ───────────────────────────────────────────────────────────────────
function getRules() {
return db.prepare(`SELECT * FROM rules ORDER BY id LIMIT 1000`).all().map(parseJsonFields('conditions', 'actions'));
}