From 73c0659d4aa4102de2aa208817a1d7ab90bb490b Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 28 Sep 2026 23:24:50 +0200 Subject: [PATCH] hgfhfd --- .env.example | 3 + Dockerfile | 26 ++- config_example.json | 3 + config_example.yaml | 8 + docker-compose.yml | 2 + public/s/css/f0ckm.css | 76 ++++++-- public/s/js/anon_ssh.js | 5 + public/s/js/comments.js | 43 ++++- public/s/js/f0ckm.js | 86 ++++++++- public/s/js/page-modal.js | 185 +++++++++++++++++++ src/inc/retention.mjs | 12 +- src/inc/routes/admin.mjs | 8 +- src/inc/routes/apiv2/anon.mjs | 17 +- src/inc/routes/index.mjs | 5 +- src/inc/routes/notifications.mjs | 4 +- src/inc/security.mjs | 42 ++++- src/inc/settings.mjs | 2 + src/index.mjs | 9 +- views/about.html | 2 - views/item-partial-legacy.html | 4 +- views/item-partial-modern.html | 4 +- views/privacy.html | 44 +++-- views/rules.html | 2 - views/snippets/comment-input.html | 24 +++ views/snippets/footer.html | 17 +- views/snippets/header.html | 2 +- views/snippets/navbar.html | 255 +++++++++++++++++++++++---- views/snippets/page-modal.html | 44 +++++ views/snippets/visibility-modal.html | 3 +- 29 files changed, 820 insertions(+), 117 deletions(-) create mode 100644 public/s/js/page-modal.js create mode 100644 views/snippets/comment-input.html create mode 100644 views/snippets/page-modal.html diff --git a/.env.example b/.env.example index 02ed0d3..d5f50dc 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ POSTGRES_USER=f0ckm POSTGRES_DB=f0ckm POSTGRES_PASSWORD=f0ckm +# Secret for hashing user IPs (HMAC-SHA256). Generate once: openssl rand -hex 32 +# Kept here instead of config.json so a leaked config or DB dump can't be used to reverse IP hashes. +IP_HASH_SECRET= # --- Nginx & Tor Profiles (Optional) --- # Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both # COMPOSE_PROFILES=tor diff --git a/Dockerfile b/Dockerfile index d10b272..94aad2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,31 @@ RUN apk add --no-cache \ file \ curl \ torsocks \ - exiftool + exiftool \ + bash \ + ca-certificates + +# curl-impersonate: reproduces real browser TLS/HTTP2 handshakes for outgoing 4chan requests +# (picked up automatically by src/inc/chan_http.mjs via PATH). Pinned release + SHA-256 per arch; +# unsupported architectures skip it and the app falls back to plain curl with a random User-Agent. +ARG CURL_IMPERSONATE_VERSION=v2.2.3 +ARG CURL_IMPERSONATE_SHA256_X86_64=288332a313e9edd884a1575c2627844fd9f3388fcefc62982b6b4eae12828357 +ARG CURL_IMPERSONATE_SHA256_AARCH64=18000c51542fe63f0acc5cd3d89fb2217e35574f4c2911aec33e6774973a0809 +ARG TARGETARCH +RUN set -eux; \ + case "${TARGETARCH:-$(uname -m)}" in \ + amd64|x86_64) arch=x86_64; sha="$CURL_IMPERSONATE_SHA256_X86_64" ;; \ + arm64|aarch64) arch=aarch64; sha="$CURL_IMPERSONATE_SHA256_AARCH64" ;; \ + *) echo "curl-impersonate: no build for ${TARGETARCH:-$(uname -m)}, skipping"; exit 0 ;; \ + esac; \ + curl -fsSL -o /tmp/curl-impersonate.tgz \ + "https://github.com/lexiforest/curl-impersonate/releases/download/${CURL_IMPERSONATE_VERSION}/curl-impersonate-${CURL_IMPERSONATE_VERSION}.${arch}-linux-musl.tar.gz"; \ + echo "${sha} /tmp/curl-impersonate.tgz" | sha256sum -c -; \ + mkdir -p /opt/curl-impersonate; \ + tar xzf /tmp/curl-impersonate.tgz -C /opt/curl-impersonate; \ + rm /tmp/curl-impersonate.tgz; \ + /opt/curl-impersonate/curl-impersonate --version | head -n 1 +ENV PATH="/opt/curl-impersonate:${PATH}" WORKDIR /opt/f0ckm COPY . . diff --git a/config_example.json b/config_example.json index 4dcf378..1e7f844 100644 --- a/config_example.json +++ b/config_example.json @@ -16,6 +16,8 @@ "example.org" ], "invite_secret": "YOUR_SECRET_HERE", + "ip_hash_secret": "", + "ip_hash_legacy_secrets": [], "hide_comments_from_public": false, "guest_anonymize": false, "anon_anonymize": false, @@ -103,6 +105,7 @@ "background": true, "log_user_ips": false, "hash_user_ips": true, + "anon_hw_fingerprint": true, "retention_ip_days": 30, "retention_activity_log_days": 90, "retention_login_attempts_days": 30, diff --git a/config_example.yaml b/config_example.yaml index 3180d99..cc76f82 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -13,6 +13,11 @@ main: - example.net - example.org invite_secret: YOUR_SECRET_HERE + # IP hashing secret. Prefer the IP_HASH_SECRET environment variable (.env) so it isn't stored next to the data. + # Falls back to invite_secret when neither is set. + ip_hash_secret: "" + # Previous IP hash secrets, only used to match bans hashed before a rotation (invite_secret is added automatically) + ip_hash_legacy_secrets: [] hide_comments_from_public: false guest_anonymize: false anon_anonymize: false @@ -93,6 +98,9 @@ websrv: background: true log_user_ips: false hash_user_ips: true + # Device fingerprint of anonymous users (hashed in the browser, used only to enforce bans). + # false = never computed or stored; already stored fingerprints are purged. Shown on /privacy. + anon_hw_fingerprint: true # Data retention in days (0 = keep forever). Shown to users on /privacy. retention_ip_days: 30 # stored IPs: user_ips rows deleted, IP columns elsewhere set to NULL retention_activity_log_days: 90 # anonymous activity log (actions, IP, fingerprints) diff --git a/docker-compose.yml b/docker-compose.yml index ba19ad4..8029544 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,8 @@ services: - DB_PASS=${POSTGRES_PASSWORD:-f0ckm} - DB_NAME=${POSTGRES_DB:-f0ckm} - NODE_ENV=production + # Dedicated secret for IP hashing (HMAC). Set in .env, keep it out of config.json and DB backups. + - IP_HASH_SECRET=${IP_HASH_SECRET:-} ports: - "1337:1337" restart: unless-stopped diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index a7e7f47..7f337df 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -2918,11 +2918,6 @@ body.sidebar-right-hidden #sidebar-drag-zone { overflow: visible !important; } - body.layout-legacy .login-placeholder { - border-left: 1px solid var(--nav-border-color) !important; - border-right: 1px solid var(--nav-border-color) !important; - } - body.layout-legacy.sidebar-right-hidden .item-layout-container { grid-template-columns: 1fr; } @@ -4357,7 +4352,7 @@ html[theme='95'] .sidebar-video-title { margin-bottom: 5px; padding: 5px; font-size: 1em; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid var(--nav-border-color); contain: layout style; margin: 0px; } @@ -11344,6 +11339,7 @@ input#s_avatar { min-width: 50px; justify-content: center; opacity: 0.8; + height: 32px; } /* Avatar-style button variant */ @@ -11973,13 +11969,6 @@ html[theme='paper'] .char-counter, color: #888; } -.login-placeholder { - padding: 15px; - text-align: center; - background: var(--bg); - border-bottom: 1px solid var(--nav-link-hover-bg); -} - /* Markdown Styles */ .comment-content .greentext { color: #789922; @@ -12660,7 +12649,6 @@ span.badge.badge-current { } #help-button { - padding: 5px; cursor: pointer; } @@ -12991,8 +12979,17 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover, } @media (max-width: 999px) { + /* Only once the reader is far enough down for it to help (f0ckm.js toggles .is-visible) */ .mobile-scroll-to-top { display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.2s ease, visibility 0s linear 0.2s; + } + .mobile-scroll-to-top.is-visible { + visibility: visible; + opacity: 1; + transition: opacity 0.2s ease, visibility 0s; } } @@ -13376,7 +13373,6 @@ body > nav.navbar { gap: 5px; background: transparent; border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; cursor: pointer; padding: 4px; flex-shrink: 0; @@ -13438,8 +13434,10 @@ body > nav.navbar { z-index: 10000 !important; } +/* At least as wide as the toggle button (.nav-user-dropdown is sized by it), wider only if an entry needs it */ #nav-visitor-menu { - min-width: max-content; + box-sizing: border-box; + min-width: 100%; width: max-content; white-space: nowrap; } @@ -13491,7 +13489,7 @@ body > nav.navbar { /* On mobile: right group stays in the single top row */ .nav-right-group { - gap: 15px; + gap: 5px; padding: 0 4px; margin-left: auto; } @@ -13518,6 +13516,12 @@ body > nav.navbar { animation: navFadeIn 0.2s ease-out; } + /* Guests: menu items aligned right, next to the thumb and the visitor menu */ + .nav-collapse-guest, + .nav-collapse-guest .nav-links { + justify-content: flex-end; + } + @keyframes navFadeIn { from { opacity: 0; @@ -15522,7 +15526,9 @@ body.layout-modern .tag-ac-input { .item-sidebar-right-footer, .global-sidebar-right-footer { display: grid; - grid-template-columns: 1fr 1fr 1fr auto; + /* One equal column per item, however many links the footer has */ + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); align-items: center; align-content: center; border-top: 1px solid var(--nav-border-color); @@ -22973,4 +22979,36 @@ div#flash, } .chan-rehost-edit-btn:hover { background: rgba(255, 255, 255, 0.2) !important; -} \ No newline at end of file +} +/* Tap-to-activate sliders on touch devices (see f0ckm.js): locked sliders let touches through to the page, + so scrolling over them can't change a value; a tap unlocks one */ +html.range-tap-lock input[type="range"]:not(.v0ck input):not(#volume-slider):not([data-no-tap-lock]):not(.range-unlocked) { + pointer-events: none; + opacity: 0.7; +} +html.range-tap-lock input[type="range"].range-unlocked { + opacity: 1; + outline: 1px solid var(--accent); + outline-offset: 4px; +} + +/* Header close (×) for modals whose header has no other close control; full screen on phones, so it must + always be visible. Clicks the modal's own Cancel button so closing behaves exactly the same. */ +.modal-x { + position: absolute; top: 12px; right: 12px; z-index: 1; + width: 36px; height: 36px; margin: 0; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + background: none; border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 0 !important; + color: var(--text-color, #fff); font-size: 1.6em; line-height: 1; cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.modal-x:hover { color: var(--accent); border-color: var(--accent); } + +/* Guest comment box (comments.js renderGuestInput): looks exactly like the real input, but inert. + Clicks fall through to the wrapper, which opens the login modal. */ +.comment-input.is-guest { cursor: pointer; } +.comment-input.is-guest textarea, +.comment-input.is-guest .submit-comment { + pointer-events: none; + user-select: none; +} diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js index 39cd368..5747366 100644 --- a/public/s/js/anon_ssh.js +++ b/public/s/js/anon_ssh.js @@ -34,6 +34,11 @@ // ─── Hardware fingerprint (kept for ban enforcement) ────────────────────── async function getHardwareFingerprint() { + // Disabled by the operator (websrv.anon_hw_fingerprint: false): compute nothing, forget any cached value + if (window.f0ckHwFingerprint === false) { + try { localStorage.removeItem('f0ck_anon_hw_fp'); } catch (e) {} + return null; + } try { const cached = localStorage.getItem('f0ck_anon_hw_fp'); if (cached) return cached; diff --git a/public/s/js/comments.js b/public/s/js/comments.js index c075191..7fadcdd 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -144,7 +144,7 @@ class CommentSystem { let isAtBottom = currentlyAtBottom; // Base the bottom state primarily on whether the input box / end-of-thread marker is visible - const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder'); + const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice'); if (bottomElement) { const bottomRect = bottomElement.getBoundingClientRect(); @@ -790,18 +790,25 @@ class CommentSystem { } } - // Render skeleton (Result: Layout visible immediately) + // Render skeleton only if the fetch is slow. Rendering it immediately made every item switch flash an + // empty list that the real comments then replaced a moment later. // Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments, // so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top. + let skeletonTimer = null; if (!scrollToId && !preserveScroll) { - if (this.user || !this.container.querySelector('.login-placeholder, .comment-input, .lock-notice')) { - this.render([], this.user, initialIsSubscribed); - this.restoreState(state); + if (this.user || !this.container.querySelector('.comment-input, .lock-notice')) { + skeletonTimer = setTimeout(() => { + skeletonTimer = null; + this.render([], this.user, initialIsSubscribed); + this.restoreState(state); + }, 250); } } + const cancelSkeleton = () => { if (skeletonTimer) { clearTimeout(skeletonTimer); skeletonTimer = null; } }; try { const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`); + cancelSkeleton(); // If server is restarting (502/503), res.ok will be false. if (!res.ok) throw new Error(`Server returned ${res.status}`); @@ -914,6 +921,7 @@ class CommentSystem { } } } catch (e) { + cancelSkeleton(); console.error('[CommentSystem] Error loading comments:', e); // Catch-all for network errors, 502s, JSON parse errors (e.g. server restart) // If initial load already finished (SSR or first fetch), just keep existing comments on screen. @@ -1389,7 +1397,8 @@ class CommentSystem { } else if (currentUserId) { inputSection = this.renderInput(); } else { - inputSection = '
'; + // Guests can read but not comment: an inert look-alike keeps the layout identical + inputSection = this.renderGuestInput(); } const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view'); @@ -1476,7 +1485,7 @@ class CommentSystem { }); } - const mainInput = this.container.querySelector('.main-input'); + const mainInput = this.container.querySelector('.main-input:not(.is-guest)'); if (mainInput) this.setupEmojiPicker(mainInput); // Lazy emoji load: scan the just-rendered comment text for :emoji: patterns. @@ -2383,6 +2392,24 @@ class CommentSystem { `; } + // Same markup as renderInput() so every theme styles it identically, but inert: readonly, not focusable, + // no pointer events on the controls, no submit. Clicking it opens the login modal. + renderGuestInput() { + const i18n = window.f0ckI18n || {}; + const placeholder = i18n.login_to_comment || 'Log in to comment'; + const postLabel = i18n.post || 'Post'; + return ` +
+ +
+
+ +
+
+ `; + } + renderInput(parentId = null) { const i18n = window.f0ckI18n || {}; const session = window.f0ckSession || {}; @@ -2742,7 +2769,7 @@ class CommentSystem { }); } else { // Scroll to Bottom of comments - const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice') || this.container.querySelector('.login-placeholder'); + const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice'); if (bottomElement) { bottomElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 98f8316..8c34c9f 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1,7 +1,8 @@ // Pages whose content depends on the rating/mime filter and must reload when it changes. // Profile pages count even when both previews are empty (then no .posts grid is rendered). // Elements whose horizontal drags belong to them (sliders, sideways-scrolling rows): swipe gestures ignore touches starting here. -window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]'; +// .f0ck-tuner-row: on touch devices sliders are locked (pointer-events none), so the touch lands on the row +window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-row, .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]'; window.isFilterReloadableView = function () { return !!document.querySelector('.posts, .tags-grid') || /^\/user\/[^/]+\/?$/.test(window.location.pathname); @@ -2925,8 +2926,9 @@ window.cancelAnimFrame = (function () { - if (registerBtn && registerModal) { - registerBtn.addEventListener('click', (e) => { + // The nav "Register" link is only rendered while registration is open; the modal logic must not depend on it + if (registerModal) { + if (registerBtn) registerBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(registerModal); }); @@ -3059,7 +3061,7 @@ window.cancelAnimFrame = (function () { // Delegate help button click (since it's in a partial) document.addEventListener('click', (e) => { - if (e.target.id === 'help-button') { + if (e.target.closest?.('#help-button')) { openModal(shortcutsModal); } }); @@ -22291,7 +22293,29 @@ document.addEventListener('click', (e) => { // ── Mobile scroll-to-top button ─────────────────────────────────────────────── // Visible only on mobile (≤999px), appears below #comments-container so users -// can easily get back to the top after reading comments. +// can easily get back to the top after reading comments. Only shown once the relevant scroller is +// scrolled far enough that getting back up by hand is a chore (1.5 screens, at least 600px). +(() => { + const scrollerFor = (btn) => btn.closest('#onara-modal') || document.querySelector('.index-container') || null; + const update = () => { + document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => { + const sc = scrollerFor(btn); + const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop); + const viewH = sc ? sc.clientHeight : window.innerHeight; + btn.classList.toggle('is-visible', top > Math.max(600, viewH * 1.5)); + }); + }; + let queued = false; + const schedule = () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { queued = false; update(); }); + }; + // Capture: scroll events don't bubble, and the scroller may be the window, .index-container or the Onara modal + document.addEventListener('scroll', schedule, { capture: true, passive: true }); + window.addEventListener('resize', schedule, { passive: true }); +})(); + document.addEventListener('click', (e) => { if (!e.target.closest('.mobile-scroll-to-top')) return; e.preventDefault(); @@ -24357,3 +24381,55 @@ window.BulkSelection = (() => { }; })(); + +// ─── Tap-to-activate range sliders on touch devices ─────────────────────────── +// A finger scrolling a panel full of sliders easily grabs one by accident. On coarse pointers, sliders +// ignore touches (CSS: pointer-events none) until tapped; the tapped slider unlocks and relocks when the +// user touches elsewhere or leaves it alone for a few seconds. Player controls are exempt (see LOCKABLE). +(() => { + if (!window.matchMedia || !window.matchMedia('(pointer: coarse)').matches) return; + const LOCKABLE = 'input[type="range"]:not(.v0ck input):not(#volume-slider):not([data-no-tap-lock])'; + const RELOCK_MS = 4000; + const HIT_PAD = 12; + let active = null; + let timer = null; + + const lock = () => { + if (active) active.classList.remove('range-unlocked'); + active = null; + clearTimeout(timer); + }; + const armTimer = () => { + clearTimeout(timer); + timer = setTimeout(lock, RELOCK_MS); + }; + const hits = (el, x, y) => { + const r = el.getBoundingClientRect(); + return r.width > 0 && x >= r.left - HIT_PAD && x <= r.right + HIT_PAD && y >= r.top - HIT_PAD && y <= r.bottom + HIT_PAD; + }; + + document.documentElement.classList.add('range-tap-lock'); + + // A tap (no click fires after a scroll) lands on the slider's parent because the slider has no pointer events + document.addEventListener('click', (e) => { + const target = Array.from(document.querySelectorAll(LOCKABLE)).find(el => el !== active && hits(el, e.clientX, e.clientY)); + if (!target) return; + lock(); + active = target; + active.classList.add('range-unlocked'); + armTimer(); + e.preventDefault(); + e.stopPropagation(); + }, true); + + // Touching anywhere outside the unlocked slider locks it again + document.addEventListener('touchstart', (e) => { + if (!active) return; + const t = e.touches[0]; + if (t && !hits(active, t.clientX, t.clientY)) lock(); + else armTimer(); + }, { capture: true, passive: true }); + + // Keep it unlocked while it's being used + document.addEventListener('input', (e) => { if (e.target === active) armTimer(); }, true); +})(); diff --git a/public/s/js/page-modal.js b/public/s/js/page-modal.js new file mode 100644 index 0000000..9d8f19b --- /dev/null +++ b/public/s/js/page-modal.js @@ -0,0 +1,185 @@ +/** + * page-modal.js — open info pages (ranking, rules, about, privacy, terms) in a modal over the content area + * (below the navbar, between the sidebars). + * + * Normal navigation swaps #main, which kills a playing video/audio. Links marked [data-page-modal] + * instead fetch the page, lift its #main content (with the page's diff --git a/views/snippets/visibility-modal.html b/views/snippets/visibility-modal.html index c928889..06247f8 100644 --- a/views/snippets/visibility-modal.html +++ b/views/snippets/visibility-modal.html @@ -2,6 +2,7 @@