{title}
+{children}
+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' ?
- 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}+ Privacy notice +
++ Controller: Heidi. Contact: Discord clippiii. +
+{children}
+