Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 110 additions & 17 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,37 +347,130 @@
}

// ─── 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' && scope === 'device') { 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)

// ── 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);
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 ───────────────────────────────────────────────────────────────────
Expand Down
8 changes: 6 additions & 2 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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,
Expand All @@ -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 () {
Expand All @@ -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,
});
},
});
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -2004,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",
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -2004,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",
Expand Down
40 changes: 40 additions & 0 deletions src/middleware/portalOwner.js
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 42 additions & 0 deletions src/routes/api/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,46 @@ 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' });
}
});

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;
12 changes: 8 additions & 4 deletions src/routes/api/settings/portal.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading