diff --git a/README.md b/README.md index c132d02..bb73c00 100644 --- a/README.md +++ b/README.md @@ -86,27 +86,31 @@ table automatically. ## Viewer analytics -The site shows a GDPR-style consent banner before analytics start. If a visitor +The site shows a centered cookie notice before analytics start. The first screen +offers `Allow all` or `Configure`; detailed settings only appear after +`Configure`. A necessary cookie remembers the visitor's choice. If a visitor allows analytics, the browser sends page-view and heartbeat events to -`POST /api/viewers/event`. The public `/viewers` page reads `GET /api/viewers` -and shows active pages, client/browser information, 24-hour page totals, and -top pages. +`POST /api/viewers/event`. Visitors can choose whether to include browser/device, +screen, language/timezone, and referrer details. The public `/viewers` page reads +`GET /api/viewers` and shows active pages, 24-hour page totals, top pages, and +any consented client details. Viewer analytics are stored in SQLite under the same `UPTIME_STORAGE_DIR` by -default. Raw IP addresses are not stored in the public response; the server -stores a salted IP hash for deduplication and abuse review. Set a unique salt in -production: +default. Raw IP addresses and IP hashes are not stored in viewer analytics. +Withdrawing consent removes the local visitor ID and calls +`POST /api/viewers/delete` to delete matching visitor/session analytics records. +The `/privacy` page lists the controller, contact route, purposes, retention, +rights, and complaint routes. ```sh ANALYTICS_DATABASE_FILE=viewers.sqlite ANALYTICS_RETENTION_DAYS=30 ANALYTICS_ACTIVE_WINDOW_SECONDS=75 -ANALYTICS_SALT=replace-with-a-random-secret ``` This is an implementation aid, not legal advice. For production GDPR compliance, -publish a privacy notice that matches the configured retention period and data -fields, and make sure the configured salt is secret. +keep the `/privacy` page aligned with the configured retention period, hosting +setup, and actual data fields. ## GitHub webhook diff --git a/example.env b/example.env index 9d59bd3..89e3197 100644 --- a/example.env +++ b/example.env @@ -10,7 +10,6 @@ UPTIME_HISTORY_LIMIT=336 ANALYTICS_DATABASE_FILE=viewers.sqlite ANALYTICS_RETENTION_DAYS=30 ANALYTICS_ACTIVE_WINDOW_SECONDS=75 -ANALYTICS_SALT=change-me-viewer-salt API_CACHE_TTL_MS=15000 API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 diff --git a/server.cjs b/server.cjs index 4b49ebd..561eac3 100644 --- a/server.cjs +++ b/server.cjs @@ -46,7 +46,6 @@ const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336) const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite' const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30) const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75) -const ANALYTICS_SALT = process.env.ANALYTICS_SALT || 'change-me-viewer-salt' const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) @@ -376,10 +375,6 @@ function clientIp(req) { return req.socket.remoteAddress || 'unknown' } -function hashIp(ip) { - return crypto.createHash('sha256').update(`${ANALYTICS_SALT}:${ip}`).digest('hex') -} - function sanitizeText(value, maxLength = 200) { return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength) } @@ -458,20 +453,24 @@ function recordViewerEvent(req, payload) { purgeOldAnalytics(db) const serverClient = parseClient(req.headers['user-agent'] || '') + const shareUserAgent = payload.user_agent !== 'Not shared' const event = { visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(), session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(), - ip_hash: hashIp(clientIp(req)), + ip_hash: '', event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type) ? payload.event_type : 'heartbeat', page_path: sanitizePath(payload.page_path), page_title: sanitizeText(payload.page_title, 160), referrer: sanitizeText(payload.referrer, 300), - user_agent: sanitizeText(req.headers['user-agent'] || payload.user_agent, 500), - browser: sanitizeText(payload.browser || serverClient.browser, 80), - os: sanitizeText(payload.os || serverClient.os, 80), - device: sanitizeText(payload.device || serverClient.device, 80), + user_agent: sanitizeText( + shareUserAgent ? payload.user_agent || req.headers['user-agent'] : 'Not shared', + 500, + ), + browser: sanitizeText(payload.browser || (shareUserAgent ? serverClient.browser : 'Not shared'), 80), + os: sanitizeText(payload.os || (shareUserAgent ? serverClient.os : 'Not shared'), 80), + device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80), screen: sanitizeText(payload.screen, 40), language: sanitizeText(payload.language, 40), timezone: sanitizeText(payload.timezone, 80), @@ -515,6 +514,35 @@ function recordViewerEvent(req, payload) { `).run({ ...event, now }) } +function deleteViewerData(payload) { + const db = ensureAnalyticsDb() + const visitorId = sanitizeText(payload.visitor_id, 80) + const sessionId = sanitizeText(payload.session_id, 80) + + if (!visitorId && !sessionId) { + throw new Error('A visitor or session identifier is required') + } + + const result = db.transaction(() => { + let eventsDeleted = 0 + let sessionsDeleted = 0 + + if (visitorId) { + eventsDeleted += db.prepare('delete from viewer_events where visitor_id = ?').run(visitorId).changes + sessionsDeleted += db.prepare('delete from active_viewers where visitor_id = ?').run(visitorId).changes + } + + if (sessionId) { + eventsDeleted += db.prepare('delete from viewer_events where session_id = ?').run(sessionId).changes + sessionsDeleted += db.prepare('delete from active_viewers where session_id = ?').run(sessionId).changes + } + + return { events_deleted: eventsDeleted, sessions_deleted: sessionsDeleted } + })() + + return result +} + function viewerDashboard() { const db = ensureAnalyticsDb() purgeOldAnalytics(db) @@ -585,7 +613,7 @@ function viewerDashboard() { }, privacy: { retention_days: ANALYTICS_RETENTION_DAYS, - stores_ip_hashes: true, + stores_ip_hashes: false, exposes_raw_ip: false, }, } @@ -801,6 +829,21 @@ const server = http.createServer((req, res) => { return } + if (req.method === 'POST' && req.url === '/api/viewers/delete') { + if (!isSameOriginRequest(req)) { + sendJson(res, 403, { error: 'Analytics deletion is restricted to this site' }) + return + } + + readJsonBody(req) + .then((payload) => { + const result = deleteViewerData(payload) + sendJson(res, 200, result) + }) + .catch((error) => sendJson(res, 400, { error: error.message })) + return + } + if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { sendJson(res, 403, { error: 'CORS requests are not allowed' }) return diff --git a/src/App.jsx b/src/App.jsx index 1489e53..4f24c89 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ const apiEndpoints = { uptime: '/api/uptime', viewers: '/api/viewers', viewerEvent: '/api/viewers/event', + viewerDelete: '/api/viewers/delete', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, @@ -29,7 +30,20 @@ const navItems = [ ] const analyticsConsentKey = 'tssbot.analyticsConsent' +const analyticsPreferencesKey = 'tssbot.analyticsPreferences' +const analyticsPreferencesCookie = 'tssbot_analytics_preferences' const analyticsVisitorKey = 'tssbot.analyticsVisitor' +const analyticsConsentVersion = 2 + +const defaultAnalyticsPreferences = { + chosen: false, + analytics: false, + device: false, + display: false, + locale: false, + referrer: false, + version: analyticsConsentVersion, +} async function fetchJson(path, signal) { const response = await fetch(path, { @@ -50,6 +64,7 @@ function parseRoute(pathname = window.location.pathname) { if (pathname === '/teams') return { page: 'teams', teamName: '' } if (pathname === '/uptime') return { page: 'uptime', teamName: '' } if (pathname === '/viewers') return { page: 'viewers', teamName: '' } + if (pathname === '/privacy') return { page: 'privacy', teamName: '' } if (pathname.startsWith('/teams/')) { const teamName = decodeURIComponent(pathname.slice('/teams/'.length)) return { page: 'team', teamName } @@ -75,14 +90,107 @@ function bestTeamName(team) { return team?.tag_name || team?.short_name || team?.long_name || '' } -function storedConsent() { +function normalizeAnalyticsPreferences(value) { + if (!value || typeof value !== 'object') return { ...defaultAnalyticsPreferences } + + return { + chosen: Boolean(value.chosen) && value.version === analyticsConsentVersion, + analytics: Boolean(value.analytics), + device: Boolean(value.device), + display: Boolean(value.display), + locale: Boolean(value.locale), + referrer: Boolean(value.referrer), + version: analyticsConsentVersion, + } +} + +function browserPrivacySignalEnabled() { + return Boolean( + navigator.globalPrivacyControl || + navigator.doNotTrack === '1' || + window.doNotTrack === '1', + ) +} + +function readCookie(name) { try { - return window.localStorage.getItem(analyticsConsentKey) || '' + const match = document.cookie + .split('; ') + .find((item) => item.startsWith(`${encodeURIComponent(name)}=`)) + return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : '' } catch { return '' } } +function writeCookie(name, value) { + try { + const sixMonths = 60 * 60 * 24 * 180 + document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Max-Age=${sixMonths}; Path=/; SameSite=Lax` + } catch { + // Some browsers block cookies; local storage and in-memory state still work. + } +} + +function storedAnalyticsPreferences() { + const cookieValue = readCookie(analyticsPreferencesCookie) + if (cookieValue) { + try { + return normalizeAnalyticsPreferences(JSON.parse(cookieValue)) + } catch { + // Fall back through the older storage formats below. + } + } + + try { + const stored = window.localStorage.getItem(analyticsPreferencesKey) + if (stored) return normalizeAnalyticsPreferences(JSON.parse(stored)) + } catch { + // Fall back through the older consent flag below. + } + + try { + const legacyConsent = window.localStorage.getItem(analyticsConsentKey) || '' + if (legacyConsent === 'analytics') { + return { + ...defaultAnalyticsPreferences, + chosen: true, + analytics: true, + device: true, + display: true, + locale: true, + referrer: true, + } + } + if (legacyConsent === 'declined') { + return { ...defaultAnalyticsPreferences, chosen: true, analytics: false } + } + } catch { + // Use the default prompt state. + } + + return { ...defaultAnalyticsPreferences } +} + +function persistAnalyticsPreferences(preferences) { + const normalized = normalizeAnalyticsPreferences(preferences) + const serialized = JSON.stringify(normalized) + + writeCookie(analyticsPreferencesCookie, serialized) + + try { + window.localStorage.setItem(analyticsPreferencesKey, serialized) + window.localStorage.setItem( + analyticsConsentKey, + normalized.analytics ? 'analytics' : 'declined', + ) + } catch { + // Local storage can be blocked; the cookie and in-memory choice still apply. + } + + return normalized +} + function stableId(storageKey) { try { const existing = window.localStorage.getItem(storageKey) @@ -98,6 +206,22 @@ function stableId(storageKey) { } } +function storedVisitorId(storageKey) { + try { + return window.localStorage.getItem(storageKey) || '' + } catch { + return '' + } +} + +function forgetVisitorId(storageKey) { + try { + window.localStorage.removeItem(storageKey) + } catch { + // Storage may be unavailable; nothing else to clear locally. + } +} + const analyticsSessionId = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` @@ -135,6 +259,7 @@ function routeLabel(route) { if (route.page === 'battle-logs') return 'Battle Logs' if (route.page === 'uptime') return 'Uptime' if (route.page === 'viewers') return 'viewers' + if (route.page === 'privacy') return 'Privacy notice' return 'Home' } @@ -193,7 +318,7 @@ function App() { const [live, setLive] = useState({ status: 'idle', data: null, error: null }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null }) - const [analyticsConsent, setAnalyticsConsent] = useState(() => storedConsent()) + const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences()) const [teamQuery, setTeamQuery] = useState('') const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' }) const [profile, setProfile] = useState({ @@ -231,16 +356,22 @@ function App() { ? "Uptime | Toothless' TSS Bot" : route.page === 'viewers' ? "viewers | Toothless' TSS Bot" + : route.page === 'privacy' + ? "Privacy notice | Toothless' TSS Bot" : "Toothless' TSS Bot" document.title = title }, [route.page, route.teamName]) useEffect(() => { - if (analyticsConsent !== 'analytics') return + if (!analyticsPreferences.analytics) return const visitorId = stableId(analyticsVisitorKey) let stopped = false + const deviceDetails = analyticsPreferences.device + const displayDetails = analyticsPreferences.display + const localeDetails = analyticsPreferences.locale + const referrerDetails = analyticsPreferences.referrer function sendViewerEvent(eventType) { if (stopped) return @@ -252,17 +383,20 @@ function App() { session_id: analyticsSessionId, page_path: window.location.pathname, page_title: routeLabel(route), - referrer: document.referrer, - browser: browserName(), - os: operatingSystem(), - device: deviceType(), - screen: `${window.screen.width}x${window.screen.height}`, - language: navigator.language, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - metadata: { - color_depth: window.screen.colorDepth, - viewport: `${window.innerWidth}x${window.innerHeight}`, - }, + referrer: referrerDetails ? document.referrer : '', + user_agent: deviceDetails ? navigator.userAgent : 'Not shared', + browser: deviceDetails ? browserName() : 'Not shared', + os: deviceDetails ? operatingSystem() : 'Not shared', + device: deviceDetails ? deviceType() : 'Not shared', + screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '', + language: localeDetails ? navigator.language : '', + timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '', + metadata: displayDetails + ? { + color_depth: window.screen.colorDepth, + viewport: `${window.innerWidth}x${window.innerHeight}`, + } + : {}, } fetch(apiEndpoints.viewerEvent, { @@ -285,7 +419,7 @@ function App() { window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisibilityChange) } - }, [analyticsConsent, route]) + }, [analyticsPreferences, route]) useEffect(() => { const onKeyDown = (event) => { @@ -633,13 +767,26 @@ function App() { } } - function chooseAnalyticsConsent(value) { - try { - window.localStorage.setItem(analyticsConsentKey, value) - } catch { - // Local storage can be blocked; the in-memory choice still controls this session. + function chooseAnalyticsConsent(preferences) { + const previousVisitorId = storedVisitorId(analyticsVisitorKey) + const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true }) + + if (!nextPreferences.analytics) { + forgetVisitorId(analyticsVisitorKey) + if (previousVisitorId) { + fetch(apiEndpoints.viewerDelete, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + visitor_id: previousVisitorId, + session_id: analyticsSessionId, + }), + keepalive: true, + }).catch(() => {}) + } } - setAnalyticsConsent(value) + + setAnalyticsPreferences(nextPreferences) } const activeNavPath = @@ -716,9 +863,10 @@ function App() { {route.page === 'battle-logs' ? : null} {route.page === 'uptime' ? : null} {route.page === 'viewers' ? : null} + {route.page === 'privacy' ? : null}
- + ) } @@ -743,57 +891,293 @@ function Footer({ navigate }) { > viewers +
) } -function ConsentBanner({ consent, onChoose }) { - if (consent) { +function ConsentBanner({ preferences, onChoose }) { + const [isOpen, setIsOpen] = useState(() => !preferences.chosen) + const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen)) + const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences)) + const privacySignalEnabled = browserPrivacySignalEnabled() + + useEffect(() => { + setDraft(normalizeAnalyticsPreferences(preferences)) + if (!preferences.chosen) { + setIsOpen(true) + setIsConfiguring(false) + } + }, [preferences]) + + useEffect(() => { + if (!isOpen) return undefined + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = previousOverflow + } + }, [isOpen]) + + function updateDraft(key, value) { + setDraft((current) => ({ ...current, [key]: value })) + } + + function savePreferences(nextPreferences) { + onChoose(normalizeAnalyticsPreferences(nextPreferences)) + setIsOpen(false) + } + + if (!isOpen) { return ( ) } return ( -
-
-
-

Analytics consent

-

- We can track page views, live viewing state, browser, device, screen size, - language, timezone, referrer, and pseudonymous identifiers so the public - viewers page works. Raw IP addresses are not shown publicly. +

+
+
+ +

+ We use a necessary cookie to remember these choices. You can also allow + analytics for the public viewers page and choose which details are included.

+ {privacySignalEnabled ? ( +

+ Your browser is signalling a privacy preference, so optional analytics stay + off unless you explicitly allow them. +

+ ) : null}
-
- - -
+ + {isConfiguring ? ( + <> +
+ + updateDraft('analytics', value)} + /> + updateDraft('device', value)} + /> + updateDraft('display', value)} + /> + updateDraft('locale', value)} + /> + updateDraft('referrer', value)} + /> +
+ +
+ + + +
+ + ) : ( +
+ + +
+ )}
) } +function PreferenceToggle({ checked, description, disabled = false, label, onChange }) { + return ( + + ) +} + +function PrivacyPage() { + return ( +
+
+

+ Privacy notice +

+

How Toothless' TSS Bot handles data

+

+ Controller: Heidi. Contact: Discord clippiii. +

+
+ +
+ + Necessary cookies store your cookie and analytics choices. If you opt in to + viewer analytics, we collect page views, live viewing status, a pseudonymous + visitor ID, and a session ID. Optional settings control whether browser/device, + screen, language/timezone, and referrer details are included. + + + + Necessary cookies are used to remember your privacy choices. Optional viewer + analytics are used only to power the public viewers page, understand basic site + activity, and keep abuse low. + + + + Necessary cookies are used because they are needed to remember your consent + preferences. Optional analytics only run with your consent, and you can withdraw + that consent from the Cookie settings button at any time. + + + + Analytics events are automatically deleted after the configured retention period, + currently documented as 30 days by default. Active viewer sessions expire after a + short inactivity window. If you decline analytics after previously allowing them, + the site asks the server to delete records linked to your current visitor and + session IDs, and removes the local visitor ID from your browser. + + + + Viewer analytics are stored by this site and are not sold. Public viewer pages show + only consented analytics fields and never show raw IP addresses. Hosting providers + may process server logs as part of running the site. + + + + You can ask for access, correction, deletion, restriction, objection, portability, + or withdrawal of consent by contacting Heidi on Discord at clippiii. If you are in + the UK, you can also complain to the ICO. If you are in the EU or EEA, you can + complain to your local data protection authority. + +
+
+ ) +} + +function PrivacySection({ children, title }) { + return ( +
+

{title}

+

{children}

+
+ ) +} + function Landing({ live, matches, navigate }) { const treeRef = useRef(null)