- New SportsMatch model/controller and sports UI components/modal - Move share-modal to a reusable x-share-modal/x-share-button component - Add VideoSharedWithUser notification and share-to-members flow - Device/user-agent tracking on views, downloads, share accesses - ProfileVisit model + migration; subscription source tracking - Email thumbnail support; remove stale TODO files
282 lines
14 KiB
JavaScript
282 lines
14 KiB
JavaScript
/* TAKEONE device fingerprint — strongest guest identifier a browser will let us compute.
|
|
*
|
|
* Combines ~15 signals (canvas, WebGL, audio, fonts, screen, locale, hardware) into a
|
|
* stable 64-char hash. Cached in localStorage AND mirrored to a `_fp` cookie so the
|
|
* server sees it on every request. Falls back gracefully if any single signal fails
|
|
* (private browsing, locked-down browsers, no GPU, etc).
|
|
*
|
|
* NOT a MAC address — browsers cannot expose MAC. This is the closest practical equivalent.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var STORAGE_KEY = '_takeone_fp';
|
|
var COOKIE_KEY = '_fp';
|
|
var COOKIE_MAX = 60 * 60 * 24 * 365 * 5; // 5 years
|
|
|
|
// ── Set cookie helper ────────────────────────────────────────────
|
|
function setCookie(name, val) {
|
|
try {
|
|
document.cookie =
|
|
name + '=' + encodeURIComponent(val) +
|
|
'; Max-Age=' + COOKIE_MAX +
|
|
'; Path=/; SameSite=Lax' +
|
|
(location.protocol === 'https:' ? '; Secure' : '');
|
|
} catch (e) { /* sandboxed iframe etc. */ }
|
|
}
|
|
|
|
function readCookie(name) {
|
|
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
|
return m ? decodeURIComponent(m[1]) : null;
|
|
}
|
|
|
|
// ── SHA-256 (Web Crypto where available, JS fallback otherwise) ──
|
|
function sha256(str) {
|
|
if (window.crypto && window.crypto.subtle && window.TextEncoder) {
|
|
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
|
|
.then(function (buf) {
|
|
return Array.from(new Uint8Array(buf))
|
|
.map(function (b) { return b.toString(16).padStart(2, '0'); })
|
|
.join('');
|
|
});
|
|
}
|
|
// Minimal JS fallback (only used in ancient browsers)
|
|
return Promise.resolve(jsSha256(str));
|
|
}
|
|
|
|
// Tiny pure-JS SHA-256 (used only if subtleCrypto missing). Adapted from public-domain refs.
|
|
function jsSha256(ascii) {
|
|
function rightRotate(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
|
|
var mathPow = Math.pow, maxWord = mathPow(2, 32), result = '';
|
|
var words = [], asciiBitLength = ascii.length * 8;
|
|
var hash = jsSha256.h = jsSha256.h || [], k = jsSha256.k = jsSha256.k || [], primeCounter = k.length;
|
|
var isComposite = {};
|
|
for (var candidate = 2; primeCounter < 64; candidate++) {
|
|
if (!isComposite[candidate]) {
|
|
for (var i = 0; i < 313; i += candidate) isComposite[i] = candidate;
|
|
hash[primeCounter] = (mathPow(candidate, .5) * maxWord) | 0;
|
|
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
|
|
}
|
|
}
|
|
ascii += '\x80';
|
|
while (ascii.length % 64 - 56) ascii += '\x00';
|
|
for (i = 0; i < ascii.length; i++) {
|
|
var j = ascii.charCodeAt(i);
|
|
if (j >> 8) return '';
|
|
words[i >> 2] |= j << ((3 - i) % 4) * 8;
|
|
}
|
|
words[words.length] = ((asciiBitLength / maxWord) | 0);
|
|
words[words.length] = (asciiBitLength);
|
|
for (j = 0; j < words.length;) {
|
|
var w = words.slice(j, j += 16), oldHash = hash;
|
|
hash = hash.slice(0, 8);
|
|
for (i = 0; i < 64; i++) {
|
|
var w15 = w[i - 15], w2 = w[i - 2];
|
|
var a = hash[0], e = hash[4];
|
|
var temp1 = hash[7]
|
|
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25))
|
|
+ ((e & hash[5]) ^ ((~e) & hash[6]))
|
|
+ k[i]
|
|
+ (w[i] = (i < 16) ? w[i] : (
|
|
w[i - 16]
|
|
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3))
|
|
+ w[i - 7]
|
|
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))
|
|
) | 0);
|
|
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22))
|
|
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
|
|
hash = [(temp1 + temp2) | 0].concat(hash);
|
|
hash[4] = (hash[4] + temp1) | 0;
|
|
}
|
|
for (i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0;
|
|
}
|
|
for (i = 0; i < 8; i++) {
|
|
for (j = 3; j + 1; j--) {
|
|
var b = (hash[i] >> (j * 8)) & 255;
|
|
result += ((b < 16) ? 0 : '') + b.toString(16);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Signal probes ────────────────────────────────────────────────
|
|
function canvasFingerprint() {
|
|
try {
|
|
var c = document.createElement('canvas');
|
|
c.width = 280; c.height = 60;
|
|
var ctx = c.getContext('2d');
|
|
ctx.textBaseline = 'top';
|
|
ctx.font = "14px 'Arial'";
|
|
ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20);
|
|
ctx.fillStyle = '#069'; ctx.fillText('TAKEONE,fp 🎬', 2, 15);
|
|
ctx.fillStyle = 'rgba(102,204,0,0.7)';
|
|
ctx.fillText('TAKEONE,fp 🎬', 4, 17);
|
|
// Curved shape exposes GPU sub-pixel rounding differences
|
|
ctx.beginPath();
|
|
ctx.arc(50, 30, 20, 0, Math.PI * 2, true);
|
|
ctx.closePath();
|
|
ctx.fillStyle = 'rgb(255,0,255)';
|
|
ctx.fill();
|
|
return c.toDataURL();
|
|
} catch (e) { return 'canvas:err'; }
|
|
}
|
|
|
|
function webglFingerprint() {
|
|
try {
|
|
var c = document.createElement('canvas');
|
|
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
|
|
if (!gl) return 'webgl:none';
|
|
var info = gl.getExtension('WEBGL_debug_renderer_info');
|
|
var vendor = info ? gl.getParameter(info.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
|
var renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
|
var max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
var ext = (gl.getSupportedExtensions() || []).sort().join(',');
|
|
return vendor + '|' + renderer + '|' + max + '|' + ext;
|
|
} catch (e) { return 'webgl:err'; }
|
|
}
|
|
|
|
function audioFingerprint() {
|
|
return new Promise(function (resolve) {
|
|
try {
|
|
var Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
if (!Ctx) return resolve('audio:none');
|
|
var ctx = new Ctx(1, 44100, 44100);
|
|
var osc = ctx.createOscillator();
|
|
osc.type = 'triangle'; osc.frequency.value = 10000;
|
|
var compressor = ctx.createDynamicsCompressor();
|
|
['threshold','knee','ratio','attack','release'].forEach(function (p) {
|
|
if (compressor[p] && compressor[p].setValueAtTime) {
|
|
compressor[p].setValueAtTime(({
|
|
threshold:-50, knee:40, ratio:12, attack:0, release:.25
|
|
})[p], ctx.currentTime);
|
|
}
|
|
});
|
|
osc.connect(compressor); compressor.connect(ctx.destination);
|
|
osc.start(0); ctx.startRendering();
|
|
var done = false;
|
|
ctx.oncomplete = function (e) {
|
|
if (done) return; done = true;
|
|
var sum = 0, d = e.renderedBuffer.getChannelData(0);
|
|
for (var i = 4500; i < 5000; i++) sum += Math.abs(d[i]);
|
|
resolve(sum.toString());
|
|
};
|
|
setTimeout(function () { if (!done) { done = true; resolve('audio:timeout'); } }, 1500);
|
|
} catch (e) { resolve('audio:err'); }
|
|
});
|
|
}
|
|
|
|
function fontsFingerprint() {
|
|
// Quick probe: list which of N common fonts the OS actually has installed,
|
|
// detected by measuring text width against fallback baselines.
|
|
try {
|
|
var baseFonts = ['monospace','sans-serif','serif'];
|
|
var testString = 'mmmmmmmmmmlli';
|
|
var testSize = '72px';
|
|
var fonts = [
|
|
'Andale Mono','Arial','Arial Black','Arial Hebrew','Arial MT','Arial Narrow','Arial Rounded MT Bold','Arial Unicode MS',
|
|
'Bitstream Vera Sans Mono','Book Antiqua','Bookman Old Style','Calibri','Cambria','Cambria Math','Century','Century Gothic','Century Schoolbook',
|
|
'Comic Sans','Comic Sans MS','Consolas','Courier','Courier New','Geneva','Georgia','Helvetica','Helvetica Neue','Impact',
|
|
'Lucida Bright','Lucida Calligraphy','Lucida Console','Lucida Fax','LUCIDA GRANDE','Lucida Handwriting','Lucida Sans','Lucida Sans Typewriter','Lucida Sans Unicode',
|
|
'Microsoft Sans Serif','Monaco','Monotype Corsiva','MS Gothic','MS Outlook','MS PGothic','MS Reference Sans Serif','MS Sans Serif','MS Serif','MYRIAD','MYRIAD PRO',
|
|
'Palatino','Palatino Linotype','Segoe Print','Segoe Script','Segoe UI','Segoe UI Light','Segoe UI Semibold','Segoe UI Symbol','Tahoma','Times','Times New Roman','Times New Roman PS','Trebuchet MS','Verdana','Wingdings','Wingdings 2','Wingdings 3'
|
|
];
|
|
var body = document.body || document.documentElement;
|
|
var span = document.createElement('span');
|
|
span.style.position = 'absolute'; span.style.left = '-9999px';
|
|
span.style.fontSize = testSize; span.textContent = testString;
|
|
body.appendChild(span);
|
|
var defaults = {};
|
|
baseFonts.forEach(function (b) {
|
|
span.style.fontFamily = b;
|
|
defaults[b] = { w: span.offsetWidth, h: span.offsetHeight };
|
|
});
|
|
var detected = [];
|
|
fonts.forEach(function (f) {
|
|
var diff = false;
|
|
for (var i = 0; i < baseFonts.length; i++) {
|
|
span.style.fontFamily = "'" + f + "'," + baseFonts[i];
|
|
if (span.offsetWidth !== defaults[baseFonts[i]].w ||
|
|
span.offsetHeight !== defaults[baseFonts[i]].h) { diff = true; break; }
|
|
}
|
|
if (diff) detected.push(f);
|
|
});
|
|
body.removeChild(span);
|
|
return detected.join(',');
|
|
} catch (e) { return 'fonts:err'; }
|
|
}
|
|
|
|
function collectSignals() {
|
|
var nav = window.navigator || {};
|
|
var scr = window.screen || {};
|
|
return Promise.all([audioFingerprint()]).then(function (results) {
|
|
return {
|
|
canvas : canvasFingerprint(),
|
|
webgl : webglFingerprint(),
|
|
audio : results[0],
|
|
fonts : fontsFingerprint(),
|
|
screen : (scr.width || 0) + 'x' + (scr.height || 0) + 'x' + (scr.colorDepth || 0),
|
|
dpr : window.devicePixelRatio || 1,
|
|
platform : nav.platform || '',
|
|
cpu : nav.hardwareConcurrency || 0,
|
|
mem : nav.deviceMemory || 0,
|
|
tz : (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
|
|
langs : (nav.languages || []).join(','),
|
|
touch : (nav.maxTouchPoints || 0) + (('ontouchstart' in window) ? 'T' : ''),
|
|
pdfviewer : nav.pdfViewerEnabled ? '1' : '0',
|
|
userAgent : nav.userAgent || ''
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── Build / cache the hash ───────────────────────────────────────
|
|
function ensureFingerprint() {
|
|
// 1) localStorage first (fastest path)
|
|
var cached = null;
|
|
try { cached = localStorage.getItem(STORAGE_KEY); } catch (e) {}
|
|
// 2) cookie next (survives some localStorage wipes)
|
|
if (!cached) cached = readCookie(COOKIE_KEY);
|
|
|
|
if (cached && /^[a-f0-9]{64}$/.test(cached)) {
|
|
setCookie(COOKIE_KEY, cached); // refresh expiry on each visit
|
|
window._takeoneFp = cached;
|
|
return Promise.resolve(cached);
|
|
}
|
|
|
|
return collectSignals().then(function (sig) {
|
|
var serialised = JSON.stringify(sig);
|
|
return sha256(serialised).then(function (hash) {
|
|
try { localStorage.setItem(STORAGE_KEY, hash); } catch (e) {}
|
|
setCookie(COOKIE_KEY, hash);
|
|
window._takeoneFp = hash;
|
|
|
|
// Backfill the view row that the server just inserted from the cookie-less first visit
|
|
try {
|
|
var pathMatch = location.pathname.match(/^\/videos\/([^\/?#]+)/);
|
|
if (pathMatch) {
|
|
var token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
|
fetch('/videos/' + pathMatch[1] + '/identify', {
|
|
method : 'POST',
|
|
headers : {
|
|
'Content-Type' : 'application/json',
|
|
'X-CSRF-TOKEN' : token,
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ hash: hash })
|
|
}).catch(function () { /* best-effort */ });
|
|
}
|
|
} catch (e) {}
|
|
|
|
return hash;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Kick off ASAP — but never block paint
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', ensureFingerprint, { once: true });
|
|
} else {
|
|
ensureFingerprint();
|
|
}
|
|
})();
|