From 3dcaf57ffd028ac247c27129815b0598dcdff45b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:55:26 +0200 Subject: [PATCH 01/11] feat(peers): peersOfOwner(userId) helper (direct peers.user_id lookup) --- src/services/peers.js | 9 +++++++++ tests/peers_of_owner.test.js | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/peers_of_owner.test.js diff --git a/src/services/peers.js b/src/services/peers.js index 33fe41ab..75cf2a3f 100644 --- a/src/services/peers.js +++ b/src/services/peers.js @@ -711,6 +711,14 @@ async function batch(action, ids) { return ids.length; } +/** + * Get all peer IDs owned by a user + */ +function peersOfOwner(userId) { + if (userId == null) return []; + return getDb().prepare('SELECT id FROM peers WHERE user_id = ?').all(userId).map(r => r.id); +} + module.exports = { getAll, getById, @@ -724,4 +732,5 @@ module.exports = { batch, setHostname, markHostnamesStale, + peersOfOwner, }; diff --git a/tests/peers_of_owner.test.js b/tests/peers_of_owner.test.js new file mode 100644 index 00000000..c1ad140f --- /dev/null +++ b/tests/peers_of_owner.test.js @@ -0,0 +1,23 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let peers, getDb; +beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } +function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; } + +test('peersOfOwner returns only the owner\'s peer ids', () => { + const u1 = seedUser('o1'), u2 = seedUser('o2'); + const a = seedPeer('10.8.0.1', u1); const b = seedPeer('10.8.0.2', u1); seedPeer('10.8.0.3', u2); + const ids = peers.peersOfOwner(u1).sort((x,y)=>x-y); + assert.deepEqual(ids, [a,b].sort((x,y)=>x-y)); +}); +test('peersOfOwner returns [] for an owner with no peers and for null', () => { + const u = seedUser('lonely'); + assert.deepEqual(peers.peersOfOwner(u), []); + assert.deepEqual(peers.peersOfOwner(null), []); +}); From c76a85bc7910980e2ed8c9ec2fbb0455b58389d0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:57:51 +0200 Subject: [PATCH 02/11] feat(portal): portal.trust_owner_mapping setting (default off, config + PUT) --- src/routes/api/settings/portal.js | 12 ++++++++---- src/services/portalConfig.js | 4 +++- tests/portal_trust_setting.test.js | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 tests/portal_trust_setting.test.js diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index aaf20752..a91afb2f 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -1,7 +1,7 @@ 'use strict'; -// Portal settings cluster — master switch + per-widget toggles + public host. -// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, +// Portal settings cluster — master switch + per-widget toggles + public host + owner mapping. +// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, portal.trust_owner_mapping, // portal.base_domain, portal.prefix const { Router } = require('express'); @@ -30,11 +30,11 @@ router.get('/portal', (req, res) => { }); /** - * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host + * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host + owner mapping * * Accepts: * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool, pihole: bool }, - * base_domain: string, prefix: string } + * trust_owner_mapping: bool, base_domain: string, prefix: string } */ router.put('/portal', (req, res) => { try { @@ -57,6 +57,10 @@ router.put('/portal', (req, res) => { settings.set('portal.widget.pihole', widgets.pihole ? '1' : '0'); } + if (body.trust_owner_mapping !== undefined) { + settings.set('portal.trust_owner_mapping', body.trust_owner_mapping ? '1' : '0'); + } + // Host change (base_domain + prefix committed together). if (body.base_domain !== undefined || body.prefix !== undefined) { const base = String(body.base_domain !== undefined ? body.base_domain : settings.get('portal.base_domain', '') || '').trim().toLowerCase(); diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js index 53511fcb..e3ba8786 100644 --- a/src/services/portalConfig.js +++ b/src/services/portalConfig.js @@ -5,8 +5,9 @@ const settings = require('./settings'); /** * Returns the current VPN landing portal configuration derived from settings. * All values default to enabled ('1') unless explicitly set to '0'. + * Note: trustOwnerMapping defaults to disabled ('0') — unlike the widgets. * - * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean } }} + * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean }, trustOwnerMapping: boolean }} */ const on = (key) => settings.get(key, '1') !== '0'; @@ -19,6 +20,7 @@ function portalConfig() { services: on('portal.widget.services'), pihole: on('portal.widget.pihole'), }, + trustOwnerMapping: settings.get('portal.trust_owner_mapping', '0') !== '0', }; } diff --git a/tests/portal_trust_setting.test.js b/tests/portal_trust_setting.test.js new file mode 100644 index 00000000..1a2b3ff5 --- /dev/null +++ b/tests/portal_trust_setting.test.js @@ -0,0 +1,19 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +let portalConfig, settings; +beforeEach(async () => { await setup(); portalConfig = require('../src/services/portalConfig'); settings = require('../src/services/settings'); }); +afterEach(teardown); + +test('trustOwnerMapping defaults to false (key unwritten)', () => { + assert.equal(portalConfig().trustOwnerMapping, false); +}); +test('PUT persists trust_owner_mapping=true and config reflects it', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ trust_owner_mapping: true }).expect(200); + assert.equal(settings.get('portal.trust_owner_mapping'), '1'); + assert.equal(portalConfig().trustOwnerMapping, true); +}); From ef31010d18bd4824b7ccefd1e8c6e68c985c70fe Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:00:50 +0200 Subject: [PATCH 03/11] feat(portal): portalOwner seam (session>device precedence, no IDOR) --- src/middleware/portalOwner.js | 40 +++++++++++++++++++++++ src/routes/index.js | 3 +- tests/portal_owner_middleware.test.js | 47 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/middleware/portalOwner.js create mode 100644 tests/portal_owner_middleware.test.js diff --git a/src/middleware/portalOwner.js b/src/middleware/portalOwner.js new file mode 100644 index 00000000..eeac97ab --- /dev/null +++ b/src/middleware/portalOwner.js @@ -0,0 +1,40 @@ +// src/middleware/portalOwner.js +'use strict'; +const { getDb } = require('../db/connection'); +const settings = require('../services/settings'); + +function trustEnabled() { + return settings.get('portal.trust_owner_mapping', '0') !== '0'; +} + +/** Owner (users.id) of a peer via the TP1 peers.user_id column, or null. */ +function ownerOfPeer(peerId) { + if (peerId == null) return null; + const row = getDb().prepare('SELECT user_id FROM peers WHERE id = ?').get(peerId); + return row && row.user_id != null ? row.user_id : null; +} + +/** + * Resolve the portal OWNER on top of portalIdentity (which set req.portalPeerId). + * Precedence: an authenticated session ALWAYS wins over device-owner trust. + * Device-owner trust only applies when there is no session AND the admin enabled it. + * The owner id never comes from the request body/query/header (no IDOR). + */ +function portalOwner(req, _res, next) { + req.portalLoggedIn = !!(req.session && req.session.userId); + if (req.portalLoggedIn) { + req.portalOwnerId = req.session.userId; + req.portalOwnerSource = 'session'; + } else if (trustEnabled() && req.portalPeerId != null) { + const uid = ownerOfPeer(req.portalPeerId); + req.portalOwnerId = uid; + req.portalOwnerSource = uid != null ? 'device' : null; + } else { + req.portalOwnerId = null; + req.portalOwnerSource = null; + } + next(); +} + +module.exports = portalOwner; +module.exports.ownerOfPeer = ownerOfPeer; diff --git a/src/routes/index.js b/src/routes/index.js index 44525992..08210217 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -298,7 +298,8 @@ router.get('/api/v1/events', requireAuth, require('./api/events')); // ─── Portal API (source-IP identity, no session auth) ────────── const portalIdentity = require('../middleware/portalIdentity'); -router.use('/api/v1/portal', apiLimiter, portalIdentity, require('./api/portal')); +const portalOwner = require('../middleware/portalOwner'); +router.use('/api/v1/portal', apiLimiter, portalIdentity, portalOwner, require('./api/portal')); // ─── Portal page (source-IP identity, no session auth) ───────── const portalConfig = require('../services/portalConfig'); diff --git a/tests/portal_owner_middleware.test.js b/tests/portal_owner_middleware.test.js new file mode 100644 index 00000000..89f4f9ee --- /dev/null +++ b/tests/portal_owner_middleware.test.js @@ -0,0 +1,47 @@ +// tests/portal_owner_middleware.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let portalOwner, settings, getDb; +beforeEach(async () => { await setup(); portalOwner = require('../src/middleware/portalOwner'); settings = require('../src/services/settings'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } +function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; } +function run(req){ let nexted=false; portalOwner(req, {}, ()=>{ nexted=true; }); assert.ok(nexted,'must call next'); return req; } + +test('logged in → owner=session.userId, source session (device irrelevant)', () => { + const u = seedUser('alice'); const other = seedUser('bob'); const dev = seedPeer('10.8.0.9', other); + const req = run({ session:{ userId:u }, portalPeerId: dev }); + assert.equal(req.portalOwnerId, u); // session wins over device owner 'bob' + assert.equal(req.portalOwnerSource, 'session'); + assert.equal(req.portalLoggedIn, true); +}); +test('zero-login + trust OFF → owner null', () => { + const u = seedUser('carol'); const dev = seedPeer('10.8.0.10', u); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, null); + assert.equal(req.portalOwnerSource, null); + assert.equal(req.portalLoggedIn, false); +}); +test('zero-login + trust ON + device has owner → owner=device.user_id, source device', () => { + settings.set('portal.trust_owner_mapping','1'); + const u = seedUser('dave'); const dev = seedPeer('10.8.0.11', u); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, u); + assert.equal(req.portalOwnerSource, 'device'); +}); +test('zero-login + trust ON + device has NO owner → owner null', () => { + settings.set('portal.trust_owner_mapping','1'); + const dev = seedPeer('10.8.0.12', null); + const req = run({ session:{}, portalPeerId: dev }); + assert.equal(req.portalOwnerId, null); + assert.equal(req.portalOwnerSource, null); +}); +test('zero-login + trust ON + unidentified device → owner null', () => { + settings.set('portal.trust_owner_mapping','1'); + const req = run({ session:{}, portalPeerId: null }); + assert.equal(req.portalOwnerId, null); +}); From 434fe738bcd61e08a5ec6cef31d6a751a7c319c1 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:06:47 +0200 Subject: [PATCH 04/11] feat(portal): GET /pihole/owner per-owner aggregate (no IDOR, whitelisted) --- src/routes/api/portal.js | 23 ++++++++++ tests/pihole_portal_owner.test.js | 76 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/pihole_portal_owner.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 42e2d5b9..27ba4ca1 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -163,4 +163,27 @@ router.get('/pihole', (req, res) => { } }); +router.get('/pihole/owner', (req, res) => { + try { + if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false }); + const cache = pihole.getCache(); + if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) { + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } + if (req.portalOwnerId == null) return res.json({ ok: true, data: null, reason: 'no_owner' }); + if (cache.attribution === 'collapsed') return res.json({ ok: true, data: null, reason: 'collapsed' }); + const ownerPeerIds = new Set(peers.peersOfOwner(req.portalOwnerId)); // owner id NEVER from req body/query + let allowed = 0, blocked = 0; const seen = new Set(); + for (const c of (cache.topClients || [])) if (ownerPeerIds.has(c.peerId)) { allowed += c.count; seen.add(c.peerId); } + for (const c of (cache.topClientsBlocked || [])) if (ownerPeerIds.has(c.peerId)) { blocked += c.count; seen.add(c.peerId); } + if (seen.size === 0) return res.json({ ok: true, data: null, reason: 'no_data' }); + const total = allowed + blocked; + const blockedPct = total ? Math.round((blocked / total) * 100) : 0; + res.json({ ok: true, data: { total, blocked, allowed, blockedPct, deviceCount: seen.size, asOf: cache.lastSyncAt } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /pihole/owner failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/tests/pihole_portal_owner.test.js b/tests/pihole_portal_owner.test.js new file mode 100644 index 00000000..9a41dc59 --- /dev/null +++ b/tests/pihole_portal_owner.test.js @@ -0,0 +1,76 @@ +// tests/pihole_portal_owner.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +// setup MUST be required before config/default so NODE_ENV=test is set before +// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode). +const { setup, teardown, getAgent } = require('./helpers/setup'); +const config = require('../config/default'); +const HOME_HOST = `home.${config.dns.domain}`; +let app, getDb, pihole, license, adminId, p1, p2, foreignPeer, foreignUser; + +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); + license = require('../src/services/license'); + license.hasFeature = () => true; + adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id; // getAgent() is this user + p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid; + p2 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m2','k2','10.8.0.6/32',1,'regular',?)").run(adminId).lastInsertRowid; + foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid; + foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid; +}); +afterEach(() => { teardown(); }); + +function cacheWith(extra){ return Object.assign({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, topClients:[], topClientsBlocked:[] }, extra); } + +test('logged-in owner → aggregate over OWN devices only (+ deviceCount), no foreign device', async () => { + pihole.getCache = () => cacheWith({ + topClients: [{ ip:'10.8.0.5', count:500, peerId:p1, peerName:'m1' }, { ip:'10.8.0.6', count:300, peerId:p2, peerName:'m2' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }], + topClientsBlocked: [{ ip:'10.8.0.5', count:50, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:888, peerId:foreignPeer, peerName:'b1' }], + }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data.allowed, 800); // 500+300, NOT 999 + assert.equal(r.body.data.blocked, 50); // 50, NOT 888 + assert.equal(r.body.data.total, 850); + assert.equal(r.body.data.deviceCount, 2); // p1 + p2 + assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']); +}); +test('IDOR: a foreign owner_id in body/query is ignored', async () => { + pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner?user_id=' + foreignUser).send({ user_id: foreignUser }).expect(200); + // admin owns p1/p2 which are NOT in the cache here → no_data, never the foreign 999 + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'no_data'); +}); +test('owner with devices but none in lists → no_data', async () => { + pihole.getCache = () => cacheWith({}); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data'); +}); +test('not logged in, trust off → no_owner', async () => { + pihole.getCache = () => cacheWith({}); + const r = await supertest(app).get('/api/v1/portal/pihole/owner').set('Host', HOME_HOST).expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_owner'); +}); +test('collapsed → collapsed', async () => { + pihole.getCache = () => cacheWith({ attribution:'collapsed' }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'collapsed'); +}); +test('feature off → unavailable', async () => { + license.hasFeature = () => false; + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); +test('logged-in but user deleted (peers user_id nulled) → no_data, not no_owner', async () => { + getDb().prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(adminId); // simulate TP1 cleanup + pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.5', count:5, peerId:p1, peerName:'m1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data'); +}); From 9c0c7a6bd41dfdbad0ee7edc93bae6c4e135031b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:10:32 +0200 Subject: [PATCH 05/11] feat(portal): GET /pihole/household global summary (login-gated, whitelisted) --- src/routes/api/portal.js | 19 +++++++++++ tests/pihole_portal_household.test.js | 48 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/pihole_portal_household.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 27ba4ca1..2df595b1 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -186,4 +186,23 @@ router.get('/pihole/owner', (req, res) => { } }); +router.get('/pihole/household', (req, res) => { + try { + if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false }); + const cache = pihole.getCache(); + if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) { + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } + if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' }); // trust switch never relaxes household + const s = cache.summary; + if (!s || !s.queries) return res.json({ ok: true, data: null, reason: 'unavailable' }); + const total = s.queries.total || 0, blocked = s.queries.blocked || 0; + const blockedPct = total ? Math.round((blocked / total) * 100) : 0; + res.json({ ok: true, data: { total, blocked, blockedPct, activeClients: (s.clients && s.clients.active != null) ? s.clients.active : null, asOf: cache.lastSyncAt } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /pihole/household failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/tests/pihole_portal_household.test.js b/tests/pihole_portal_household.test.js new file mode 100644 index 00000000..ffa3be25 --- /dev/null +++ b/tests/pihole_portal_household.test.js @@ -0,0 +1,48 @@ +// tests/pihole_portal_household.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +// setup MUST be required before config/default so NODE_ENV=test is set before +// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode). +const { setup, teardown, getAgent } = require('./helpers/setup'); +const config = require('../config/default'); +const HOME_HOST = `home.${config.dns.domain}`; +let app, getDb, pihole, license; +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); license = require('../src/services/license'); + license.hasFeature = () => true; +}); +afterEach(() => { teardown(); }); +function cacheWith(summary){ return { instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, summary }; } + +test('logged-in → global summary numbers', async () => { + pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250, percent:25 }, clients:{ active:7 } }); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data.total, 1000); + assert.equal(r.body.data.blocked, 250); + assert.equal(r.body.data.blockedPct, 25); + assert.equal(r.body.data.activeClients, 7); + assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']); +}); +test('not logged in → login_required (even with trust on)', async () => { + require('../src/services/settings').set('portal.trust_owner_mapping','1'); + pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250 }, clients:{ active:7 } }); + const r = await supertest(app).get('/api/v1/portal/pihole/household').set('Host', HOME_HOST).expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'login_required'); +}); +test('summary null (pre first sync) → unavailable', async () => { + pihole.getCache = () => cacheWith(null); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); +test('feature off → unavailable', async () => { + license.hasFeature = () => false; + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable'); +}); From b9486f12f05ed896bca03c426947ea3ef63cc997 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:19:01 +0200 Subject: [PATCH 06/11] =?UTF-8?q?test(portal):=20regression=20=E2=80=94=20?= =?UTF-8?q?owner/household=20never=20leak=20foreign=20or=20raw=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ole_portal_owner_household_no_leak.test.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/pihole_portal_owner_household_no_leak.test.js diff --git a/tests/pihole_portal_owner_household_no_leak.test.js b/tests/pihole_portal_owner_household_no_leak.test.js new file mode 100644 index 00000000..719b4079 --- /dev/null +++ b/tests/pihole_portal_owner_household_no_leak.test.js @@ -0,0 +1,65 @@ +// tests/pihole_portal_owner_household_no_leak.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +const { setup, teardown, getAgent } = require('./helpers/setup'); +let getDb, pihole, license, adminId, p1, foreignPeer, foreignUser; +beforeEach(async () => { + await setup(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); license = require('../src/services/license'); license.hasFeature = () => true; + adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id; + p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid; + foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid; + foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid; +}); +afterEach(() => { teardown(); }); + +test('owner endpoint: response exposes only the aggregate, never foreign device or raw fields', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + topClients:[{ ip:'10.8.0.5', count:100, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }], + topClientsBlocked:[{ ip:'10.8.0.5', count:10, peerId:p1, peerName:'m1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200); + const raw = JSON.stringify(r.body); + assert.equal(r.body.data.allowed, 100); + // exhaustive key whitelist — cannot be gamed by field-name collisions + assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']); + assert.ok(!raw.includes('10.8.0.50') && !raw.includes('b1'), 'foreign device leaked'); + assert.ok(!/\bip\b|peerId|peerName|user_id|owner_name|topClients/.test(raw), 'raw field leaked: ' + raw); +}); +test('household endpoint: only the global aggregate, no client list', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + summary:{ queries:{ total:9, blocked:3 }, clients:{ active:2 } }, + topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] }); + const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200); + const raw = JSON.stringify(r.body); + assert.equal(r.body.data.total, 9); + assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']); + assert.ok(!/peerId|peerName|topClients|\bip\b|10\.8\.0\.50/.test(raw), 'raw field leaked: ' + raw); +}); +test('structural: owner+household handlers serialize only locally-built aggregates', () => { + const src = fs.readFileSync(path.join(__dirname,'..','src','routes','api','portal.js'),'utf8'); + for (const route of ['/pihole/owner','/pihole/household']) { + // String-based extraction (indexOf + brace-counting) — immune to regex-escaping bugs. + const start = src.indexOf("router.get('" + route + "'"); + assert.ok(start !== -1, route + ' handler not found'); + let depth = 0, i = start, end = -1; + while (i < src.length) { + const ch = src[i]; + if (ch === '{') depth++; + else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } } + i++; + } + assert.ok(end !== -1, route + ' handler not terminated'); + const body = src.slice(start, end + 1); + // Regex LITERALS on the extracted string — single-escaped, verified to match real code. + assert.ok(!/res\.json\(\s*cache\s*\)/.test(body) && !/res\.json\(\s*\{[\s\S]*?\.\.\.\s*cache/.test(body), route + ' returns/spreads raw cache'); + // [^\n]* (same-line only) avoids false-positives from `res.json(...)` early-exit + // guards that appear in the body BEFORE the topClients loop lines. A real leak + // (e.g. `res.json({ data: cache.topClients })`) would be on one line → still caught. + assert.ok(!/res\.json\([^\n]*topClients/.test(body), route + ' serializes topClients'); + } +}); From 8f863ed648692449e822a9bc629404ddd6c4b050 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:25:06 +0200 Subject: [PATCH 07/11] feat(portal): admin trust_owner_mapping toggle + kiosk help text (3 themes) --- public/js/settings.js | 8 ++++++-- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ templates/aurora/pages/settings.njk | 5 +++++ templates/default/pages/settings.njk | 5 +++++ templates/pro/pages/settings.njk | 5 +++++ tests/portal_trust_settings_ui.test.js | 17 +++++++++++++++++ 7 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/portal_trust_settings_ui.test.js diff --git a/public/js/settings.js b/public/js/settings.js index 1ed64275..13818f2c 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1764,9 +1764,10 @@ var widgetTraffic = document.getElementById('portal-widget-traffic'); var widgetServices = document.getElementById('portal-widget-services'); var widgetPihole = document.getElementById('portal-widget-pihole'); + var trustToggle = document.getElementById('portal-trust-owner-mapping'); if (!enabledToggle) return; - [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].forEach(function (el) { + [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].forEach(function (el) { if (el) el.addEventListener('click', function () { el.classList.toggle('on'); el.dispatchEvent(new Event('change')); @@ -1786,12 +1787,13 @@ setToggle(widgetTraffic, d.widgets && d.widgets.traffic); setToggle(widgetServices, d.widgets && d.widgets.services); setToggle(widgetPihole, d.widgets && d.widgets.pihole); + setToggle(trustToggle, d.trustOwnerMapping); if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal'); }).catch(function (err) { console.error('Failed to load portal settings:', err); }); - var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].filter(Boolean); + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].filter(Boolean); SettingsAutosave.bind({ cluster: 'portal', fields: portalFields, @@ -1803,6 +1805,7 @@ 'portal-widget-traffic': widgetTraffic ? widgetTraffic.classList.contains('on') : true, 'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true, 'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true, + 'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false, }; }, save: function () { @@ -1814,6 +1817,7 @@ services: widgetServices ? widgetServices.classList.contains('on') : true, pihole: widgetPihole ? widgetPihole.classList.contains('on') : true, }, + trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false, }); }, }); diff --git a/src/i18n/de.json b/src/i18n/de.json index dc93a422..00636aff 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1980,6 +1980,8 @@ "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", "settings.portal.widget_pihole": "Pi-hole-Widget", + "settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)", + "settings.portal.trust_owner_mapping_help": "Wenn aktiv, sehen nicht eingeloggte Nutzer auf Geräten mit zugeordnetem Besitzer dessen aggregierte DNS-Zahlen ohne Login. Nur aktivieren, wenn Geräte ausschließlich vom zugeordneten Besitzer genutzt werden.", "settings.portal.saved": "Portal-Einstellungen gespeichert", "settings.portal.host_not_verified": "Domain ist nicht verifiziert", "settings.portal.host_invalid_prefix": "Ungültiges Subdomain-Präfix", diff --git a/src/i18n/en.json b/src/i18n/en.json index 9a1a221b..1df1f305 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1980,6 +1980,8 @@ "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", "settings.portal.widget_pihole": "Pi-hole widget", + "settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)", + "settings.portal.trust_owner_mapping_help": "When enabled, unauthenticated users on a device with an assigned owner see that owner's aggregated DNS counts without logging in. Enable only when devices are used exclusively by their assigned owner.", "settings.portal.saved": "Portal settings saved", "settings.portal.host_not_verified": "Domain is not verified", "settings.portal.host_invalid_prefix": "Invalid subdomain prefix", diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 9e51a489..d897fc87 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -977,6 +977,11 @@ {{ t('settings.portal.widget_pihole') }}
+
+ {{ t('settings.portal.trust_owner_mapping') }} +
+
+
{{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index afdec830..caa95d27 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -1142,6 +1142,11 @@ {{ t('settings.portal.widget_pihole') }}
+
+ {{ t('settings.portal.trust_owner_mapping') }} +
+
+
{{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index 7265ec44..d34b85bf 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -1029,6 +1029,11 @@ {{ t('settings.portal.widget_pihole') }}
+
+ {{ t('settings.portal.trust_owner_mapping') }} +
+
+
{{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/tests/portal_trust_settings_ui.test.js b/tests/portal_trust_settings_ui.test.js new file mode 100644 index 00000000..3b6f7dde --- /dev/null +++ b/tests/portal_trust_settings_ui.test.js @@ -0,0 +1,17 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +test('trust toggle + help present in all 3 theme settings pages', () => { + for (const theme of ['aurora','default','pro']) { + const html = fs.readFileSync(path.join(__dirname,'..','templates',theme,'pages','settings.njk'),'utf8'); + assert.ok(html.includes('portal-trust-owner-mapping'), `${theme}: toggle id`); + assert.ok(html.includes('settings.portal.trust_owner_mapping'), `${theme}: label key`); + assert.ok(html.includes('settings.portal.trust_owner_mapping_help'), `${theme}: help key`); + } +}); +test('settings.js wires trust toggle into portal cluster + PUT', () => { + const js = fs.readFileSync(path.join(__dirname,'..','public','js','settings.js'),'utf8'); + assert.ok(/portal-trust-owner-mapping/.test(js)); + assert.ok(/trust_owner_mapping:/.test(js)); +}); From 41ce5744169d1a54772e0dedce948d2c55b85f70 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:30:20 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(portal):=20hydratePiholeScope=20?= =?UTF-8?q?=E2=80=94=20scope-aware=20render=20+=20segment=20wiring=20(DOM-?= =?UTF-8?q?safe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/portal.js | 119 +++++++++++++++++++++++---- tests/pihole_portal_scope_js.test.js | 17 ++++ 2 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 tests/pihole_portal_scope_js.test.js diff --git a/public/js/portal.js b/public/js/portal.js index 283e4803..c0ba2ae6 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -347,37 +347,122 @@ } // ─── Pi-hole widget ───────────────────────────────────────────────────────── - function hydratePihole() { - const card = document.querySelector('.pihole-widget'); + var piScopeActive = 'device'; + + // Endpoint map for the three scopes (TP2a device + TP2b owner/household) + var PI_ENDPOINTS = { + device: '/api/v1/portal/pihole', + owner: '/api/v1/portal/pihole/owner', + household: '/api/v1/portal/pihole/household' + }; + + function hydratePiholeScope(scope) { + var card = document.querySelector('.pihole-widget'); if (!card) return; // widget toggled off → not in DOM → no fetch setLoading(card, true); - fetch('/api/v1/portal/pihole') + var url = PI_ENDPOINTS[scope] || PI_ENDPOINTS.device; + fetch(url) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (body) { setLoading(card, false); - const msg = document.getElementById('piMsg'); - const bodyEl = card.querySelector('.pihole-body'); + var msg = document.getElementById('piMsg'); + var bodyEl = card.querySelector('.pihole-body'); if (!body.ok || body.data === null) { - if (body.reason === 'unavailable') { card.style.display = 'none'; return; } - var key = { unavailable:'piholeUnavailable', collapsed:'piholeCollapsed', no_data:'piholeNoData', unidentified:'piholeUnidentified' }[body.reason] || 'piholeUnavailable'; + var reason = body.reason; + if (reason === 'unavailable') { card.style.display = 'none'; return; } if (bodyEl) bodyEl.style.display = 'none'; - if (msg) { msg.textContent = (PT[key] || ''); msg.style.display = 'block'; } // PT = i18n map (portal.js line 14) + if (msg) { + msg.replaceChildren(); + if (reason === 'no_owner' || reason === 'login_required') { + // DOM-safe login affordance — no innerHTML with i18n text or /login href + var hintKey = reason === 'no_owner' ? 'piholeNoOwner' : 'piholeLoginRequired'; + var hintSpan = document.createElement('span'); + hintSpan.textContent = PT[hintKey] || ''; + var a = document.createElement('a'); + a.href = '/login'; + a.textContent = PT.piholeLoginLink || 'Log in'; + msg.appendChild(hintSpan); + msg.appendChild(document.createTextNode(' ')); + msg.appendChild(a); + } else { + var key = { collapsed:'piholeCollapsed', no_data:'piholeNoData', unidentified:'piholeUnidentified' }[reason] || 'piholeUnavailable'; + msg.textContent = PT[key] || ''; // PT = i18n map (portal.js ~line 14) + } + msg.style.display = 'block'; + } return; } if (bodyEl) bodyEl.style.display = ''; var d = body.data; - document.getElementById('piPct').textContent = String(d.blockedPct); - var bar = document.getElementById('piBar'); if (bar) bar.style.width = d.blockedPct + '%'; - document.getElementById('piTotal').textContent = String(d.total); - document.getElementById('piBlocked').textContent = String(d.blocked); - document.getElementById('piAllowed').textContent = String(d.allowed); - // genuine idle device (in a list, count 0) → show "no queries today" note (spec §5) + + // ── Stats common to all scopes ────────────────────────────────────── + var pctEl = document.getElementById('piPct'); + if (pctEl) pctEl.textContent = String(d.blockedPct); + var bar = document.getElementById('piBar'); + if (bar) bar.style.width = d.blockedPct + '%'; + var totalEl = document.getElementById('piTotal'); + if (totalEl) totalEl.textContent = String(d.total); + var blockedEl = document.getElementById('piBlocked'); + if (blockedEl) blockedEl.textContent = String(d.blocked); + + // ── Scope-specific fields ─────────────────────────────────────────── + if (scope !== 'household') { + // device + owner: show allowed count + var allowedEl = document.getElementById('piAllowed'); + if (allowedEl) allowedEl.textContent = String(d.allowed); + } + + if (scope === 'owner') { + // owner: device count across the owner's peers + var devCountEl = document.getElementById('piOwnerDevices'); + if (devCountEl) { + devCountEl.textContent = (PT['piholeOwnerDevices'] || '{n}').replace('{n}', String(d.deviceCount)); + } + var devHintEl = document.getElementById('piOwnerDevicesHint'); + if (devHintEl) devHintEl.textContent = PT['piholeOwnerDevicesHint'] || ''; + } + + if (scope === 'household') { + // household: active client count, no allowed stat + var clientsEl = document.getElementById('piActiveClients'); + if (clientsEl) { + clientsEl.textContent = (PT['piholeActiveClients'] || '{n}').replace('{n}', String(d.activeClients || 0)); + } + } + + // ── Zero-queries notice (spec §5) ─────────────────────────────────── if (msg) { - if (d.total === 0) { msg.textContent = (PT['piholeZeroQueries'] || ''); msg.style.display = 'block'; } - else { msg.style.display = 'none'; } + msg.replaceChildren(); + if (d.total === 0) { + msg.textContent = PT['piholeZeroQueries'] || ''; + msg.style.display = 'block'; + } else { + msg.style.display = 'none'; + } } }) - .catch(function () { setLoading(card, false); showError(card, hydratePihole); }); + .catch(function () { setLoading(card, false); showError(card, function () { hydratePiholeScope(scope); }); }); + } + + // hydratePihole: TP2a entry point — wires the segment switcher once, then + // loads the default 'device' scope (boot call below is unchanged). + function hydratePihole() { + var seg = document.getElementById('piholeSeg'); + if (seg) { + seg.querySelectorAll('button').forEach(function (btn) { + btn.classList.toggle('on', btn.dataset.scope === piScopeActive); + }); + seg.addEventListener('click', function (e) { + var btn = e.target.closest('button'); + if (!btn || !btn.dataset.scope) return; + piScopeActive = btn.dataset.scope; + seg.querySelectorAll('button').forEach(function (b) { + b.classList.toggle('on', b.dataset.scope === piScopeActive); + }); + hydratePiholeScope(piScopeActive); + }); + } + hydratePiholeScope('device'); } // ─── Boot ─────────────────────────────────────────────────────────────────── diff --git a/tests/pihole_portal_scope_js.test.js b/tests/pihole_portal_scope_js.test.js new file mode 100644 index 00000000..9e947e2b --- /dev/null +++ b/tests/pihole_portal_scope_js.test.js @@ -0,0 +1,17 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +test('portal.js wires scope switching to the 3 endpoints + login affordance, DOM-safe, PT, no raw fields', () => { + const js = fs.readFileSync(path.join(__dirname,'..','public','js','portal.js'),'utf8'); + assert.ok(/hydratePiholeScope/.test(js), 'no hydratePiholeScope'); + assert.ok(/data-scope|dataset\.scope/.test(js), 'no scope wiring'); + assert.ok(/\/api\/v1\/portal\/pihole\/owner/.test(js) && /\/api\/v1\/portal\/pihole\/household/.test(js), 'missing endpoints'); + assert.ok(/no_owner|login_required/.test(js) && /\/login/.test(js), 'no login affordance'); + assert.ok(/\bPT\[/.test(js) && !/\bI18N\b/.test(js), 'must use PT i18n object'); + // client-side leak guard (spec §7): scope render must not touch raw cache fields + assert.ok(!/\.topClients\b|\.clients\b|data\.ip\b/.test(js), 'raw field referenced'); + // DOM-safety: the login affordance must NOT be built by concatenating i18n text into innerHTML + assert.ok(!/innerHTML\s*[+=][^;]*PT\.pihole(NoOwner|LoginRequired)/.test(js), 'innerHTML + PT i18n (XSS risk)'); + assert.ok(!/innerHTML\s*[+=][^;]*['"]\/login['"]/.test(js), 'login href via innerHTML'); +}); From 732f28bd046d9dc5f18834d0c926aa6a0dafbddc Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:38:57 +0200 Subject: [PATCH 09/11] feat(portal): pihole scope switcher markup + CSS + i18n --- public/css/portal.css | 2 ++ public/js/portal.js | 8 ++++++++ src/i18n/de.json | 9 +++++++++ src/i18n/en.json | 9 +++++++++ templates/portal/portal.njk | 22 ++++++++++++++++++---- tests/pihole_portal_scope_markup.test.js | 12 ++++++++++++ 6 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/pihole_portal_scope_markup.test.js diff --git a/public/css/portal.css b/public/css/portal.css index aac69107..733c78de 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -289,6 +289,8 @@ body::before{ .pihole-foot{font-size:11px;color:var(--faint);margin-top:4px} .pihole-msg{font-size:13px;color:var(--muted);padding:10px 0;line-height:1.5} .c-pihole.loading{min-height:160px} +.pihole-seg{font-size:12px;margin-left:auto} +.pihole-hint{font-size:11px;color:var(--muted)} /* ============================================================ REDUCED MOTION diff --git a/public/js/portal.js b/public/js/portal.js index c0ba2ae6..5b5f8ceb 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -395,6 +395,14 @@ if (bodyEl) bodyEl.style.display = ''; var d = body.data; + // ── Scope-visibility: deterministically show/hide cross-scope fields ─ + var allowedWrap = document.getElementById('piAllowedWrap'); + if (allowedWrap) allowedWrap.style.display = (scope === 'household') ? 'none' : ''; + var ownerExtra = document.getElementById('piOwnerExtra'); + if (ownerExtra) ownerExtra.style.display = (scope === 'owner') ? '' : 'none'; + var hhExtra = document.getElementById('piHouseholdExtra'); + if (hhExtra) hhExtra.style.display = (scope === 'household') ? '' : 'none'; + // ── Stats common to all scopes ────────────────────────────────────── var pctEl = document.getElementById('piPct'); if (pctEl) pctEl.textContent = String(d.blockedPct); diff --git a/src/i18n/de.json b/src/i18n/de.json index 00636aff..f57e2a20 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2006,6 +2006,15 @@ "portal.pihole.no_data": "Noch keine Daten für dieses Gerät verfügbar", "portal.pihole.unidentified": "Gerät nicht erkannt", "portal.pihole.zero_queries": "Keine Anfragen heute", + "portal.pihole.scope_device": "Gerät", + "portal.pihole.scope_owner": "Ich", + "portal.pihole.scope_household": "Haushalt", + "portal.pihole.no_owner": "Einloggen, um alle deine Geräte zu sehen", + "portal.pihole.login_required": "Einloggen für die Haushalts-Ansicht", + "portal.pihole.login_link": "Einloggen", + "portal.pihole.owner.devices_in_snapshot": "{n} Geräte im Snapshot", + "portal.pihole.owner.devices_in_snapshot_hint": "Nur Geräte in Pi-holes Top-10; weitere folgen in einem späteren Update.", + "portal.pihole.household.active_clients": "{n} aktive Clients", "settings.autosave.saved": "Gespeichert", "settings.autosave.error": "Speichern fehlgeschlagen", "settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind", diff --git a/src/i18n/en.json b/src/i18n/en.json index 1df1f305..91c0e2c8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2006,6 +2006,15 @@ "portal.pihole.no_data": "No data for this device yet", "portal.pihole.unidentified": "Device not recognised", "portal.pihole.zero_queries": "No queries today", + "portal.pihole.scope_device": "Device", + "portal.pihole.scope_owner": "Mine", + "portal.pihole.scope_household": "Household", + "portal.pihole.no_owner": "Log in to see all your devices", + "portal.pihole.login_required": "Log in to see the household view", + "portal.pihole.login_link": "Log in", + "portal.pihole.owner.devices_in_snapshot": "{n} devices in snapshot", + "portal.pihole.owner.devices_in_snapshot_hint": "Only devices in Pi-hole's top 10; more in a later update.", + "portal.pihole.household.active_clients": "{n} active clients", "settings.autosave.saved": "Saved", "settings.autosave.error": "Save failed", "settings.autosave.pending": "Will save once all required fields are filled", diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index 64850490..52772fd8 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -29,9 +29,15 @@ piholeCollapsed: t('portal.pihole.collapsed'), piholeNoData: t('portal.pihole.no_data'), piholeUnidentified: t('portal.pihole.unidentified'), - piholeZeroQueries: t('portal.pihole.zero_queries') + piholeZeroQueries: t('portal.pihole.zero_queries'), + piholeNoOwner: t('portal.pihole.no_owner'), + piholeLoginRequired: t('portal.pihole.login_required'), + piholeLoginLink: t('portal.pihole.login_link'), + piholeOwnerDevices: t('portal.pihole.owner.devices_in_snapshot'), + piholeOwnerDevicesHint: t('portal.pihole.owner.devices_in_snapshot_hint'), + piholeActiveClients: t('portal.pihole.household.active_clients') } | dump | safe }} - +
@@ -117,15 +123,23 @@ {% if widgets.pihole %}
-

{{ t('portal.pihole.title') }}

+

{{ t('portal.pihole.title') }} + + + + + +

%
{{ t('portal.pihole.total') }}
{{ t('portal.pihole.blocked') }}
-
{{ t('portal.pihole.allowed') }}
+
{{ t('portal.pihole.allowed') }}
+ +
{{ t('portal.pihole.foot') }}
diff --git a/tests/pihole_portal_scope_markup.test.js b/tests/pihole_portal_scope_markup.test.js new file mode 100644 index 00000000..1d927f29 --- /dev/null +++ b/tests/pihole_portal_scope_markup.test.js @@ -0,0 +1,12 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +test('portal.njk has a scope segment switcher in the pihole card', () => { + const html = fs.readFileSync(path.join(__dirname,'..','templates','portal','portal.njk'),'utf8'); + assert.ok(/data-scope=["']device["']/.test(html) && /data-scope=["']owner["']/.test(html) && /data-scope=["']household["']/.test(html), 'missing scope buttons'); + assert.ok(/id=["']piholeSeg["']/.test(html), 'missing #piholeSeg'); + assert.ok(html.includes('portal.pihole.scope_device') && html.includes('portal.pihole.scope_owner') && html.includes('portal.pihole.scope_household'), 'missing scope i18n'); + // cache-bust fix (Step 3e): portal.js must be versioned like portal.css, else stale cached JS leaves the card on placeholders + assert.ok(/\/js\/portal\.js\?v=/.test(html), 'portal.js script tag missing ?v= cache-bust'); +}); From cafacea0db49411ee092c3d8ae638338530bde0e Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:51:12 +0200 Subject: [PATCH 10/11] fix(portal): keep pihole switcher reachable when owner/household scope is unavailable --- public/js/portal.js | 2 +- tests/pihole_portal_scope_js.test.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/public/js/portal.js b/public/js/portal.js index 5b5f8ceb..54290409 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -369,7 +369,7 @@ var bodyEl = card.querySelector('.pihole-body'); if (!body.ok || body.data === null) { var reason = body.reason; - if (reason === 'unavailable') { card.style.display = 'none'; return; } + if (reason === 'unavailable' && scope === 'device') { card.style.display = 'none'; return; } if (bodyEl) bodyEl.style.display = 'none'; if (msg) { msg.replaceChildren(); diff --git a/tests/pihole_portal_scope_js.test.js b/tests/pihole_portal_scope_js.test.js index 9e947e2b..14e013dd 100644 --- a/tests/pihole_portal_scope_js.test.js +++ b/tests/pihole_portal_scope_js.test.js @@ -14,4 +14,6 @@ test('portal.js wires scope switching to the 3 endpoints + login affordance, DOM // DOM-safety: the login affordance must NOT be built by concatenating i18n text into innerHTML assert.ok(!/innerHTML\s*[+=][^;]*PT\.pihole(NoOwner|LoginRequired)/.test(js), 'innerHTML + PT i18n (XSS risk)'); assert.ok(!/innerHTML\s*[+=][^;]*['"]\/login['"]/.test(js), 'login href via innerHTML'); + // card-hide on unavailable must be gated to device scope only (owner/household must keep card+switcher visible) + assert.ok(/reason\s*===\s*['"]unavailable['"]\s*&&\s*scope\s*===\s*['"]device['"]/.test(js), 'unavailable card-hide must be gated to device scope'); }); From a27323b9887b4da9fdd0b737424d5273f8550298 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:59:55 +0200 Subject: [PATCH 11/11] test(portal): retarget TP2a widget-ui leak/i18n guard to hydratePiholeScope The TP2b 8a refactor moved the device render logic out of hydratePihole() (now a thin segment-wiring delegator to hydratePiholeScope('device')); the PT-i18n usage and raw-field leak guard now live in hydratePiholeScope. The guard's intent is preserved by inspecting that function. CI gate (the file was not in the local task subsets). --- tests/pihole_portal_widget_ui.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/pihole_portal_widget_ui.test.js b/tests/pihole_portal_widget_ui.test.js index 0b8e68f3..364c3546 100644 --- a/tests/pihole_portal_widget_ui.test.js +++ b/tests/pihole_portal_widget_ui.test.js @@ -16,13 +16,16 @@ test('portal.js has hydratePihole wired to the endpoint + DOM guard', () => { assert.ok(/querySelector\(\s*['"]\.pihole-widget['"]\s*\)/.test(js), 'no DOM guard on .pihole-widget'); assert.ok(/hydratePihole\(\);/.test(js), 'hydratePihole not called at boot'); }); -test('hydratePihole uses PT for i18n (not undefined I18N) and leaks no raw fields', () => { +test('hydratePiholeScope uses PT for i18n (not undefined I18N) and leaks no raw fields', () => { + // TP2b refactor: the render logic that TP2a kept in hydratePihole() now lives in + // hydratePiholeScope(scope); hydratePihole() is a thin delegator that wires the + // segment switcher and calls hydratePiholeScope('device'). Inspect the render fn. const js = fs.readFileSync(path.join(__dirname,'..','public','js','portal.js'),'utf8'); - const m = js.match(/function hydratePihole\([\s\S]*?\n \}/); - assert.ok(m, 'hydratePihole not found'); + const m = js.match(/function hydratePiholeScope\([\s\S]*?\n \}/); + assert.ok(m, 'hydratePiholeScope not found'); const body = m[0]; assert.ok(/\bPT\[/.test(body), 'must use PT[key] (the real i18n object, portal.js:14)'); assert.ok(!/\bI18N\b/.test(body), 'I18N is undefined in portal.js — use PT'); // client-side structural leak guard (spec §6 test 3, second half): only whitelisted fields - assert.ok(!/\.topClients\b|\.clients\b|\.ip\b/.test(body), 'hydratePihole references a raw cache field'); + assert.ok(!/\.topClients\b|\.clients\b|\.ip\b/.test(body), 'hydratePiholeScope references a raw cache field'); });