diff --git a/config_example.json b/config_example.json
index 3ed13a4..df9b90b 100644
--- a/config_example.json
+++ b/config_example.json
@@ -31,6 +31,7 @@
],
"enable_pdf": false,
"enable_nsfl": false,
+ "enable_comments": true,
"enable_private_uploads": true,
"enable_expiring_uploads": true,
"default_upload_visibility": 0,
@@ -124,6 +125,7 @@
"halls_enabled": true,
"userhalls_enabled": true,
"enable_userhall_image_upload": true,
+ "enable_oc": true,
"abyss_enabled": true,
"meme_creator": true,
"enable_cleanup": false,
diff --git a/migrations/add_passkey_credentials.sql b/migrations/add_passkey_credentials.sql
new file mode 100644
index 0000000..dbc33d6
--- /dev/null
+++ b/migrations/add_passkey_credentials.sql
@@ -0,0 +1,31 @@
+-- passkey_credentials: stores WebAuthn credential records for all users
+-- (both registered accounts and anonymous shadow users)
+CREATE TABLE IF NOT EXISTS public.passkey_credentials (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
+ credential_id TEXT NOT NULL UNIQUE, -- base64url-encoded credentialId
+ public_key_spki TEXT NOT NULL, -- base64-encoded DER SPKI (ES-256 / P-256)
+ sign_count BIGINT NOT NULL DEFAULT 0, -- replay-attack counter
+ aaguid TEXT, -- authenticator AAGUID (informational)
+ name TEXT, -- user-assigned label ("Bitwarden", "iPhone")
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON public.passkey_credentials(credential_id);
+CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON public.passkey_credentials(user_id);
+
+-- Extend anon_identities to support passkey-based identities.
+-- New passkey rows use credential_id; legacy SSH rows retain pubkey/fingerprint.
+-- pubkey and fingerprint are made nullable to allow passkey-only rows.
+ALTER TABLE public.anon_identities
+ ADD COLUMN IF NOT EXISTS credential_id TEXT UNIQUE;
+
+ALTER TABLE public.anon_identities
+ ALTER COLUMN pubkey DROP NOT NULL;
+
+ALTER TABLE public.anon_identities
+ ALTER COLUMN fingerprint DROP NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_anon_identities_credential_id
+ ON public.anon_identities(credential_id);
diff --git a/migrations/add_report_categories.sql b/migrations/add_report_categories.sql
new file mode 100644
index 0000000..f9c5ff1
--- /dev/null
+++ b/migrations/add_report_categories.sql
@@ -0,0 +1 @@
+ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}';
diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 394e2ae..5a9a3a6 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -14062,6 +14062,79 @@ textarea.mod-reason:focus {
font-size: 0.75em;
}
+/* Audit filter bar */
+.audit-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 18px;
+ padding: 12px 14px;
+ background: var(--badge-bg);
+ border: 1px solid var(--nav-border-color);
+ border-radius: 8px;
+}
+
+.audit-filter-input {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--nav-border-color);
+ border-radius: 5px;
+ color: inherit;
+ font-size: 0.85em;
+ padding: 5px 10px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.audit-filter-input:focus {
+ border-color: var(--accent);
+}
+
+select.audit-filter-input {
+ min-width: 180px;
+}
+
+input.audit-filter-input {
+ min-width: 160px;
+}
+
+.audit-filter-btn {
+ cursor: pointer;
+ font-size: 0.8em;
+ padding: 5px 12px;
+ border: none;
+ line-height: 1.6;
+}
+
+.audit-filter-clear {
+ opacity: 0.7;
+}
+
+.audit-filter-clear:hover {
+ opacity: 1;
+}
+
+/* Header right group (time + id) */
+.audit-card-header-right {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+}
+
+/* Entry ID badge */
+.audit-entry-id {
+ font-size: 0.72em;
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+ color: var(--accent);
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid var(--nav-border-color);
+ border-radius: 4px;
+ padding: 1px 6px;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+}
+
.audit-diff {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
@@ -17393,7 +17466,7 @@ textarea#profile_description {
.user-infobox-block {
display: flex;
- gap: 10px;
+ gap: 5px;
background: var(--comment-bg);
border: 1px solid var(--author-border, rgba(0, 255, 0, 0.2));
line-height: 1;
@@ -17401,9 +17474,8 @@ textarea#profile_description {
align-items: flex-start;
width: 100%;
max-width: 100%;
- min-height: 90px;
+ min-height: 40px;
box-sizing: border-box;
- margin-bottom: 10px;
}
/* ── Banner inside alternative infobox ──────────────────────────────── */
@@ -17435,8 +17507,8 @@ textarea#profile_description {
.user-infobox-avatar img {
- width: 64px;
- height: 64px;
+ width: auto;
+ height: 22px;
object-fit: cover;
}
@@ -17447,12 +17519,23 @@ textarea#profile_description {
}
.user-infobox-header {
- display: flex;
- justify-content: space-between;
+ display: grid;
align-items: center;
- margin-bottom: 6px;
padding-bottom: 6px;
- border-bottom: 1px solid var(--nav-border-color);
+ grid-template-columns: auto 1fr;
+}
+
+.user-infobox-mime {
+ background: black;
+ padding: 4px;
+ margin: 2px;
+ font-size: 6px;
+ border-radius: 3px;
+ font-weight: bold;
+ text-transform: uppercase;
+ cursor: default;
+ user-select: none;
+ align-self: self-end;
}
.user-infobox-actions i {
@@ -17485,6 +17568,7 @@ textarea#profile_description {
font-size: 0.8em;
color: #888;
letter-spacing: 0.5px;
+ text-align: right;
}
.timestamp-link {
@@ -21592,6 +21676,7 @@ div#flash,
background: var(--nav-bg, #2b2b2b);
color: var(--white, #fff);
border: 1px solid var(--nav-border-color, #444);
+ margin-right: 4px;
}
.bulk-btn-close:hover {
@@ -21600,6 +21685,21 @@ div#flash,
border-color: #ff4444 !important;
}
+.bulk-btn-danger {
+ background: transparent !important;
+ color: #ff5555 !important;
+ border: 1px solid #ff5555 !important;
+ font-weight: bold !important;
+ box-shadow: none !important;
+}
+
+.bulk-btn-danger:hover {
+ background: #ff4444 !important;
+ color: #fff !important;
+ border-color: #ff4444 !important;
+ filter: none !important;
+}
+
/* Bulk rating dropdown wrapper */
.bulk-dropdown-wrap {
position: relative;
diff --git a/public/s/js/admin.js b/public/s/js/admin.js
index 3ab8972..0ccc225 100644
--- a/public/s/js/admin.js
+++ b/public/s/js/admin.js
@@ -396,18 +396,48 @@
}
};
- const deleteButtonEvent = async e => {
- if (e) {
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
- }
- const ctx = getContext();
- if (!ctx) return;
- const { postid, poster, authorId } = ctx;
-
+ const deleteSubItemEvent = async (subf0ck, postid) => {
+ if (!subf0ck || !postid) return;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
+ const subDesc = subf0ck.display_index
+ ? `Slide #${subf0ck.display_index}`
+ : `Slide (${subf0ck.slug || subf0ck.id})`;
+
+ ModAction.confirm(
+ 'Delete Slide from Album',
+ `Are you sure you want to delete ${subDesc} from this album?
The rest of the album will remain intact.`,
+ async (reason) => {
+ const res = await post("/api/v2/admin/delete-album-item", {
+ postid: postid,
+ sub_id: subf0ck.id,
+ sub_slug: subf0ck.slug,
+ order_index: subf0ck.order_index,
+ reason: reason
+ });
+ if (!res.success) {
+ throw new Error(res.msg || 'Failed to delete album item');
+ }
+ if (res.post_deleted) {
+ if (window.flashMessage) window.flashMessage('Album deleted (no remaining items)', 2500, 'info');
+ const mediaObj = document.querySelector('.media-object');
+ if (mediaObj) {
+ mediaObj.innerHTML = '
Album Deleted
The album has been removed.
';
+ }
+ } else {
+ if (window.albumGallery && typeof window.albumGallery.removeSubf0ck === 'function') {
+ window.albumGallery.removeSubf0ck(subf0ck.id || subf0ck.slug || subf0ck.order_index);
+ } else {
+ window.location.reload();
+ }
+ if (window.flashMessage) window.flashMessage('Slide deleted from album', 2500, 'success');
+ }
+ },
+ { allowEmpty: window.f0ckSession?.is_admin, confirmText: 'Delete Slide' }
+ );
+ };
+
+ const deleteEntirePost = (postid, poster, authorId) => {
const i18n = window.f0ckI18n || {};
const confirmTitle = i18n.item_delete_title || 'Delete Item';
const posterStr = poster
@@ -438,6 +468,68 @@
}, { allowEmpty: window.f0ckSession?.is_admin });
};
+ const deleteButtonEvent = async e => {
+ if (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ }
+ const ctx = getContext();
+ if (!ctx) return;
+ const { postid, poster, authorId } = ctx;
+
+ if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
+
+ const isAlbum = !!(window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function');
+ const curSub = isAlbum ? window.albumGallery.getCurrentSubf0ck() : null;
+
+ if (isAlbum && curSub) {
+ const subDesc = curSub.display_index ? `Slide ${curSub.display_index}` : 'Current Slide';
+ const choiceHtml = `
+
+ This post is an album containing multiple slides. What would you like to delete?
+
+
+
+
+
+ `;
+
+ ModAction.confirm('Delete Options', choiceHtml, () => {}, {
+ hideReason: true,
+ hideConfirm: true,
+ unsafeContent: true,
+ cancelText: 'Cancel'
+ });
+
+ setTimeout(() => {
+ const modal = document.getElementById('mod-action-modal');
+ if (!modal) return;
+ const subBtn = modal.querySelector('#btn-choice-delete-sub');
+ const albumBtn = modal.querySelector('#btn-choice-delete-album');
+ if (subBtn) {
+ subBtn.onclick = () => {
+ modal.style.display = 'none';
+ deleteSubItemEvent(curSub, postid);
+ };
+ }
+ if (albumBtn) {
+ albumBtn.onclick = () => {
+ modal.style.display = 'none';
+ deleteEntirePost(postid, poster, authorId);
+ };
+ }
+ }, 50);
+ return;
+ }
+
+ deleteEntirePost(postid, poster, authorId);
+ };
+
let tmptt = null;
const editTagEvent = async e => {
e.preventDefault();
@@ -504,6 +596,17 @@
addtagClick(e);
} else if (target.closest("#a_delete")) {
deleteButtonEvent(e);
+ } else if (target.closest(".album-sub-delete-btn, #a_delete_sub")) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ const ctx = getContext();
+ const curSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
+ ? window.albumGallery.getCurrentSubf0ck()
+ : null;
+ if (ctx && curSub) {
+ deleteSubItemEvent(curSub, ctx.postid);
+ }
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
editTagEvent(e);
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js
index 426632a..83539e5 100644
--- a/public/s/js/anon_ssh.js
+++ b/public/s/js/anon_ssh.js
@@ -1,826 +1,466 @@
/**
- * f0ckm Anonymous OpenSSH Ed25519 Identity Manager
- * Provides client-side Ed25519 key generation, OpenSSH key formatting,
- * signing, and authentication without login.
+ * f0ckm Anonymous Passkey Identity Manager
+ *
+ * Replaces the old OpenSSH Ed25519 approach.
+ * Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store.
+ *
+ * Flow:
+ * First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session
+ * Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session
+ *
+ * For registered users:
+ * Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey.
*/
(function () {
'use strict';
- const PKCS8_HEADER = new Uint8Array([
- 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
- ]);
+ // ─── base64url helpers (browser) ───────────────────────────────────────────
- const STORAGE_KEY_PRIV = 'f0ck_anon_ssh_priv';
- const STORAGE_KEY_PUB = 'f0ck_anon_ssh_pub';
- const STORAGE_KEY_FP = 'f0ck_anon_ssh_fp';
+ function b64urlToArr(b64) {
+ const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
+ const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ return arr;
+ }
- function bytesToBase64(bytes) {
- let binary = '';
- const len = bytes.byteLength;
- for (let i = 0; i < len; i++) {
- binary += String.fromCharCode(bytes[i]);
+ function arrToB64url(buf) {
+ const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
+ let bin = '';
+ arr.forEach(b => bin += String.fromCharCode(b));
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ }
+
+ // ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
+
+ async function getHardwareFingerprint() {
+ try {
+ const cached = localStorage.getItem('f0ck_anon_hw_fp');
+ if (cached) return cached;
+ } catch (e) {}
+
+ try {
+ let glVendor = '', glRenderer = '', glLimits = '';
+ try {
+ const canvas = document.createElement('canvas');
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
+ if (gl) {
+ const ext = gl.getExtension('WEBGL_debug_renderer_info');
+ if (ext) {
+ glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
+ glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
+ }
+ glLimits = [
+ gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
+ gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
+ gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
+ gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
+ gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
+ gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
+ ].join(',');
+ }
+ } catch (e) {}
+
+ let gpuArch = '';
+ try {
+ if (navigator.gpu) {
+ const adapter = await navigator.gpu.requestAdapter();
+ if (adapter && adapter.info) {
+ gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
+ }
+ }
+ } catch (e) {}
+
+ const concurrency = navigator.hardwareConcurrency || 0;
+ const memory = navigator.deviceMemory || 0;
+ const platform = navigator.platform || '';
+ const screenInfo = [
+ window.screen ? window.screen.width : 0,
+ window.screen ? window.screen.height : 0,
+ window.screen ? window.screen.colorDepth : 0,
+ window.devicePixelRatio || 1
+ ].join('x');
+ const touchPoints = navigator.maxTouchPoints || 0;
+
+ let canvasFp = '';
+ try {
+ const c2d = document.createElement('canvas');
+ c2d.width = 240; c2d.height = 60;
+ const ctx = c2d.getContext('2d');
+ if (ctx) {
+ ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20);
+ ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif';
+ ctx.fillText('f0ck.dev 😃', 4, 35);
+ const imgData = ctx.getImageData(0, 0, 240, 60).data;
+ let sum = 0;
+ for (let i = 0; i < imgData.length; i += 4) {
+ sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
+ }
+ canvasFp = sum.toString(16);
+ }
+ } catch (e) {}
+
+ let audioFp = '';
+ try {
+ const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
+ if (AudioCtx) {
+ const actx = new AudioCtx(1, 44100, 44100);
+ const osc = actx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(10000, actx.currentTime);
+ const comp = actx.createDynamicsCompressor();
+ comp.threshold.setValueAtTime(-50, actx.currentTime);
+ comp.knee.setValueAtTime(40, actx.currentTime);
+ comp.ratio.setValueAtTime(12, actx.currentTime);
+ comp.attack.setValueAtTime(0, actx.currentTime);
+ comp.release.setValueAtTime(0.25, actx.currentTime);
+ osc.connect(comp); comp.connect(actx.destination); osc.start(0);
+ const buf = await actx.startRendering();
+ const ch = buf.getChannelData(0);
+ let sum = 0;
+ for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0);
+ audioFp = sum.toFixed(7);
+ }
+ } catch (e) {}
+
+ const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~');
+ const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
+ const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
+ const fp = `HW:${hashHex}`;
+ try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {}
+ return fp;
+ } catch (err) {
+ console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err);
+ return null;
}
- return window.btoa(binary);
}
- function base64ToBytes(base64) {
- const binary = window.atob(base64.replace(/\s+/g, ''));
- const len = binary.length;
- const bytes = new Uint8Array(len);
- for (let i = 0; i < len; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes;
+ // ─── Tombstone (ban state persisted client-side) ───────────────────────────
+
+ function getTombstone() {
+ try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; }
}
- function bytesToHex(bytes) {
- return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
+ function setTombstone(t) {
+ try {
+ localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t));
+ if (t && t.banned) {
+ document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`;
+ }
+ } catch (e) {}
}
- function hexToBytes(hex) {
- const clean = hex.replace(/[^0-9a-fA-F]/g, '');
- const bytes = new Uint8Array(clean.length / 2);
- for (let i = 0; i < bytes.length; i++) {
- bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
- }
- return bytes;
- }
+ // ─── AnonPasskey class ────────────────────────────────────────────────────
- function writeUInt32BE(buf, value, offset) {
- buf[offset] = (value >>> 24) & 0xff;
- buf[offset + 1] = (value >>> 16) & 0xff;
- buf[offset + 2] = (value >>> 8) & 0xff;
- buf[offset + 3] = value & 0xff;
- }
-
- function readUInt32BE(buf, offset) {
- return (
- (buf[offset] << 24) |
- (buf[offset + 1] << 16) |
- (buf[offset + 2] << 8) |
- buf[offset + 3]
- ) >>> 0;
- }
-
- function concatUint8Arrays(arrays) {
- const totalLen = arrays.reduce((acc, a) => acc + a.length, 0);
- const result = new Uint8Array(totalLen);
- let offset = 0;
- for (const arr of arrays) {
- result.set(arr, offset);
- offset += arr.length;
- }
- return result;
- }
-
- class AnonSSH {
+ class AnonPasskey {
constructor() {
- this.cryptoKey = null;
- this.pubkey = null;
- this.fingerprint = null;
- this.shortFingerprint = null;
- this.rawPub = null;
- this.rawSeed = null;
this.isSessionReady = false;
- this.hwFingerprint = null;
- try {
- this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null;
- } catch (e) {}
+ this._loginInProgress = false;
}
- /**
- * Compute a hardware-level fingerprint based on GPU, CPU, RAM, screen, and Web Audio DSP.
- * Survives across private browsing windows and different browsers on the same physical device.
- */
- async getHardwareFingerprint() {
- if (this.hwFingerprint) return this.hwFingerprint;
- try {
- const cached = localStorage.getItem('f0ck_anon_hw_fp');
- if (cached) {
- this.hwFingerprint = cached;
- return this.hwFingerprint;
- }
- } catch (e) {}
+ // ── Check WebAuthn support ──────────────────────────────────────────────
- try {
- let glVendor = '';
- let glRenderer = '';
- let glLimits = '';
- try {
- const canvas = document.createElement('canvas');
- const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
- if (gl) {
- const ext = gl.getExtension('WEBGL_debug_renderer_info');
- if (ext) {
- glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
- glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
- }
- glLimits = [
- gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
- gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
- gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
- gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
- gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
- gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
- ].join(',');
- }
- } catch (e) {}
+ get supported() {
+ return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
+ }
- // WebGPU adapter architecture (modern GPUs)
- let gpuArch = '';
- try {
- if (navigator.gpu) {
- const adapter = await navigator.gpu.requestAdapter();
- if (adapter && adapter.info) {
- gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
- }
- }
- } catch (e) {}
+ // ── UI helpers ──────────────────────────────────────────────────────────
- // CPU & Memory
- const concurrency = navigator.hardwareConcurrency || 0;
- const memory = navigator.deviceMemory || 0;
- const platform = navigator.platform || '';
-
- // Display, Gamut & Dynamic Range
- const screenInfo = [
- window.screen ? window.screen.width : 0,
- window.screen ? window.screen.height : 0,
- window.screen ? window.screen.availWidth : 0,
- window.screen ? window.screen.availHeight : 0,
- window.screen ? window.screen.colorDepth : 0,
- window.devicePixelRatio || 1,
- window.matchMedia && window.matchMedia('(color-gamut: p3)').matches ? 'p3' : (window.matchMedia && window.matchMedia('(color-gamut: rec2020)').matches ? 'rec2020' : 'srgb'),
- window.matchMedia && window.matchMedia('(dynamic-range: high)').matches ? 'hdr' : 'sdr'
- ].join('x');
-
- // Input & Peripheral Hardware (touch, mouse, stylus)
- const touchPoints = (typeof navigator !== 'undefined' && 'maxTouchPoints' in navigator) ? navigator.maxTouchPoints : 0;
- const pointerType = (window.matchMedia && window.matchMedia('(pointer: fine)').matches) ? 'fine' : ((window.matchMedia && window.matchMedia('(pointer: coarse)').matches) ? 'coarse' : 'none');
- const hoverType = (window.matchMedia && window.matchMedia('(hover: hover)').matches) ? 'hover' : 'none';
- const inputHardware = `${touchPoints}:${pointerType}:${hoverType}`;
-
- // 2D Canvas Font Rasterizer & Subpixel Geometry
- let canvasFp = '';
- try {
- const c2d = document.createElement('canvas');
- c2d.width = 240;
- c2d.height = 60;
- const ctx = c2d.getContext('2d');
- if (ctx) {
- ctx.textBaseline = 'alphabetic';
- ctx.fillStyle = '#f60';
- ctx.fillRect(10, 5, 60, 20);
- ctx.fillStyle = '#069';
- ctx.font = '14pt Arial, sans-serif';
- ctx.fillText('f0ck.dev