aggressive data collection :PP

This commit is contained in:
2026-05-14 23:07:30 +01:00
parent ef10da8b0b
commit fe2e2751d5
4 changed files with 503 additions and 73 deletions
+14 -10
View File
@@ -86,27 +86,31 @@ table automatically.
## Viewer analytics ## 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 allows analytics, the browser sends page-view and heartbeat events to
`POST /api/viewers/event`. The public `/viewers` page reads `GET /api/viewers` `POST /api/viewers/event`. Visitors can choose whether to include browser/device,
and shows active pages, client/browser information, 24-hour page totals, and screen, language/timezone, and referrer details. The public `/viewers` page reads
top pages. `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 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 default. Raw IP addresses and IP hashes are not stored in viewer analytics.
stores a salted IP hash for deduplication and abuse review. Set a unique salt in Withdrawing consent removes the local visitor ID and calls
production: `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 ```sh
ANALYTICS_DATABASE_FILE=viewers.sqlite ANALYTICS_DATABASE_FILE=viewers.sqlite
ANALYTICS_RETENTION_DAYS=30 ANALYTICS_RETENTION_DAYS=30
ANALYTICS_ACTIVE_WINDOW_SECONDS=75 ANALYTICS_ACTIVE_WINDOW_SECONDS=75
ANALYTICS_SALT=replace-with-a-random-secret
``` ```
This is an implementation aid, not legal advice. For production GDPR compliance, This is an implementation aid, not legal advice. For production GDPR compliance,
publish a privacy notice that matches the configured retention period and data keep the `/privacy` page aligned with the configured retention period, hosting
fields, and make sure the configured salt is secret. setup, and actual data fields.
## GitHub webhook ## GitHub webhook
-1
View File
@@ -10,7 +10,6 @@ UPTIME_HISTORY_LIMIT=336
ANALYTICS_DATABASE_FILE=viewers.sqlite ANALYTICS_DATABASE_FILE=viewers.sqlite
ANALYTICS_RETENTION_DAYS=30 ANALYTICS_RETENTION_DAYS=30
ANALYTICS_ACTIVE_WINDOW_SECONDS=75 ANALYTICS_ACTIVE_WINDOW_SECONDS=75
ANALYTICS_SALT=change-me-viewer-salt
API_CACHE_TTL_MS=15000 API_CACHE_TTL_MS=15000
API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
+54 -11
View File
@@ -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_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite'
const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30) 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_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_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_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) 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' return req.socket.remoteAddress || 'unknown'
} }
function hashIp(ip) {
return crypto.createHash('sha256').update(`${ANALYTICS_SALT}:${ip}`).digest('hex')
}
function sanitizeText(value, maxLength = 200) { function sanitizeText(value, maxLength = 200) {
return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength) return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength)
} }
@@ -458,20 +453,24 @@ function recordViewerEvent(req, payload) {
purgeOldAnalytics(db) purgeOldAnalytics(db)
const serverClient = parseClient(req.headers['user-agent'] || '') const serverClient = parseClient(req.headers['user-agent'] || '')
const shareUserAgent = payload.user_agent !== 'Not shared'
const event = { const event = {
visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(), visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(),
session_id: sanitizeText(payload.session_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) event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type)
? payload.event_type ? payload.event_type
: 'heartbeat', : 'heartbeat',
page_path: sanitizePath(payload.page_path), page_path: sanitizePath(payload.page_path),
page_title: sanitizeText(payload.page_title, 160), page_title: sanitizeText(payload.page_title, 160),
referrer: sanitizeText(payload.referrer, 300), referrer: sanitizeText(payload.referrer, 300),
user_agent: sanitizeText(req.headers['user-agent'] || payload.user_agent, 500), user_agent: sanitizeText(
browser: sanitizeText(payload.browser || serverClient.browser, 80), shareUserAgent ? payload.user_agent || req.headers['user-agent'] : 'Not shared',
os: sanitizeText(payload.os || serverClient.os, 80), 500,
device: sanitizeText(payload.device || serverClient.device, 80), ),
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), screen: sanitizeText(payload.screen, 40),
language: sanitizeText(payload.language, 40), language: sanitizeText(payload.language, 40),
timezone: sanitizeText(payload.timezone, 80), timezone: sanitizeText(payload.timezone, 80),
@@ -515,6 +514,35 @@ function recordViewerEvent(req, payload) {
`).run({ ...event, now }) `).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() { function viewerDashboard() {
const db = ensureAnalyticsDb() const db = ensureAnalyticsDb()
purgeOldAnalytics(db) purgeOldAnalytics(db)
@@ -585,7 +613,7 @@ function viewerDashboard() {
}, },
privacy: { privacy: {
retention_days: ANALYTICS_RETENTION_DAYS, retention_days: ANALYTICS_RETENTION_DAYS,
stores_ip_hashes: true, stores_ip_hashes: false,
exposes_raw_ip: false, exposes_raw_ip: false,
}, },
} }
@@ -801,6 +829,21 @@ const server = http.createServer((req, res) => {
return 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/')) { if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
sendJson(res, 403, { error: 'CORS requests are not allowed' }) sendJson(res, 403, { error: 'CORS requests are not allowed' })
return return
+435 -51
View File
@@ -13,6 +13,7 @@ const apiEndpoints = {
uptime: '/api/uptime', uptime: '/api/uptime',
viewers: '/api/viewers', viewers: '/api/viewers',
viewerEvent: '/api/viewers/event', viewerEvent: '/api/viewers/event',
viewerDelete: '/api/viewers/delete',
teams: '/api/tss/leaderboard/teams?limit=100', teams: '/api/tss/leaderboard/teams?limit=100',
teamsHealth: '/api/tss/leaderboard/teams?limit=1', teamsHealth: '/api/tss/leaderboard/teams?limit=1',
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
@@ -29,7 +30,20 @@ const navItems = [
] ]
const analyticsConsentKey = 'tssbot.analyticsConsent' const analyticsConsentKey = 'tssbot.analyticsConsent'
const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
const analyticsVisitorKey = 'tssbot.analyticsVisitor' 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) { async function fetchJson(path, signal) {
const response = await fetch(path, { const response = await fetch(path, {
@@ -50,6 +64,7 @@ function parseRoute(pathname = window.location.pathname) {
if (pathname === '/teams') return { page: 'teams', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' }
if (pathname === '/uptime') return { page: 'uptime', teamName: '' } if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
if (pathname.startsWith('/teams/')) { if (pathname.startsWith('/teams/')) {
const teamName = decodeURIComponent(pathname.slice('/teams/'.length)) const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
return { page: 'team', teamName } return { page: 'team', teamName }
@@ -75,14 +90,107 @@ function bestTeamName(team) {
return team?.tag_name || team?.short_name || team?.long_name || '' 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 { 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 { } catch {
return '' 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) { function stableId(storageKey) {
try { try {
const existing = window.localStorage.getItem(storageKey) 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 = const analyticsSessionId =
window.crypto?.randomUUID?.() || window.crypto?.randomUUID?.() ||
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` `${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 === 'battle-logs') return 'Battle Logs'
if (route.page === 'uptime') return 'Uptime' if (route.page === 'uptime') return 'Uptime'
if (route.page === 'viewers') return 'viewers' if (route.page === 'viewers') return 'viewers'
if (route.page === 'privacy') return 'Privacy notice'
return 'Home' return 'Home'
} }
@@ -193,7 +318,7 @@ function App() {
const [live, setLive] = useState({ status: 'idle', data: null, error: null }) const [live, setLive] = useState({ status: 'idle', data: null, error: null })
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, 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 [teamQuery, setTeamQuery] = useState('')
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' }) const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
const [profile, setProfile] = useState({ const [profile, setProfile] = useState({
@@ -231,16 +356,22 @@ function App() {
? "Uptime | Toothless' TSS Bot" ? "Uptime | Toothless' TSS Bot"
: route.page === 'viewers' : route.page === 'viewers'
? "viewers | Toothless' TSS Bot" ? "viewers | Toothless' TSS Bot"
: route.page === 'privacy'
? "Privacy notice | Toothless' TSS Bot"
: "Toothless' TSS Bot" : "Toothless' TSS Bot"
document.title = title document.title = title
}, [route.page, route.teamName]) }, [route.page, route.teamName])
useEffect(() => { useEffect(() => {
if (analyticsConsent !== 'analytics') return if (!analyticsPreferences.analytics) return
const visitorId = stableId(analyticsVisitorKey) const visitorId = stableId(analyticsVisitorKey)
let stopped = false let stopped = false
const deviceDetails = analyticsPreferences.device
const displayDetails = analyticsPreferences.display
const localeDetails = analyticsPreferences.locale
const referrerDetails = analyticsPreferences.referrer
function sendViewerEvent(eventType) { function sendViewerEvent(eventType) {
if (stopped) return if (stopped) return
@@ -252,17 +383,20 @@ function App() {
session_id: analyticsSessionId, session_id: analyticsSessionId,
page_path: window.location.pathname, page_path: window.location.pathname,
page_title: routeLabel(route), page_title: routeLabel(route),
referrer: document.referrer, referrer: referrerDetails ? document.referrer : '',
browser: browserName(), user_agent: deviceDetails ? navigator.userAgent : 'Not shared',
os: operatingSystem(), browser: deviceDetails ? browserName() : 'Not shared',
device: deviceType(), os: deviceDetails ? operatingSystem() : 'Not shared',
screen: `${window.screen.width}x${window.screen.height}`, device: deviceDetails ? deviceType() : 'Not shared',
language: navigator.language, screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, language: localeDetails ? navigator.language : '',
metadata: { timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
color_depth: window.screen.colorDepth, metadata: displayDetails
viewport: `${window.innerWidth}x${window.innerHeight}`, ? {
}, color_depth: window.screen.colorDepth,
viewport: `${window.innerWidth}x${window.innerHeight}`,
}
: {},
} }
fetch(apiEndpoints.viewerEvent, { fetch(apiEndpoints.viewerEvent, {
@@ -285,7 +419,7 @@ function App() {
window.clearInterval(timer) window.clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
} }
}, [analyticsConsent, route]) }, [analyticsPreferences, route])
useEffect(() => { useEffect(() => {
const onKeyDown = (event) => { const onKeyDown = (event) => {
@@ -633,13 +767,26 @@ function App() {
} }
} }
function chooseAnalyticsConsent(value) { function chooseAnalyticsConsent(preferences) {
try { const previousVisitorId = storedVisitorId(analyticsVisitorKey)
window.localStorage.setItem(analyticsConsentKey, value) const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
} catch {
// Local storage can be blocked; the in-memory choice still controls this session. 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 = const activeNavPath =
@@ -716,9 +863,10 @@ function App() {
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null} {route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null} {route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null} {route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
{route.page === 'privacy' ? <PrivacyPage /> : null}
</section> </section>
<Footer navigate={navigate} /> <Footer navigate={navigate} />
<ConsentBanner consent={analyticsConsent} onChoose={chooseAnalyticsConsent} /> <ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
</main> </main>
) )
} }
@@ -743,57 +891,293 @@ function Footer({ navigate }) {
> >
viewers viewers
</button> </button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/privacy')}
type="button"
>
Privacy
</button>
</div> </div>
</div> </div>
</footer> </footer>
) )
} }
function ConsentBanner({ consent, onChoose }) { function ConsentBanner({ preferences, onChoose }) {
if (consent) { 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 (
<button <button
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:text-text" className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:text-text"
onClick={() => onChoose('')} onClick={() => {
setIsConfiguring(true)
setIsOpen(true)
}}
type="button" type="button"
> >
Privacy settings Cookie settings
</button> </button>
) )
} }
return ( return (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-fury-white shadow-[0_-8px_24px_rgba(0,0,0,0.08)]"> <div
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-8 lg:flex-row lg:items-center lg:justify-between"> aria-labelledby="consent-title"
<div className="max-w-3xl"> aria-modal="true"
<h2 className="text-base font-semibold">Analytics consent</h2> className="fixed inset-0 z-50 grid place-items-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
<p className="mt-1 text-sm text-text-soft"> role="dialog"
We can track page views, live viewing state, browser, device, screen size, >
language, timezone, referrer, and pseudonymous identifiers so the public <div className="w-full max-w-2xl rounded-md border border-border bg-fury-white p-5 text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)] sm:p-6">
viewers page works. Raw IP addresses are not shown publicly. <div className="max-w-xl">
<h2 id="consent-title" className="text-xl font-semibold">
Cookie and analytics settings
</h2>
<p className="mt-2 text-sm leading-6 text-text-soft">
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.
</p> </p>
{privacySignalEnabled ? (
<p className="mt-2 text-sm font-semibold text-text">
Your browser is signalling a privacy preference, so optional analytics stay
off unless you explicitly allow them.
</p>
) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2">
<button {isConfiguring ? (
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text" <>
onClick={() => onChoose('declined')} <div className="mt-5 space-y-3">
type="button" <PreferenceToggle
> checked
Decline description="Stores this consent choice so the popup does not appear on every page."
</button> disabled
<button label="Necessary cookie"
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua" />
onClick={() => onChoose('analytics')} <PreferenceToggle
type="button" checked={draft.analytics}
> description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
Allow analytics label="Viewer analytics"
</button> onChange={(value) => updateDraft('analytics', value)}
</div> />
<PreferenceToggle
checked={draft.device}
description="Includes browser, operating system, and broad device type."
disabled={!draft.analytics}
label="Browser and device details"
onChange={(value) => updateDraft('device', value)}
/>
<PreferenceToggle
checked={draft.display}
description="Includes screen size, viewport size, and colour depth."
disabled={!draft.analytics}
label="Screen details"
onChange={(value) => updateDraft('display', value)}
/>
<PreferenceToggle
checked={draft.locale}
description="Includes browser language and timezone."
disabled={!draft.analytics}
label="Language and timezone"
onChange={(value) => updateDraft('locale', value)}
/>
<PreferenceToggle
checked={draft.referrer}
description="Includes the page that linked you here when the browser provides it."
disabled={!draft.analytics}
label="Referrer"
onChange={(value) => updateDraft('referrer', value)}
/>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
type="button"
>
Decline all
</button>
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
onClick={() => savePreferences(draft)}
type="button"
>
Save choices
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() =>
savePreferences({
chosen: true,
analytics: true,
device: true,
display: true,
locale: true,
referrer: true,
version: analyticsConsentVersion,
})
}
type="button"
>
Allow all
</button>
</div>
</>
) : (
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
onClick={() => setIsConfiguring(true)}
type="button"
>
Configure
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() =>
savePreferences({
chosen: true,
analytics: true,
device: true,
display: true,
locale: true,
referrer: true,
version: analyticsConsentVersion,
})
}
type="button"
>
Allow all
</button>
</div>
)}
</div> </div>
</div> </div>
) )
} }
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
return (
<label
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
disabled ? 'opacity-65' : 'cursor-pointer'
}`}
>
<input
checked={checked}
className="mt-1 h-4 w-4 accent-fury-cyan"
disabled={disabled}
onChange={(event) => onChange?.(event.target.checked)}
type="checkbox"
/>
<span>
<span className="block text-sm font-semibold text-text">{label}</span>
<span className="mt-1 block text-xs leading-5 text-text-soft">{description}</span>
</span>
</label>
)
}
function PrivacyPage() {
return (
<section className="mx-auto max-w-4xl py-12">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Privacy notice
</p>
<h1 className="mt-2 text-4xl font-bold">How Toothless&apos; TSS Bot handles data</h1>
<p className="mt-3 text-text-soft">
Controller: Heidi. Contact: Discord <span className="font-semibold text-text">clippiii</span>.
</p>
</div>
<div className="mt-8 grid gap-6 text-sm leading-6 text-text-soft">
<PrivacySection title="What we collect">
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.
</PrivacySection>
<PrivacySection title="Why we collect it">
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.
</PrivacySection>
<PrivacySection title="Legal basis">
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.
</PrivacySection>
<PrivacySection title="Retention and deletion">
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.
</PrivacySection>
<PrivacySection title="Sharing and transfers">
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.
</PrivacySection>
<PrivacySection title="Your rights">
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.
</PrivacySection>
</div>
</section>
)
}
function PrivacySection({ children, title }) {
return (
<article className="border-l border-border pl-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<p className="mt-2">{children}</p>
</article>
)
}
function Landing({ live, matches, navigate }) { function Landing({ live, matches, navigate }) {
const treeRef = useRef(null) const treeRef = useRef(null)