1/* Shared by the four public websites. Run sync.mjs after editing. */ 2(function () { 3 'use strict'; 4 if (window.ForfeitPrivacy) return; 5 const VERSION = 1; 6 const KEY = 'forfeit_privacy_v1'; 7 const MAX_AGE = 183 * 86400000; 8 const domain = /(^|\.)overlord\.app$/.test(location.hostname) ? '; Domain=overlord.app' : 9 /(^|\.)forfeit\.app$/.test(location.hostname) ? '; Domain=forfeit.app' : ''; 10 let memory = null; 11 let memoryOnly = false; 12 let last = ''; 13 const listeners = new Set(); 14 const gpc = () => navigator.globalPrivacyControl === true; 15 function valid(value) { 16 return value && value.version === VERSION && typeof value.analytics === 'boolean' && 17 typeof value.marketing === 'boolean' && Number.isFinite(value.timestamp) && 18 value.timestamp <= Date.now() && Date.now() - value.timestamp < MAX_AGE; 19 } 20 function read() { 21 let value = memoryOnly ? memory : null; 22 try { 23 const cookie = document.cookie.split('; ').find(x => x.startsWith(KEY + '=')); 24 if (cookie) { 25 try { value = JSON.parse(decodeURIComponent(cookie.slice(KEY.length + 1))); } 26 catch (_) { value = null; } 27 } 28 } catch (_) { value = memory; } 29 if (!valid(value)) return {version: VERSION, timestamp: 0, analytics: false, marketing: false}; 30 return {...value, marketing: value.marketing && !gpc()}; 31 } 32 function allowed(category) { return read()[category] === true; } 33 const safeProperties = new Set(['step', 'platform', 'source', 'clicked_button', 'depth', 34 'step_number', 'total_steps', 'time_since_last_step_ms', 'value', 'currency', 'goal_type']); 35 function sanitize(properties) { 36 const result = {}; 37 for (const [key, value] of Object.entries(properties || {})) { 38 if (safeProperties.has(key) && ['string', 'number', 'boolean'].includes(typeof value)) { 39 result[key] = typeof value === 'string' ? value.slice(0, 100) : value; 40 } 41 } 42 return result; 43 } 44 function sanitizeAnalytics(properties) { 45 const result = sanitize(properties); 46 // Preserve only randomly generated SDK transport IDs, never old email IDs. 47 for (const key of ['distinct_id', '$session_id', '$window_id']) { 48 if (typeof properties?.[key] === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(properties[key])) result[key] = properties[key]; 49 } 50 if (typeof properties?.token === 'string' && /^phc_[a-zA-Z0-9]+$/.test(properties.token)) result.token = properties.token; 51 return result; 52 } 53 function clearOptional() { 54 const choice = read(); 55 const optional = {test: key => (!choice.analytics && /^(ph_|_ga|_gid|_gat)/.test(key)) || 56 (!choice.marketing && /^_fb[pc]/.test(key)) || 57 (!choice.marketing && /^(ov_utm|signup_utm)/.test(key)) || 58 (!choice.analytics && !choice.marketing && /^ov_(did|sid|initial_ref|last_step_ts)$/.test(key))}; 59 for (const name of ['localStorage', 'sessionStorage']) { 60 try { 61 const storage = window[name]; 62 Object.keys(storage).filter(k => optional.test(k)).forEach(k => storage.removeItem(k)); 63 } catch (_) {} 64 } 65 try { 66 for (const entry of document.cookie.split('; ')) { 67 const key = entry.split('=')[0]; 68 if (!optional.test(key)) continue; 69 for (const suffix of ['', domain, '; Domain=' + location.hostname]) { 70 document.cookie = key + '=; Max-Age=0; Path=/' + suffix; 71 } 72 } 73 } catch (_) {} 74 } 75 function apply() { 76 const choice = read(); 77 const serialized = JSON.stringify(choice); 78 if (serialized === last) return; 79 last = serialized; 80 window['ga-disable-G-NTT286BQ81'] = !choice.analytics; 81 if (!choice.analytics) { 82 window.posthog?.opt_out_capturing?.(); 83 window.posthog?.stopSessionRecording?.(); 84 } else window.posthog?.opt_in_capturing?.(); 85 if (!choice.analytics && Array.isArray(window.dataLayer)) window.dataLayer.length = 0; 86 if (!choice.marketing && Array.isArray(window.fbq?.queue)) { 87 window.fbq.queue = window.fbq.queue.filter(args => args[0] === 'init'); 88 } 89 window.fbq?.('consent', choice.marketing ? 'grant' : 'revoke'); 90 if (!choice.analytics || !choice.marketing) clearOptional(); 91 for (const callback of listeners) callback(choice); 92 document.querySelectorAll('script[data-consent]').forEach(original => { 93 if (!choice[original.dataset.consent] || original.dataset.loaded) return; 94 original.dataset.loaded = 'true'; 95 const script = document.createElement('script'); 96 if (original.dataset.src) script.src = original.dataset.src; 97 else script.textContent = original.textContent; 98 script.async = false;
99 original.after(script); 100 }); 101 document.querySelectorAll('iframe[data-consent-src]').forEach(frame => { 102 if (!frame.previousElementSibling?.hasAttribute('data-embed-notice')) { 103 const notice = document.createElement('p'); 104 notice.setAttribute('data-embed-notice', ''); 105 notice.append('This external video is disabled until you accept Analytics. '); 106 const link = document.createElement('a'); 107 link.href = frame.dataset.consentSrc; 108 link.textContent = 'Open the video on its provider website'; 109 link.rel = 'noreferrer'; 110 notice.append(link); 111 frame.before(notice); 112 } 113 frame.previousElementSibling.hidden = choice.analytics; 114 frame.hidden = !choice.analytics; 115 if (choice.analytics) { 116 if (frame.getAttribute('src') !== frame.dataset.consentSrc) frame.src = frame.dataset.consentSrc; 117 } else frame.removeAttribute('src'); 118 }); 119 window.dispatchEvent(new CustomEvent('forfeit:consent', {detail: choice})); 120 } 121 function save(analytics, marketing) { 122 memory = {version: VERSION, timestamp: Date.now(), analytics: !!analytics, marketing: !!marketing && !gpc()}; 123 try { 124 document.cookie = KEY + '=' + encodeURIComponent(JSON.stringify(memory)) + 125 '; Path=/; Max-Age=' + MAX_AGE / 1000 + '; SameSite=Lax' + domain + 126 (location.protocol === 'https:' ? '; Secure' : ''); 127 memoryOnly = !document.cookie.split('; ').some(value => value.startsWith(KEY + '=')); 128 } catch (_) { memoryOnly = true; } 129 try { localStorage.setItem(KEY, JSON.stringify(memory)); } catch (_) {} 130 apply(); 131 } 132 let dialog; 133 let returnFocus; 134 // Desktop gets a corner card that leaves the page usable, so it opens 135 // non-modal and there is no dimmed backdrop. Phones keep the centred modal. 136 // The pointer checks matter: a page without a viewport meta reports a ~980px 137 // width on a phone, which the width test alone would read as a desktop. 138 const corner = () => window.matchMedia('(min-width:641px) and (hover:hover) and (pointer:fine)').matches; 139 function open() { 140 if (!dialog) return; 141 returnFocus = document.activeElement; 142 const choice = read(); 143 dialog.querySelector('#privacy-analytics').checked = choice.analytics; 144 const marketing = dialog.querySelector('#privacy-marketing'); 145 marketing.checked = choice.marketing; 146 marketing.disabled = gpc(); 147 dialog.querySelector('#privacy-gpc').hidden = !gpc(); 148 if (!dialog.open) { 149 const inCorner = corner(); 150 dialog.classList.toggle('privacy-corner', inCorner); 151 if (inCorner) dialog.show(); else dialog.showModal(); 152 } 153 // Opening focuses the first control on its own, and Safari paints that as 154 // focus-visible, so a tap used to open the dialog with a blue box around 155 // the Cookie notice link. Focus the dialog itself instead. 156 dialog.focus(); 157 } 158 window.ForfeitPrivacy = {read, allowed, save, open, sanitize, sanitizeAnalytics, subscribe(callback) { 159 listeners.add(callback); 160 return () => listeners.delete(callback); 161 }}; 162 function mount() { 163 const style = document.createElement('style'); 164 style.textContent = ` 165 #privacy-dialog{box-sizing:border-box;width:min(560px,calc(100% - 32px));max-height:85dvh;overflow:auto;border:1px solid #666;border-radius:16px;padding:24px;background:var(--background,#fff);color:var(--foreground,#111);font:16px/1.5 Figtree,sans-serif} 166 #privacy-dialog::backdrop{background:rgba(0,0,0,.5)} 167 #privacy-dialog h2{font-size:24px;margin:0 0 12px;color:inherit} 168 #privacy-dialog p,#privacy-dialog label{color:inherit;font-size:16px;line-height:1.5} 169 #privacy-dialog label{display:block;margin:12px 0} 170 #privacy-dialog input{appearance:auto;width:20px;height:20px;vertical-align:middle;margin-right:8px} 171 #privacy-dialog button,#privacy-choices{min-height:44px;border:1px solid currentColor;border-radius:12px;padding:10px 14px;background:var(--background,#fff);color:var(--foreground,#111);font:inherit;cursor:pointer} 172 #privacy-dialog .privacy-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px} 173 #privacy-dialog a{color:inherit;text-decoration:underline} 174 #privacy-choices{position:static;display:inline-block;margin:0 8px;border:0;padding:8px 4px;background:transparent;color:inherit;font:inherit;text-decoration:underline;vertical-align:baseline} 175 #privacy-footer{text-align:center;padding:12px;color:inherit;font:12px Figtree,sans-serif} 176 footer nav #privacy-choices{padding-left:0;padding-right:0;text-decoration:none} 177 #privacy-dialog.privacy-corner{position:fixed;inset:16px 16px auto auto;margin:0;width:min(400px,calc(100vw - 32px));max-height:calc(100dvh - 32px);z-index:2147483647} 178 /* 179 * The focus ring is for keyboard users. Opening the dialog focuses it, 180 * and browsers can count that as focus-visible, so a tap opened the 181 * dialog with a blue box on it. The ring is off by default and gated on 182 * the last input being a key instead. 183 */ 184 #privacy-dialog:focus,#privacy-dialog :focus-visible,#privacy-choices:focus-visible{outline:none} 185 html[data-privacy-keyboard] #privacy-dialog :focus-visible, 186 html[data-privacy-keyboard] #privacy-choices:focus-visible{outline:3px solid #0072c6;outline-offset:3px} 187 @media(prefers-reduced-motion:reduce){#privacy-dialog *{scroll-behavior:auto}} 188 `; 189 // Which modality the person is using, so the focus ring above only shows 190 // for a keyboard. Capture phase: a handler inside the dialog must not be 191 // able to stop it, or the ring would stick to whatever it last saw.
192 const keyboardKeys = new Set(['Tab', 'Enter', ' ', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']); 193 document.addEventListener('keydown', event => { 194 if (keyboardKeys.has(event.key)) document.documentElement.setAttribute('data-privacy-keyboard', ''); 195 }, true); 196 for (const type of ['pointerdown', 'touchstart', 'mousedown']) { 197 document.addEventListener(type, () => document.documentElement.removeAttribute('data-privacy-keyboard'), true); 198 } 199 document.head.append(style); 200 const existingStylesheet = document.querySelector('link[href="/privacy-ui.css"]'); 201 if (existingStylesheet) document.head.append(existingStylesheet); 202 else { 203 const stylesheet = document.createElement('link'); 204 stylesheet.rel = 'stylesheet'; 205 stylesheet.href = '/privacy-ui.css'; 206 document.head.append(stylesheet); 207 } 208 dialog = document.createElement('dialog'); 209 dialog.id = 'privacy-dialog'; 210 dialog.setAttribute('aria-labelledby', 'privacy-title'); 211 dialog.tabIndex = -1; 212 dialog.addEventListener('keydown', event => { 213 // A non-modal dialog gets no cancel event, so Escape is handled here. 214 if (event.key === 'Escape' && dialog.classList.contains('privacy-corner')) { 215 event.preventDefault(); 216 if (!read().timestamp) save(false, false); 217 dialog.close(); 218 return; 219 } 220 if (event.key !== 'Tab') return; 221 const controls = [...dialog.querySelectorAll('a[href],button,input')] 222 .filter(control => !control.disabled && control.getClientRects().length); 223 if (!controls.length) return; 224 const index = controls.indexOf(document.activeElement); 225 event.preventDefault(); 226 controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus(); 227 }); 228 dialog.innerHTML = `<h2 id="privacy-title">Your privacy choices</h2> 229 <p>Necessary storage keeps sign-in, security and your preferences working. Optional tracking stays off until you accept. You can change your choice at any time using Privacy Choices in the footer or app settings. Closing this popup without accepting keeps optional tracking off.</p> 230 <p>Analytics measures website use through PostHog and Google Analytics. Session recording is currently disabled. Marketing measures advertising through Meta.</p> 231 <p><a href="/cookies">Cookie notice</a>, <a href="/privacy">Privacy notice</a> and <a href="/accessibility">Accessibility Statement</a></p> 232 <div class="privacy-actions"><button type="button" data-choice="all">Accept All</button><button type="button" data-choice="none">Reject All</button><button type="button" data-choice="preferences">Choose Preferences</button></div> 233 <div id="privacy-preferences" hidden><label><input type="checkbox" checked disabled>Necessary (always enabled)</label><label><input id="privacy-analytics" type="checkbox">Analytics</label><label><input id="privacy-marketing" type="checkbox">Marketing</label><p id="privacy-gpc" hidden>Your browser's Global Privacy Control keeps marketing disabled.</p><button type="button" data-choice="save">Save Preferences</button></div>`; 234 dialog.addEventListener('click', event => { 235 const choice = event.target.dataset.choice; 236 if (!choice) return; 237 if (choice === 'preferences') { 238 dialog.querySelector('#privacy-preferences').hidden = false; 239 dialog.querySelector('#privacy-analytics').focus(); 240 return; 241 } 242 save(choice === 'all' || choice === 'save' && dialog.querySelector('#privacy-analytics').checked, 243 choice === 'all' || choice === 'save' && dialog.querySelector('#privacy-marketing').checked); 244 dialog.close(); 245 }); 246 dialog.addEventListener('close', () => returnFocus?.focus?.()); 247 dialog.addEventListener('cancel', () => { 248 if (!read().timestamp) save(false, false); 249 }); 250 document.body.append(dialog); 251 const button = document.createElement('button'); 252 button.id = 'privacy-choices'; 253 button.type = 'button'; 254 button.textContent = 'Privacy Choices'; 255 button.addEventListener('click', () => { button.focus(); open(); }); 256 // Keep the control alongside legal links, including after client-side navigation. 257 const placeButton = () => { 258 const legalOrigin = ['localhost', '127.0.0.1'].includes(location.hostname) 259 ? location.protocol + '//' + location.hostname + ':4312' : 'https://account.forfeit.app'; 260 for (const link of document.querySelectorAll('a[href]')) { 261 const url = new URL(link.href, location.href); 262 if (url.origin !== location.origin && !/(^|\.)(overlord|forfeit)\.app$/.test(url.hostname)) continue; 263 const match = url.pathname.match(/^\/(?:webapp\/)?(privacy|cookies|refunds|accessibility)(?:\.html)?\/?$/); 264 if (match) { 265 const destination = legalOrigin + '/' + match[1]; 266 if (link.href !== destination) link.href = destination; 267 } 268 } 269 if (document.querySelector('[data-privacy-menu-trigger]')) { 270 button.remove(); 271 document.getElementById('privacy-footer')?.remove(); 272 return; 273 } 274 const legalLink = [...document.querySelectorAll('footer a[href*="privacy"], .ftr a[href*="privacy"], a[href*="privacy"]')] 275 .find(link => !dialog.contains(link) && link.getClientRects().length); 276 const footer = document.querySelector('footer:not(#privacy-footer), .ftr'); 277 const explicitTarget = [...document.querySelectorAll('[data-privacy-controls]')] 278 .find(candidate => candidate.getClientRects().length); 279 const target = explicitTarget || footer?.querySelector('a[href*="privacy"]')?.parentElement || footer || legalLink?.parentElement; 280 if (target && target.id !== 'privacy-footer') { 281 if (button.parentElement !== target) { 282 target.append(button); 283 const neighbour = target.querySelector('a') || target.parentElement?.querySelector('a'); 284 if (neighbour) { 285 button.className = neighbour.className; 286 const appearance = getComputedStyle(neighbour); 287 for (const property of ['color', 'font', 'text-decoration']) { 288 button.style.setProperty(property, appearance.getPropertyValue(property)); 289 } 290 } 291 } 292 document.getElementById('privacy-footer')?.remove(); 293 } else if (!button.isConnected) { 294 const footer = document.createElement('footer'); 295 footer.id = 'privacy-footer'; 296 footer.append(button); 297 document.body.append(footer); 298 } 299 }; 300 placeButton(); 301 new MutationObserver(placeButton).observe(document.body, {childList: true, subtree: true}); 302 apply(); 303 if (!read().timestamp) open(); 304 } 305 window.addEventListener('storage', event => { 306 if (event.key !== KEY) return; 307 try { memory = JSON.parse(event.newValue); } catch (_) { memory = null; } 308 apply(); 309 }); 310 window.addEventListener('focus', apply); 311 setInterval(apply, 1000); 312 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); 313 else mount(); 314})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.