Skip to content

Commit 72947a6

Browse files
authored
Merge pull request #2 from phreakocious/viewer
Major extension overhaul.. web viewer with live streaming from the extension.. Python graph builder
2 parents 07fe5bd + e1c250a commit 72947a6

File tree

18 files changed

+3949
-18
lines changed

18 files changed

+3949
-18
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master, manifest_v3]
6+
pull_request:
7+
branches: [master, manifest_v3]
8+
9+
jobs:
10+
check:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-node@v4
15+
with:
16+
node-version: 22
17+
cache: npm
18+
- run: npm ci
19+
- run: npm audit
20+
- run: npm run lint
21+
- run: npm test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
*.pem
55
*.sublime-*
66
*.json
7+
node_modules/
8+
.venv/

dist/httpgraph.js

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@
99

1010
const default_rest_port = "65444";
1111
const default_scrub_parameters = false;
12+
const default_collecting = true;
13+
const default_domain_include = "";
14+
const default_domain_exclude = "";
15+
16+
// Request timing map — stores start times keyed by requestId
17+
const requestTimings = new Map();
18+
19+
// Initiator map — stores the origin that triggered each request
20+
const requestInitiators = new Map();
21+
22+
// Connected viewer ports for live streaming
23+
const viewerPorts = new Set();
24+
25+
function broadcastToViewers(data) {
26+
for (const port of viewerPorts) {
27+
try {
28+
port.postMessage(data);
29+
} catch (e) {
30+
viewerPorts.delete(port);
31+
}
32+
}
33+
}
1234

1335
// hashing function courtesy of bryc https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
1436
const cyrb53 = (str, seed = 42) => {
@@ -23,22 +45,56 @@ const cyrb53 = (str, seed = 42) => {
2345
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
2446
};
2547

26-
function scrubber(match, p1, offset, string) {
48+
function scrubber(match, p1, _offset, _string) {
2749
return "?SCRUBBED_hash=" + cyrb53(p1);
2850
}
2951

52+
function updateBadge(collecting) {
53+
if (collecting) {
54+
chrome.action.setBadgeText({ text: "" });
55+
} else {
56+
chrome.action.setBadgeText({ text: "OFF" });
57+
chrome.action.setBadgeBackgroundColor({ color: "#cc0000" });
58+
}
59+
}
60+
61+
function domainMatches(hostname, domainList) {
62+
return domainList.some(domain => hostname === domain || hostname.endsWith("." + domain));
63+
}
64+
3065
async function logResponse(details) {
3166
// Get settings from storage every time, as the service worker can be terminated.
3267
const items = await chrome.storage.local.get({
3368
rest_port: default_rest_port,
34-
scrub_parameters: default_scrub_parameters
69+
scrub_parameters: default_scrub_parameters,
70+
collecting: default_collecting,
71+
domain_include: default_domain_include,
72+
domain_exclude: default_domain_exclude
3573
});
3674

75+
// If collection is paused, do nothing
76+
if (!items.collecting) return;
77+
3778
const url_backend = `http://127.0.0.1:${items.rest_port}/add_record`;
3879

3980
// Avoid feedback loop and internal browser requests
4081
if ( details.url.startsWith(url_backend) || details.tabId < 0 ) return;
4182

83+
// Domain filtering
84+
try {
85+
const hostname = new URL(details.url).hostname;
86+
const includeList = items.domain_include.split("\n").map(s => s.trim().toLowerCase()).filter(Boolean);
87+
const excludeList = items.domain_exclude.split("\n").map(s => s.trim().toLowerCase()).filter(Boolean);
88+
89+
if (includeList.length > 0) {
90+
if (!domainMatches(hostname.toLowerCase(), includeList)) return;
91+
} else if (excludeList.length > 0) {
92+
if (domainMatches(hostname.toLowerCase(), excludeList)) return;
93+
}
94+
} catch (_e) {
95+
// If URL parsing fails, proceed anyway
96+
}
97+
4298
const headers = details.responseHeaders;
4399
let data = {
44100
url: details.url,
@@ -49,6 +105,20 @@ async function logResponse(details) {
49105
type: details.type
50106
};
51107

108+
// Compute request duration if we have a start time
109+
const startTime = requestTimings.get(details.requestId);
110+
if (startTime !== undefined) {
111+
data.duration_ms = Math.round(details.timeStamp - startTime);
112+
requestTimings.delete(details.requestId);
113+
}
114+
115+
// Attach initiator origin if captured from onBeforeRequest
116+
const initiator = requestInitiators.get(details.requestId);
117+
if (initiator) {
118+
data.initiator = initiator;
119+
requestInitiators.delete(details.requestId);
120+
}
121+
52122
for (const header of headers) {
53123
const headerName = header.name.toLowerCase();
54124
if (headerName === 'content-length') {
@@ -90,17 +160,115 @@ async function logResponse(details) {
90160
} catch (error) {
91161
console.error("HTTP Graph Error: Could not send data to backend.", error);
92162
}
163+
164+
broadcastToViewers(finalData);
93165
}
94166

167+
const requestFilter = { urls: [ "http://*/*", "https://*/*" ] };
168+
169+
chrome.webRequest.onBeforeRequest.addListener(
170+
(details) => {
171+
requestTimings.set(details.requestId, details.timeStamp);
172+
if (details.initiator && details.initiator !== "null") {
173+
requestInitiators.set(details.requestId, details.initiator);
174+
}
175+
},
176+
requestFilter
177+
);
178+
95179
chrome.webRequest.onCompleted.addListener(
96180
logResponse,
97-
{ urls: [ "http://*/*", "https://*/*" ] },
181+
requestFilter,
98182
[ "responseHeaders" ]
99183
);
100184

185+
chrome.webRequest.onBeforeRedirect.addListener(
186+
async (details) => {
187+
const items = await chrome.storage.local.get({
188+
rest_port: default_rest_port,
189+
collecting: default_collecting,
190+
domain_include: default_domain_include,
191+
domain_exclude: default_domain_exclude
192+
});
193+
194+
if (!items.collecting) return;
195+
196+
const url_backend = `http://127.0.0.1:${items.rest_port}/add_record`;
197+
if (details.url.startsWith(url_backend) || details.tabId < 0) return;
198+
199+
// Domain filtering on the source URL
200+
try {
201+
const hostname = new URL(details.url).hostname;
202+
const includeList = items.domain_include.split("\n").map(s => s.trim().toLowerCase()).filter(Boolean);
203+
const excludeList = items.domain_exclude.split("\n").map(s => s.trim().toLowerCase()).filter(Boolean);
204+
205+
if (includeList.length > 0) {
206+
if (!domainMatches(hostname.toLowerCase(), includeList)) return;
207+
} else if (excludeList.length > 0) {
208+
if (domainMatches(hostname.toLowerCase(), excludeList)) return;
209+
}
210+
} catch (_e) {
211+
// If URL parsing fails, proceed anyway
212+
}
213+
214+
const data = {
215+
edge_type: "redirect",
216+
url: details.url,
217+
redirect_url: details.redirectUrl,
218+
ts: details.timeStamp,
219+
ip: details.ip,
220+
method: details.method,
221+
status: details.statusCode,
222+
type: details.type
223+
};
224+
225+
try {
226+
await fetch(url_backend, {
227+
method: 'POST',
228+
headers: { 'Content-Type': 'application/json' },
229+
body: JSON.stringify(data) + "\r\n"
230+
});
231+
} catch (error) {
232+
console.error("HTTP Graph Error: Could not send redirect data to backend.", error);
233+
}
234+
235+
broadcastToViewers(data);
236+
},
237+
requestFilter,
238+
[ "responseHeaders" ]
239+
);
240+
241+
chrome.webRequest.onErrorOccurred.addListener(
242+
(details) => {
243+
requestTimings.delete(details.requestId);
244+
requestInitiators.delete(details.requestId);
245+
},
246+
requestFilter
247+
);
248+
101249
chrome.runtime.onInstalled.addListener(() => {
102250
chrome.storage.local.set({
103251
rest_port: default_rest_port,
104-
scrub_parameters: default_scrub_parameters
252+
scrub_parameters: default_scrub_parameters,
253+
collecting: default_collecting,
254+
domain_include: default_domain_include,
255+
domain_exclude: default_domain_exclude
256+
});
257+
updateBadge(default_collecting);
258+
});
259+
260+
chrome.runtime.onStartup.addListener(async () => {
261+
const items = await chrome.storage.local.get({ collecting: default_collecting });
262+
updateBadge(items.collecting);
263+
});
264+
265+
// ── Live Viewer Streaming ──
266+
chrome.runtime.onConnectExternal.addListener((port) => {
267+
if (port.name !== "httpgraph-viewer") return;
268+
console.log("HTTP Graph: Viewer connected");
269+
viewerPorts.add(port);
270+
port.onDisconnect.addListener(() => {
271+
viewerPorts.delete(port);
272+
console.log("HTTP Graph: Viewer disconnected");
105273
});
106-
});
274+
});

dist/manifest.json

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"manifest_version": 3,
33
"name": "HTTP Graph Collector",
4-
"version": "0.4",
5-
"description": "Accompanies the HTTP Graph plugin for Gephi. Collects minimal HTTP and HTTPS header data and POSTs to a REST API as you browse.",
4+
"version": "0.6",
5+
"description": "Collects HTTP/HTTPS request metadata as you browse and streams it live to the HTTP Graph Viewer at nullphase.net/hg or POSTs to a localhost REST API. All data stays local to your browser — nothing is sent over the network.",
66
"background":
77
{
88
"service_worker": "httpgraph.js"
@@ -22,19 +22,24 @@
2222
"action":
2323
{
2424
"default_title": "HTTP Graph Collector",
25+
"default_popup": "popup/popup.html",
2526
"default_icon":
2627
{
2728
"48": "httpgraph-48x48.png"
2829
}
2930
},
31+
"externally_connectable":
32+
{
33+
"matches":
34+
[
35+
"https://nullphase.net/*",
36+
"http://localhost/*",
37+
"http://127.0.0.1/*"
38+
]
39+
},
3040
"icons":
3141
{
3242
"48": "httpgraph-48x48.png",
3343
"128": "httpgraph-128x128.png"
34-
},
35-
"options_ui":
36-
{
37-
"page": "options/options.html",
38-
"open_in_tab": false
3944
}
4045
}

dist/options/options.html

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@
2121
</p>
2222
<p>
2323
(JSON data will POST to: http://127.0.0.1:<b><span id=url_port>65444</span></b>/add_record)
24-
<br />
24+
</p>
25+
<hr />
26+
<p>
27+
<label for="domain_include"><b>Only collect from these domains</b> (one per line):</label><br />
28+
<textarea id="domain_include" rows="4" cols="40" placeholder="example.com&#10;another.org"></textarea>
29+
</p>
30+
<p>
31+
<label for="domain_exclude"><b>Never collect from these domains</b> (one per line):</label><br />
32+
<textarea id="domain_exclude" rows="4" cols="40" placeholder="ads.example.com&#10;tracker.net"></textarea>
33+
</p>
34+
<p><small>If the include list is non-empty, only matching domains are collected and the exclude list is ignored.</small></p>
35+
<hr />
36+
<p>
2537
<div id="status">&nbsp;</div>
2638
</p>
2739
<p style="float: left;"><button id="save">Save</button></p>
2840
<p style="float: right;">by <a href="https://twitter.com/phreakocious" target="_blank">@phreakocious</a></p>
2941
<div style="clear: both;"></div>
3042
<script src="options.js" type="text/javascript"></script>
3143
</body>
32-
</html>
44+
</html>

dist/options/options.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,33 @@
11
function save_options() {
22
var rest_port = document.getElementById('rest_port').value
33
var scrub_parameters = document.getElementById('scrub_parameters').checked
4+
var domain_include = document.getElementById('domain_include').value
5+
var domain_exclude = document.getElementById('domain_exclude').value
46
chrome.storage.local.set({
57
rest_port: rest_port,
6-
scrub_parameters: scrub_parameters
8+
scrub_parameters: scrub_parameters,
9+
domain_include: domain_include,
10+
domain_exclude: domain_exclude
711
}, function() {
812
var status = document.getElementById('status')
913
status.textContent = 'Options saved.'
1014
setTimeout(function() {
1115
status.innerHTML = '&nbsp;'
1216
}, 750)
13-
chrome.extension.getBackgroundPage().window.location.reload()
1417
})
1518
}
1619

1720
function restore_options() {
1821
chrome.storage.local.get({
19-
rest_port,
20-
scrub_parameters
22+
rest_port: "65444",
23+
scrub_parameters: false,
24+
domain_include: "",
25+
domain_exclude: ""
2126
}, function(items) {
2227
document.getElementById('rest_port').value = items.rest_port
2328
document.getElementById('scrub_parameters').checked = items.scrub_parameters
29+
document.getElementById('domain_include').value = items.domain_include
30+
document.getElementById('domain_exclude').value = items.domain_exclude
2431
})
2532
}
2633

0 commit comments

Comments
 (0)