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
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
-1
View File
@@ -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
+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_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
+435 -51
View File
@@ -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' ? <BattleLogsPage live={live} matches={matches} /> : null}
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
{route.page === 'privacy' ? <PrivacyPage /> : null}
</section>
<Footer navigate={navigate} />
<ConsentBanner consent={analyticsConsent} onChoose={chooseAnalyticsConsent} />
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
</main>
)
}
@@ -743,57 +891,293 @@ function Footer({ navigate }) {
>
viewers
</button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/privacy')}
type="button"
>
Privacy
</button>
</div>
</div>
</footer>
)
}
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 (
<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"
onClick={() => onChoose('')}
onClick={() => {
setIsConfiguring(true)
setIsOpen(true)
}}
type="button"
>
Privacy settings
Cookie settings
</button>
)
}
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 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">
<div className="max-w-3xl">
<h2 className="text-base font-semibold">Analytics consent</h2>
<p className="mt-1 text-sm text-text-soft">
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.
<div
aria-labelledby="consent-title"
aria-modal="true"
className="fixed inset-0 z-50 grid place-items-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
role="dialog"
>
<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">
<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>
{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 className="flex shrink-0 flex-wrap gap-2">
<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={() => onChoose('declined')}
type="button"
>
Decline
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() => onChoose('analytics')}
type="button"
>
Allow analytics
</button>
</div>
{isConfiguring ? (
<>
<div className="mt-5 space-y-3">
<PreferenceToggle
checked
description="Stores this consent choice so the popup does not appear on every page."
disabled
label="Necessary cookie"
/>
<PreferenceToggle
checked={draft.analytics}
description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
label="Viewer analytics"
onChange={(value) => updateDraft('analytics', value)}
/>
<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>
)
}
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 }) {
const treeRef = useRef(null)