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 ` +