Compare commits

..

4 Commits

Author SHA1 Message Date
46277413c3 feat(toolbox): d3 pan + pinch-zoom + auto-fit initial render (ref #507)
- Wrap graph content (links + nodes) in a content <g> so d3.zoom's
    transform applies there without moving the SVG itself.
  - d3.zoom(): scale 0.2–6×, mouse wheel + touch pinch + drag-pan.
    Filter lets node drags pass through d3.drag (no collision with the
    pan gesture) and always accepts multi-touch + wheel.
  - autoFit(): after the simulation alpha drops below 0.1, compute the
    nodes' bounding box and transition to a zoom transform that fits
    all of them with 28 px padding (capped at 2.5× so a single-node
    graph doesn't blow up).  Same logic re-runs on dblclick (reset)
    and on viewport resize (debounced 150 ms).
  - Viewport-resize observer keeps the viewBox and zoom in sync when
    orientation changes.
2026-06-09 12:48:26 +02:00
d87b2ea936 feat(toolbox): full-viewport graph + drop persistent overlay messages (ref #507)
User feedback : graph too small + 'Chargement…' / 'ZÉRO TRACKER DÉTECTÉ'
overlays kept stacking on top of the graph.

Layout reworked :
  - body flex column 100vh overflow hidden — header sticky, main owns
    all remaining vertical space, graph fills it.
  - stats tiles → small glassy pill floating top-left over the graph.
  - cards collapsed into a compact 3-cell horizontal strip at the
    bottom ; open-card popover slides up over the graph so the graph
    stays visible.
  - graph svg measured at render-time, viewBox set dynamically — d3
    force-center scales to actual viewport instead of the fixed
    600×600 box.

Persistent overlays removed :
  - .social-empty and .social-loading divs gone from template.
  - JS no longer references them.  Empty graph is its own indicator
    (stats tiles show 0/0).  Network errors log to console only.

Net effect : tap 'Ma carto' on splash → full-screen force-directed
graph, stats top-left, cards bottom strip, no overlay clutter.
2026-06-09 12:46:00 +02:00
7b0314c21a fix(toolbox): move social-view i18n from data-* attr to <script> (ref #507)
JSON in a single-quoted HTML data-* attribute breaks on FR apostrophes
(l'effacement, L'analyse) — JSON.parse threw 'unterminated string at
line 1 column 475'.  <script>window.__SOCIAL_I18N__ = {…}</script>
keeps the JSON verbatim and is the standard idiom for shipping
server-rendered config to client JS.
2026-06-09 12:38:56 +02:00
bf0b8ff323 fix(toolbox): PYTHONPATH in mitm-wg launcher so addons can import secubox_toolbox (ref #507)
Root cause caught after Phase 11.B live deploy : the mitm-wg workers
run inside /opt/mitmproxy-toolbox/bin/python3 — a venv whose
site-packages does NOT include /usr/lib/secubox/toolbox/. Every addon
that does `from secubox_toolbox import ...` was therefore silently
degraded :

  - inject_banner.py: dpi_class / geo / store / utiq imports all
    failed → no host classification, no GeoIP, no ASN, no Utiq tile.
  - social_graph.py: _social = None → record_edge() no-op → zero
    social_edges rows even though the response hook fired correctly.

Adding `export PYTHONPATH=/usr/lib/secubox/toolbox` to the launcher
restores ALL helper imports for ALL addons.

Verified live on gk2 : after deploy + worker restart, 369 social_edges
recorded in 30s, fold produced cross-site links connecting 4 ad-tech
relay publishers (360yield, seedtag, smartadserver, smilewanted via
the same 35.214.136.108 endpoint).
2026-06-09 12:32:20 +02:00
4 changed files with 189 additions and 87 deletions

View File

@ -5,10 +5,18 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{{ t.page_title }} — VILLAGE3B</title> <title>{{ t.page_title }} — VILLAGE3B</title>
<link rel="stylesheet" href="/toolbox/social.css"> <link rel="stylesheet" href="/toolbox/social.css">
<script>
// Inline JSON safer than a data-* attribute : FR text has
// apostrophes (l'effacement) that would break single-quoted
// HTML attrs. Wrapped in <script> JSON is verbatim so long as
// it doesn't contain </script (json.dumps from Python never
// emits raw "</" in normal output).
window.__SOCIAL_I18N__ = {{ t_json | safe }};
</script>
<script defer src="/toolbox/d3.v7.min.js"></script> <script defer src="/toolbox/d3.v7.min.js"></script>
<script defer src="/toolbox/social.js"></script> <script defer src="/toolbox/social.js"></script>
</head> </head>
<body data-token="{{ token }}" data-lang="{{ lang }}" data-i18n='{{ t_json | safe }}'> <body data-token="{{ token }}" data-lang="{{ lang }}">
<header class="social-header"> <header class="social-header">
<div class="brand"> <div class="brand">
@ -20,21 +28,13 @@
</header> </header>
<main class="social-main"> <main class="social-main">
<p class="social-intro">{{ t.intro }}</p>
<section class="stats" aria-live="polite">
<div class="stat-tile"><span class="stat-n" data-bind="total_trackers">0</span><span class="stat-l">{{ t.stats_total_trackers }}</span></div>
<div class="stat-tile"><span class="stat-n" data-bind="total_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
</section>
<section class="graph-wrap" aria-label="{{ t.header_subtitle }}"> <section class="graph-wrap" aria-label="{{ t.header_subtitle }}">
<section class="stats" aria-live="polite">
<div class="stat-tile"><span class="stat-n" data-bind="total_trackers">0</span><span class="stat-l">{{ t.stats_total_trackers }}</span></div>
<div class="stat-tile"><span class="stat-n" data-bind="total_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
</section>
<p class="graph-hint">{{ t.graph_swipe_hint }}</p> <p class="graph-hint">{{ t.graph_swipe_hint }}</p>
<svg id="social-graph" viewBox="0 0 600 600" role="img"></svg> <svg id="social-graph" role="img" preserveAspectRatio="xMidYMid meet"></svg>
<div id="social-empty" class="social-empty" hidden>
<div class="empty-title">{{ t.empty_title }}</div>
<div class="empty-body">{{ t.empty_body }}</div>
</div>
<div id="social-loading" class="social-loading">{{ t.loading }}</div>
</section> </section>
<aside id="node-detail" class="node-detail" hidden aria-live="polite"> <aside id="node-detail" class="node-detail" hidden aria-live="polite">
@ -56,20 +56,20 @@
</dl> </dl>
</aside> </aside>
<details class="card"> <nav class="cards-row">
<summary>{{ t.card_evidence }}</summary> <details class="card">
<p class="card-pending">{{ t.card_evidence_pending }}</p> <summary>{{ t.card_evidence }}</summary>
</details> <p class="card-pending">{{ t.card_evidence_pending }}</p>
</details>
<details class="card card-wipe"> <details class="card card-wipe">
<summary>{{ t.card_wipe }}</summary> <summary>{{ t.card_wipe }}</summary>
<button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button> <button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button>
</details> </details>
<details class="card">
<details class="card"> <summary>{{ t.card_pdf }}</summary>
<summary>{{ t.card_pdf }}</summary> <p class="card-pending">{{ t.card_pdf_pending }}</p>
<p class="card-pending">{{ t.card_pdf_pending }}</p> </details>
</details> </nav>
</main> </main>
<dialog id="wipe-modal" aria-labelledby="wipe-modal-title"> <dialog id="wipe-modal" aria-labelledby="wipe-modal-title">

View File

@ -18,6 +18,14 @@ BYPASS_FILE=/var/lib/secubox/toolbox/mitm-bypass.conf
DYNAMIC_FILE=/var/lib/secubox/toolbox/mitm-bypass-dynamic.conf # noqa: SC2034 (used by addon hint) DYNAMIC_FILE=/var/lib/secubox/toolbox/mitm-bypass-dynamic.conf # noqa: SC2034 (used by addon hint)
ADDON_DIR=/usr/lib/secubox/toolbox/mitmproxy_addons ADDON_DIR=/usr/lib/secubox/toolbox/mitmproxy_addons
# Phase 11.A (#505) — make the secubox_toolbox package importable from
# addons running inside /opt/mitmproxy-toolbox/bin/python3 (a venv that
# has its own site-packages and never sees /usr/lib/secubox/toolbox).
# Without this PYTHONPATH every addon's `from secubox_toolbox import …`
# silently degrades — inject_banner loses host classification + GeoIP,
# social_graph loses its SQLite store reference, etc.
export PYTHONPATH="/usr/lib/secubox/toolbox${PYTHONPATH:+:$PYTHONPATH}"
# ── Compose ignore_hosts regex : merge static + dynamic bypass lists ── # ── Compose ignore_hosts regex : merge static + dynamic bypass lists ──
# Phase 6.N (#496) : the dynamic file is auto-populated by the cert_pin_detect # Phase 6.N (#496) : the dynamic file is auto-populated by the cert_pin_detect
# addon when it observes repeated TLS handshake failures (cert pinning). # addon when it observes repeated TLS handshake failures (cert pinning).

View File

@ -19,25 +19,33 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--cosmos-black); color: var(--text-primary); } html, body { margin: 0; padding: 0; background: var(--cosmos-black); color: var(--text-primary); }
html, body { height: 100%; }
body { body {
font-family: 'IM Fell English', Georgia, serif; font-family: 'IM Fell English', Georgia, serif;
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
min-height: 100vh; /* Full-viewport flex layout : header (sticky), main (flex 1, owns the
graph), node-detail (slides over). Cards live inside main as a
compact bottom row so the graph stays hero. */
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
/* Header */ /* Header — compact, sticky, doesn't steal vertical space from the graph. */
.social-header { .social-header {
display: flex; align-items: baseline; justify-content: space-between; flex: 0 0 auto;
padding: 14px 18px; display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid var(--gold-hermetic); border-bottom: 1px solid var(--gold-hermetic);
background: linear-gradient(180deg, rgba(201,168,76,.06), transparent); background: linear-gradient(180deg, rgba(201,168,76,.06), transparent);
} }
.brand-title { .brand-title {
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
font-weight: 600; font-weight: 600;
font-size: 22px; font-size: 18px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--gold-hermetic); color: var(--gold-hermetic);
} }
@ -52,37 +60,52 @@ body {
padding: 2px 6px; padding: 2px 6px;
} }
/* Main */ /* Main : flex column, takes all remaining height after the header.
.social-main { padding: 14px 16px 24px; max-width: 720px; margin: 0 auto; } Graph fills the body of main ; stats + cards float over the corners. */
.social-intro { color: var(--text-muted); font-size: 14px; margin: 8px 0 16px; } .social-main {
/* Stats tiles */
.stats { display: flex; gap: 12px; margin-bottom: 18px; }
.stat-tile {
flex: 1; flex: 1;
background: rgba(0, 212, 255, .04); position: relative;
border: 1px solid rgba(0, 212, 255, .2); display: flex;
border-radius: 4px; flex-direction: column;
padding: 10px 12px; min-height: 0; /* allow children to shrink past content size */
display: flex; flex-direction: column; overflow: hidden;
}
/* Phase 11.B v2 intro paragraph hidden in full-viewport mode ; the
graph itself + the hover hint communicate the same thing. */
.social-intro { display: none; }
/* Stats tiles compact horizontal strip floating top-left over the
graph. Glassy background so the graph stays visually dominant. */
.stats {
position: absolute; top: 8px; left: 8px;
display: flex; gap: 6px;
z-index: 5;
pointer-events: none;
}
.stat-tile {
background: rgba(10, 10, 15, .65);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(0, 212, 255, .35);
border-radius: 3px;
padding: 4px 9px;
display: flex; align-items: baseline; gap: 5px;
} }
.stat-n { .stat-n {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 22px; font-size: 16px;
color: var(--cyber-cyan); color: var(--cyber-cyan);
line-height: 1;
} }
.stat-l { font-size: 12px; color: var(--text-muted); } .stat-l { font-size: 10px; color: var(--text-muted); }
/* Graph */ /* Graph fills all remaining vertical space inside .social-main. */
.graph-wrap { .graph-wrap {
position: relative; position: relative;
flex: 1;
min-height: 0;
background: rgba(110, 64, 201, .03); background: rgba(110, 64, 201, .03);
border: 1px solid rgba(110, 64, 201, .25);
border-radius: 4px;
margin-bottom: 16px;
overflow: hidden; overflow: hidden;
aspect-ratio: 1 / 1;
max-height: 70vh;
} }
.graph-hint { .graph-hint {
position: absolute; top: 8px; right: 12px; position: absolute; top: 8px; right: 12px;
@ -91,20 +114,6 @@ body {
pointer-events: none; pointer-events: none;
} }
#social-graph { display: block; width: 100%; height: 100%; touch-action: none; } #social-graph { display: block; width: 100%; height: 100%; touch-action: none; }
.social-loading {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-family: 'JetBrains Mono', monospace;
}
.social-empty {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--matrix-green);
text-align: center;
font-family: 'Cinzel', serif;
}
.empty-title { font-size: 22px; letter-spacing: 0.1em; margin-bottom: 8px; }
.empty-body { font-family: 'IM Fell English', serif; font-style: italic; color: var(--matrix-green); opacity: .8; }
/* d3 node + edge styling — colors come from the data class on each element. */ /* d3 node + edge styling — colors come from the data class on each element. */
.node circle { stroke: var(--cosmos-black); stroke-width: 1.5; } .node circle { stroke: var(--cosmos-black); stroke-width: 1.5; }
@ -154,21 +163,49 @@ body {
.node-detail dt { color: var(--text-muted); font-style: italic; } .node-detail dt { color: var(--text-muted); font-style: italic; }
.node-detail dd { margin: 0; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; font-size: 12px; word-break: break-word; } .node-detail dd { margin: 0; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; font-size: 12px; word-break: break-word; }
/* Cards */ /* Cards row floats at the bottom of the viewport as a 3-cell
horizontal strip ; each <details> opens upward as a popover so the
graph stays visible while you interact with a card. */
.card { .card {
background: rgba(201, 168, 76, .04); background: rgba(201, 168, 76, .04);
border: 1px solid rgba(201, 168, 76, .25); border: 1px solid rgba(201, 168, 76, .25);
border-radius: 4px; border-radius: 4px;
padding: 10px 14px; }
margin-bottom: 10px; .cards-row {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 6px 8px env(safe-area-inset-bottom);
background: rgba(10, 10, 15, .85);
border-top: 1px solid var(--gold-hermetic);
}
.cards-row .card {
margin: 0;
padding: 6px 8px;
font-size: 12px;
} }
.card summary { .card summary {
cursor: pointer; cursor: pointer;
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
font-size: 15px; font-size: 12px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--gold-hermetic); color: var(--gold-hermetic);
list-style: none; list-style: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Open-card popover : opens upward so cards stay readable above the
bottom strip. */
.cards-row .card[open] {
position: absolute;
left: 8px; right: 8px;
bottom: calc(50px + env(safe-area-inset-bottom));
background: rgba(10, 10, 15, .96);
border-color: var(--gold-hermetic);
padding: 12px 16px;
z-index: 8;
} }
.card summary::-webkit-details-marker { display: none; } .card summary::-webkit-details-marker { display: none; }
.card summary::after { content: ' ▾'; color: var(--text-muted); font-size: 12px; } .card summary::after { content: ' ▾'; color: var(--text-muted); font-size: 12px; }

View File

@ -22,7 +22,10 @@
const body = document.body; const body = document.body;
const token = body.dataset.token; const token = body.dataset.token;
const i18n = JSON.parse(body.dataset.i18n || '{}'); // i18n is injected via <script>window.__SOCIAL_I18N__ = { … }</script>
// in the template head — keeps FR apostrophes intact (was a JSON.parse
// crash when inlined as a data-* attribute).
const i18n = window.__SOCIAL_I18N__ || {};
// ─── i18n helper ─── // ─── i18n helper ───
function t(key, vars = {}) { function t(key, vars = {}) {
@ -36,8 +39,6 @@
// ─── DOM refs ─── // ─── DOM refs ───
const svgEl = document.getElementById('social-graph'); const svgEl = document.getElementById('social-graph');
const svg = d3.select(svgEl); const svg = d3.select(svgEl);
const loadingEl = document.getElementById('social-loading');
const emptyEl = document.getElementById('social-empty');
const ndEl = document.getElementById('node-detail'); const ndEl = document.getElementById('node-detail');
const wipeModal = document.getElementById('wipe-modal'); const wipeModal = document.getElementById('wipe-modal');
@ -49,7 +50,14 @@
// ─── graph state ─── // ─── graph state ───
let simulation = null; let simulation = null;
const W = 600, H = 600;
function svgSize() {
// Measure actual rendered size so the force center scales with the
// viewport. Falls back to a sane default if the layout hasn't
// settled yet.
const r = svgEl.getBoundingClientRect();
return { W: Math.max(r.width, 320), H: Math.max(r.height, 320) };
}
function clearGraph() { function clearGraph() {
svg.selectAll('*').remove(); svg.selectAll('*').remove();
@ -58,16 +66,15 @@
function render(graph) { function render(graph) {
clearGraph(); clearGraph();
const { W, H } = svgSize();
svg.attr('viewBox', `0 0 ${W} ${H}`);
bind('total_trackers', graph.stats.total_trackers || 0); bind('total_trackers', graph.stats.total_trackers || 0);
bind('total_sites', graph.stats.total_sites || 0); bind('total_sites', graph.stats.total_sites || 0);
loadingEl.hidden = true; // Empty graph → just return ; the stats tiles already show 0/0 and
if (!graph.nodes.length) { // the user knows. No persistent overlay message.
emptyEl.hidden = false; if (!graph.nodes.length) return;
return;
}
emptyEl.hidden = true;
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves. // Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
const siteSet = new Set(); const siteSet = new Set();
@ -124,13 +131,18 @@
.force('center', d3.forceCenter(W / 2, H / 2)) .force('center', d3.forceCenter(W / 2, H / 2))
.force('collide', d3.forceCollide().radius(22)); .force('collide', d3.forceCollide().radius(22));
const linkSel = svg.append('g').attr('class', 'links') // Phase 11.B v3 — content group that owns links + nodes ; the
// d3.zoom behavior applies its transform here so pan/pinch don't
// move the SVG itself (or its viewBox).
const content = svg.append('g').attr('class', 'content');
const linkSel = content.append('g').attr('class', 'links')
.selectAll('line').data([...links, ...accentLinks]).join('line') .selectAll('line').data([...links, ...accentLinks]).join('line')
.attr('class', d => d.accent ? 'edge accent' : 'edge') .attr('class', d => d.accent ? 'edge accent' : 'edge')
.attr('stroke-width', d => Math.max(1, Math.log(1 + (d.reuse || 0)) * 1.8)) .attr('stroke-width', d => Math.max(1, Math.log(1 + (d.reuse || 0)) * 1.8))
.attr('stroke-dasharray', d => d.accent ? '4,3' : null); .attr('stroke-dasharray', d => d.accent ? '4,3' : null);
const nodeG = svg.append('g').attr('class', 'nodes') const nodeG = content.append('g').attr('class', 'nodes')
.selectAll('g').data(nodes).join('g') .selectAll('g').data(nodes).join('g')
.attr('class', d => 'node node-' + d.kind) .attr('class', d => 'node node-' + d.kind)
.call(d3.drag() .call(d3.drag()
@ -147,11 +159,60 @@
.attr('x', 12).attr('y', 4) .attr('x', 12).attr('y', 4)
.text(d => d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label); .text(d => d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label);
// ─── pan + pinch-zoom on the SVG (transform applies to content) ──
// Drag on a node calls d3.drag, drag on empty SVG calls d3.zoom's
// pan ; pinch and wheel always zoom. Touch-action: none on the
// svg (css) keeps the browser from intercepting these gestures.
const zoom = d3.zoom()
.scaleExtent([0.2, 6])
.filter((ev) => {
// Allow pan when the gesture didn't start on a node element.
// Allow all wheel + touch (multi-finger pinch).
if (ev.type === 'wheel' || (ev.touches && ev.touches.length > 1)) return true;
return !ev.target.closest('.node');
})
.on('zoom', (ev) => content.attr('transform', ev.transform));
svg.call(zoom).on('dblclick.zoom', () => autoFit(800));
// Auto-fit once the simulation cools so all nodes are visible.
let fitDone = false;
function autoFit(duration = 600) {
if (!nodes.length) return;
const bbox = content.node().getBBox();
if (!bbox.width || !bbox.height) return;
const pad = 28;
const scale = Math.min(
(W - pad * 2) / bbox.width,
(H - pad * 2) / bbox.height,
2.5,
);
const tx = (W - bbox.width * scale) / 2 - bbox.x * scale;
const ty = (H - bbox.height * scale) / 2 - bbox.y * scale;
svg.transition().duration(duration).call(
zoom.transform,
d3.zoomIdentity.translate(tx, ty).scale(scale),
);
}
simulation.on('tick', () => { simulation.on('tick', () => {
linkSel linkSel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y) .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y); .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
nodeG.attr('transform', d => `translate(${d.x},${d.y})`); nodeG.attr('transform', d => `translate(${d.x},${d.y})`);
if (!fitDone && simulation.alpha() < 0.1) {
fitDone = true;
autoFit();
}
});
// Re-fit on viewport resize.
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const sz = svgSize();
svg.attr('viewBox', `0 0 ${sz.W} ${sz.H}`);
autoFit(400);
}, 150);
}); });
} }
@ -201,9 +262,6 @@
const j = await r.json(); const j = await r.json();
wipeModal.close(); wipeModal.close();
alert(t('wipe_success', { n: j.rows_deleted || 0 })); alert(t('wipe_success', { n: j.rows_deleted || 0 }));
// Refresh
loadingEl.hidden = false;
svgEl.style.display = '';
fetchGraph(); fetchGraph();
} catch (e) { } catch (e) {
console.error('[social] wipe failed', e); console.error('[social] wipe failed', e);
@ -232,7 +290,6 @@
render(g); render(g);
} catch (e) { } catch (e) {
console.error('[social] fetch failed', e); console.error('[social] fetch failed', e);
loadingEl.textContent = t('error');
} }
} }
fetchGraph(); fetchGraph();