PageSourceSearch

https://account.forfeit.app/privacy.js

js forfeit.app collected 2026-10-02 07:54:10 UTC 17,403 bytes, 314 lines download raw bytes

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.