This commit is contained in:
2026-09-28 23:24:50 +02:00
parent 2cc768f1fd
commit 73c0659d4a
29 changed files with 820 additions and 117 deletions
+3
View File
@@ -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
+25 -1
View File
@@ -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 . .
+3
View File
@@ -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,
+8
View File
@@ -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)
+2
View File
@@ -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
+56 -18
View File
@@ -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);
@@ -22974,3 +22980,35 @@ div#flash,
.chan-rehost-edit-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
/* 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;
}
+5
View File
@@ -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;
+35 -8
View File
@@ -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 = '<div class="login-placeholder"></div>';
// 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 {
</div>`;
}
// 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 `
<div class="comment-input main-input is-guest" aria-disabled="true" title="${placeholder}"
onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
<textarea placeholder="${placeholder}" readonly tabindex="-1" aria-disabled="true"></textarea>
<div class="comment-file-preview"></div>
<div class="input-actions">
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
</div>
</div>
`;
}
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 {
+81 -5
View File
@@ -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);
})();
+185
View File
@@ -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 <style>s and the #main class, which
* some page CSS hangs on) into the modal and re-run its inline scripts. The page underneath is untouched.
*
* Inside the modal, links to other modal pages load in place; any other link closes the modal and
* navigates as usual. Direct visits to these URLs are unaffected.
*/
(() => {
if (window.f0ckPageModal) return;
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms)\/?$/;
const modal = document.getElementById('page-modal');
if (!modal) return;
const body = modal.querySelector('.pgm-body');
const titleEl = modal.querySelector('.pgm-title');
let currentUrl = null;
let loadSeq = 0;
const isModalPath = (href) => {
try {
const u = new URL(href, location.origin);
return u.origin === location.origin && MODAL_PAGES.test(u.pathname);
} catch { return false; }
};
const runScripts = (root) => {
root.querySelectorAll('script').forEach((old) => {
// JSON data blocks are read by the page scripts, not executed
if (old.type && old.type !== 'text/javascript' && old.type !== 'module') return;
const s = document.createElement('script');
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
old.replaceWith(s);
});
};
const load = async (url, title) => {
const seq = ++loadSeq;
currentUrl = url;
titleEl.textContent = title || '';
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
try {
const res = await fetch(url, { credentials: 'same-origin' });
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
if (seq !== loadSeq) return;
const main = doc.getElementById('main');
if (!res.ok || !main) throw new Error(`HTTP ${res.status}`);
const wrap = document.createElement('div');
wrap.className = `${main.className} pgm-main`.trim();
wrap.innerHTML = main.innerHTML;
body.innerHTML = '';
body.appendChild(wrap);
body.scrollTop = 0;
if (!title) {
const h = wrap.querySelector('h1, h2, h3');
titleEl.textContent = h ? h.textContent.trim() : '';
}
runScripts(wrap);
} catch (e) {
if (seq !== loadSeq) return;
body.innerHTML = `<div class="pgm-loading">Could not load this page. <a href="${url}" class="pgm-fallback">Open it directly</a>.</div>`;
}
};
// ── Placement: the modal fills the content area only, so the navbar and sidebars stay usable ──
const visibleRect = (sel) => {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') return r;
}
return null;
};
const place = () => {
const vw = window.innerWidth;
const nav = visibleRect('body > nav.navbar, nav.navbar');
const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0;
// Right sidebar: counts only while it is on screen (hidden = slid out past the right edge)
const rs = visibleRect('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const right = rs && rs.left < vw - 1 && rs.left > vw / 2 ? Math.round(vw - rs.left) : 0;
// Left (comments) sidebar on item pages
const ls = visibleRect('.item-sidebar-left');
const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0;
modal.style.setProperty('--pgm-top', `${top}px`);
modal.style.setProperty('--pgm-right', `${right}px`);
modal.style.setProperty('--pgm-left', `${left}px`);
};
// Follow sidebar slide animations for a moment after a toggle
let followUntil = 0;
const follow = () => {
if (modal.style.display === 'none') return;
place();
if (performance.now() < followUntil) requestAnimationFrame(follow);
};
const followFor = (ms) => {
const running = performance.now() < followUntil;
followUntil = performance.now() + ms;
if (!running) requestAnimationFrame(follow);
};
window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true });
new MutationObserver(() => followFor(600)).observe(document.body, { attributes: true, attributeFilter: ['class'] });
// The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize):
// follow every size change so the modal's edges stay glued to them
const LAYOUT_SEL = 'nav.navbar, .global-sidebar-right, .item-sidebar-right, .index-sidebar-right, .item-sidebar-left';
const watched = new WeakSet();
const ro = typeof ResizeObserver === 'function'
? new ResizeObserver(() => { if (modal.style.display !== 'none') place(); })
: null;
const watchLayout = () => {
document.querySelectorAll(LAYOUT_SEL).forEach((el) => {
if (watched.has(el)) return;
watched.add(el);
if (ro) ro.observe(el);
// Class/style toggles inside the navbar (collapse "show", inline heights) may animate: follow for a bit
if (el.matches('nav.navbar')) {
new MutationObserver(() => { if (modal.style.display !== 'none') followFor(600); })
.observe(el, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
}
});
};
const open = (url, title) => {
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
watchLayout();
place();
modal.style.display = 'flex';
followFor(300);
load(url, title);
};
const close = () => {
modal.style.display = 'none';
body.innerHTML = '';
currentUrl = null;
};
// Capture phase: runs before the global AJAX link handler
document.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest?.('a[href]');
if (!a) return;
if (a.hasAttribute('data-page-modal')) {
e.preventDefault();
e.stopPropagation();
open(a.getAttribute('href'), a.getAttribute('title') || '');
return;
}
if (modal.style.display === 'none' || a.target === '_blank') return;
// Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows
if (!modal.contains(a)) {
const href = a.getAttribute('href') || '';
if (href.startsWith('#') || href.startsWith('javascript:')) return;
try { if (new URL(a.href, location.origin).origin !== location.origin) return; } catch { return; }
close();
return;
}
if (isModalPath(a.href)) {
e.preventDefault();
e.stopPropagation();
load(a.getAttribute('href'), '');
return;
}
// Leaving the modal for a regular page: close it and let normal navigation take over
close();
}, true);
modal.querySelector('.pgm-close').addEventListener('click', close);
// Back/forward changes the page underneath: don't leave the modal covering it
window.addEventListener('popstate', () => { if (modal.style.display !== 'none') close(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display !== 'none') close();
});
window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
})();
+10 -2
View File
@@ -1,5 +1,6 @@
import db from "./sql.mjs";
import cfg from "./config.mjs";
import { getHwFingerprintEnabled } from "./settings.mjs";
/**
* retention.mjs — automatic deletion of personal data after a configurable period.
@@ -12,6 +13,9 @@ import cfg from "./config.mjs";
* retention_sessions_days sessions unused for this long are deleted (logs that device out)
* retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long
*
* With websrv.anon_hw_fingerprint: false, every stored device fingerprint (identities and activity log) is
* cleared on each run, regardless of age.
*
* Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept
* until they expire or are lifted.
*/
@@ -80,7 +84,11 @@ export const runRetention = async () => {
await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`);
}
if (r.fingerprint) {
if (!getHwFingerprintEnabled()) {
// Fingerprinting switched off: don't keep any fingerprints collected while it was on
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null`);
await step('activity.hw_fingerprint', () => db`update anon_activity_log set hw_fingerprint = null where hw_fingerprint is not null`);
} else if (r.fingerprint) {
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`);
}
@@ -91,7 +99,7 @@ export const runRetention = async () => {
export const startRetention = () => {
const r = getRetention();
const fmt = (d) => d ? `${d}d` : 'forever';
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${fmt(r.fingerprint)}`);
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${getHwFingerprintEnabled() ? fmt(r.fingerprint) : 'disabled (purged)'}`);
setTimeout(runRetention, 30_000);
setInterval(runRetention, RUN_INTERVAL_MS);
};
+6 -2
View File
@@ -467,11 +467,15 @@ export default (router, tpl) => {
if (!ip) throw new Error('Missing IP address');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const ipHash = security.hashIP(ip);
// Already a stored hash (banned from the IP list) or a raw address typed by the moderator
const isHash = /^[a-f0-9]{64}$/i.test(ip);
const ipHash = isHash ? ip.toLowerCase() : security.hashIP(ip);
// With hashing on, the raw address is never persisted, not even in the ban list
const ipStored = (isHash || cfg.websrv.hash_user_ips) ? ipHash : ip;
await db`
INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at)
VALUES (${ip}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
VALUES (${ipStored}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
ON CONFLICT (ip) DO UPDATE
SET reason = EXCLUDED.reason,
expires_at = EXCLUDED.expires_at,
+13 -4
View File
@@ -14,7 +14,7 @@ import {
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs';
import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs';
// Maximum number of passkeys a single anonymous identity may hold
const MAX_ANON_PASSKEYS = 4;
@@ -38,6 +38,10 @@ export default router => {
return `${prefix} (${clean || 'Violation of community rules'})`;
};
// Client-supplied device fingerprint, or null when fingerprinting is disabled (then nothing is stored or matched)
const acceptHw = (hw) => (getHwFingerprintEnabled() && hw) ? String(hw).slice(0, 128) : null;
const acceptTombstone = (t) => (t && !getHwFingerprintEnabled()) ? { ...t, hw_fingerprint: null } : t;
const setBanCookie = (res, reason, expires) => {
const payload = encodeURIComponent(JSON.stringify({
banned: true,
@@ -193,7 +197,9 @@ export default router => {
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
const hwFingerprint = acceptHw(rawHw);
const tombstone = acceptTombstone(rawTombstone);
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
@@ -326,7 +332,9 @@ export default router => {
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
const hwFingerprint = acceptHw(rawHw);
const tombstone = acceptTombstone(rawTombstone);
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
@@ -502,7 +510,8 @@ export default router => {
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw } = body;
const hwFingerprint = acceptHw(rawHw);
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
+4 -1
View File
@@ -4,8 +4,9 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess } from "../settings.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess, getHwFingerprintEnabled } from "../settings.mjs";
import { getRetention } from "../retention.mjs";
import { getIpHashSource } from "../security.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -701,7 +702,9 @@ export default (router, tpl) => {
hash_ips: hashIps,
ip_mode: !logIps ? 'off' : (hashIps ? 'hashed' : 'raw'),
anon: getEnableAnonymousAccess(),
hw: getEnableAnonymousAccess() && getHwFingerprintEnabled(),
https: String(cfg.main?.url?.full || '').startsWith('https'),
ip_secret_env: getIpHashSource().startsWith('env'),
domain: cfg.main?.url?.domain || '',
ret: {
ip: period(r.ip),
+2 -2
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs, isAnonymizeSession } from "../settings.mjs";
import { getEnableItemSlugs, isAnonymizeSession, getHwFingerprintEnabled } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
import security from "../security.mjs";
@@ -723,7 +723,7 @@ export default (router, tpl) => {
const clientIp = security.getRealIP(req);
const clientIpHash = security.hashIP(clientIp);
const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null;
const clientHw = req.session?.hw_fingerprint || req.url.qs?.hw || null;
const clientHw = getHwFingerprintEnabled() ? (req.session?.hw_fingerprint || req.url.qs?.hw || null) : null;
const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
const client = {
+36 -6
View File
@@ -5,21 +5,51 @@ import cfg from "./config.mjs";
const RATE_LIMIT_WINDOW_MINUTES = 600; // 10 hours
const MAX_ATTEMPTS = 5;
/**
* IP hash secrets.
* current: IP_HASH_SECRET env var > main.ip_hash_secret > main.invite_secret (legacy fallback).
* Kept out of config.json / DB dumps when set via the environment, so a leaked dump can't be reversed.
* legacy: secrets used before a rotation. Only used to *match* old hashes (bans), never to write new ones.
* main.ip_hash_legacy_secrets (array); the invite_secret is added automatically when a dedicated
* secret is set, so bans hashed with it keep working until they expire.
*/
const ipHashSecrets = (() => {
const env = process.env.IP_HASH_SECRET || '';
const dedicated = env || cfg.main.ip_hash_secret || '';
const current = dedicated || cfg.main.invite_secret || '';
const legacy = new Set([].concat(cfg.main.ip_hash_legacy_secrets || []).filter(Boolean));
if (dedicated && cfg.main.invite_secret) legacy.add(cfg.main.invite_secret);
legacy.delete(current);
const source = env ? 'env IP_HASH_SECRET' : (cfg.main.ip_hash_secret ? 'config main.ip_hash_secret' : 'config main.invite_secret (shared, set IP_HASH_SECRET)');
return { current, legacy: [...legacy], source };
})();
export const getIpHashSource = () => ipHashSecrets.source;
export default new class {
/**
* Anonymize IP address using Hmac-SHA256 with a secret salt.
* Anonymize IP address using Hmac-SHA256 with the current IP hash secret.
* @param {string} ip
* @returns {string}
*/
hashIP(ip) {
hashIP(ip, secret = ipHashSecrets.current) {
if (!ip) return "unknown";
const secret = cfg.main.invite_secret;
if (!secret) {
throw new Error('[FATAL] invite_secret is not configured. Set it in config.json to enable IP hashing. Refusing to use a predictable fallback salt.');
throw new Error('[FATAL] No IP hash secret configured. Set IP_HASH_SECRET (env) or main.ip_hash_secret. Refusing to use a predictable fallback salt.');
}
return crypto.createHmac("sha256", secret).update(ip).digest("hex");
}
/**
* Hashes of an IP under the current and all legacy secrets (for matching data written before a rotation).
* @param {string} ip
* @returns {string[]}
*/
hashIPCandidates(ip) {
if (!ip) return [];
return [ipHashSecrets.current, ...ipHashSecrets.legacy].filter(Boolean).map(sec => this.hashIP(ip, sec));
}
/**
* Get real IP from request headers or socket.
* @param {object} req
@@ -209,11 +239,11 @@ export default new class {
if (!ip || ip === "unknown") return null;
if (cfg.main.development && ip === "127.0.0.1" && !cfg.test_ban_localhost) return null;
try {
const ipHash = this.hashIP(ip);
const hashes = this.hashIPCandidates(ip);
const rows = await db`
select id, ip, ip_hash, reason, expires_at as expires
from banned_ips
where (ip = ${ip} or ip = ${ipHash} or ip_hash = ${ipHash} or ip_hash = ${ip})
where (ip = ${ip} or ip = any(${hashes}) or ip_hash = any(${hashes}) or ip_hash = ${ip})
and (expires_at is null or expires_at > now())
limit 1
`;
+2
View File
@@ -263,6 +263,8 @@ export const getLogUserIps = () => !!cfg.websrv.log_user_ips;
export const setLogUserIps = (val) => {}; // No-op, strictly config-based
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
// Device (hardware) fingerprinting of anonymous users for ban enforcement. On unless explicitly disabled.
export const getHwFingerprintEnabled = () => cfg.websrv.anon_hw_fingerprint !== false;
export const setHashUserIps = (val) => {}; // No-op, strictly config-based
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
+7 -2
View File
@@ -21,12 +21,12 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl, getHwFingerprintEnabled } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
import security, { getIpHashSource } from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { startRetention } from "./inc/retention.mjs";
@@ -1934,6 +1934,7 @@ process.on('uncaughtException', err => {
get min_tags() { return getMinTags(); },
get registration_open() { return getRegistrationOpen(); },
// 'off' | 'hashed' | 'raw' — how IPs are persisted (security.storableIP); used for privacy disclosures
get hw_fingerprint_enabled() { return getHwFingerprintEnabled(); },
get privacy_ip_mode() { return !cfg.websrv.log_user_ips ? 'off' : (cfg.websrv.hash_user_ips ? 'hashed' : 'raw'); },
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token,
@@ -2314,5 +2315,9 @@ process.on('uncaughtException', err => {
// ── Data retention — delete / scrub personal data after websrv.retention_*_days (shown on /privacy)
startRetention();
if (cfg.websrv.hash_user_ips) {
const src = getIpHashSource();
(src.startsWith('env') ? console.log : console.warn)(`[BOOT] IP hash secret: ${src}`);
}
})();
-2
View File
@@ -49,8 +49,6 @@
}
})();
</script>
@else
<h1>About</h1>
@endif
</div>
</div>
+1 -3
View File
@@ -304,9 +304,7 @@
@elseif(session && session.is_anon && !anon_permissions.comment)
<div class="lock-notice">🔒 Anonymous comments are disabled.</div>
@else
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@include(snippets/comment-input)
@endif
</div>
@endif
+1 -3
View File
@@ -24,9 +24,7 @@
@elseif(session && session.is_anon && !anon_permissions.comment)
<div class="lock-notice">🔒 Anonymous comments are disabled.</div>
@else
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@include(snippets/comment-input)
@endif
</div>
@endif
+29 -15
View File
@@ -15,14 +15,22 @@
<span class="pv-stat-label">IP logging</span>
@if(pv.log_ips)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-off">Off</span>@endif
</div>
@if(pv.log_ips)
<div class="pv-stat">
<span class="pv-stat-label">IP storage</span>
@if(pv.ip_mode === 'hashed')<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@elseif(pv.ip_mode === 'raw')<span class="pv-pill is-warn">Plain text</span>@else<span class="pv-pill is-good">Not stored</span>@endif
@if(pv.hash_ips)<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@else<span class="pv-pill is-warn">Plain text</span>@endif
</div>
@endif
<div class="pv-stat">
<span class="pv-stat-label">Anonymous login</span>
@if(pv.anon)<span class="pv-pill is-on">Enabled</span>@else<span class="pv-pill is-off">Disabled</span>@endif
</div>
@if(pv.anon)
<div class="pv-stat">
<span class="pv-stat-label">Device fingerprinting</span>
@if(pv.hw)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-good">Off</span>@endif
</div>
@endif
<div class="pv-stat">
<span class="pv-stat-label">Transport</span>
@if(pv.https)<span class="pv-pill is-good">HTTPS</span>@else<span class="pv-pill is-warn">HTTP</span>@endif
@@ -31,7 +39,7 @@
<p class="pv-small">
@if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as <code>HMAC-SHA256(ip, server_secret)</code>. The raw address is not persisted.@endif
@if(pv.ip_mode === 'raw')IP addresses are logged and written to the database in plain text.@endif
@if(pv.ip_mode === 'off')IP addresses are not written to the database. They are only held in memory while a request is processed (e.g. to check bans and rate limits).@endif
@if(pv.ip_mode === 'off')IP addresses are not stored. The only exception is brute-force protection: login attempts keep a keyed hash of the IP for {{ pv.ret.login }}, and a moderator ban stores the hash of the banned address.@endif
</p>
</section>
@@ -41,21 +49,21 @@
<p>A cleanup job runs hourly and deletes or blanks data older than these periods.</p>
<div class="pv-table">
<div class="pv-row pv-row-head"><span>Data</span><span>Kept for</span><span>What happens after</span></div>
<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, IP, identity and device fingerprint).</span></div>
@if(pv.log_ips)<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>@endif
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, identity fingerprint@if(pv.hw), device fingerprint@endif@if(pv.log_ips), IP@endif).</span></div>
<div class="pv-row"><span>Login attempts</span><span class="@if(pv.ret_on.login)pv-ok@else pv-warn@endif">{{ pv.ret.login }}</span><span>Rows deleted (hashed IP, attempted username, result).</span></div>
<div class="pv-row"><span>Unused sessions</span><span class="@if(pv.ret_on.sessions)pv-ok@else pv-warn@endif">{{ pv.ret.sessions }}</span><span>Session deleted after this long without use; that device is logged out.</span></div>
<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>
@if(pv.hw)<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>@endif
<div class="pv-row"><span>Active bans</span><span>Until expiry</span><span>Banned IP hashes and fingerprints are kept until the ban expires or is lifted.</span></div>
<div class="pv-row"><span>Your content</span><span>Until deleted</span><span>Uploads, comments, favourites and your account itself.</span></div>
</div>
</section>
{{-- ── IP addresses ── --}}
{{-- ── IP addresses (only when IPs are stored) ── --}}
@if(pv.log_ips)
<section class="pv-sec">
<h2><i class="fa-solid fa-network-wired"></i> IP addresses</h2>
<p>The client IP is taken from the first of <code>CF-Connecting-IP</code>, <code>True-Client-IP</code>, <code>X-Client-IP</code>, <code>X-Real-IP</code>, <code>X-Forwarded-For</code> (first entry) or the TCP peer address.</p>
@if(pv.log_ips)
<p>With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:</p>
<ul class="pv-list">
<li><code>user_sessions.ip</code>: updated on each request of a logged-in session</li>
@@ -63,30 +71,33 @@
<li><code>anon_identities.created_ip / last_ip</code> and <code>anon_activity_log.ip</code> for anonymous identities</li>
<li><code>items.uploader_ip</code>, <code>comments.ip</code>, <code>reports.reporter_ip</code></li>
</ul>
@endif
@if(pv.hash_ips)
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> keyed with a server-side secret, stored as 64 hex characters. The same IP always yields the same hash, which is what makes bans and abuse correlation work; without the secret the hash can't be reversed or recomputed. This is pseudonymisation, not anonymisation: whoever holds the secret could test candidate IPs against it.</p>
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> keyed with a server-side secret, stored as 64 hex characters. The same IP always produces the same hash, which is what lets bans and rate limits work. Without the secret a hash can't be reversed or even recomputed, so a leaked database reveals no IP addresses.@if(pv.ip_secret_env) The secret is supplied through the server environment and is not part of the configuration file or database backups.@endif</p>
<p class="pv-small">Legally, hashed IPs are still <em>pseudonymised</em> personal data (GDPR Art. 4(5)), not anonymous: the operator, who holds the secret, can check whether a given IP matches a stored hash. That is how a ban recognises a returning address, and it is why hashed IPs are also subject to the retention period above.</p>
@endif
<p><strong>Always, regardless of the logging setting:</strong> login and registration attempts store an HMAC of the IP in <code>login_attempts</code> for brute-force rate limiting, and a moderator ban stores the banned IP's hash in <code>banned_ips</code>.</p>
</section>
@endif
@if(pv.anon)
{{-- ── Anonymous login ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-user-secret"></i> Anonymous login (WebAuthn passkey)</h2>
<p>No email, password or name is involved. <em>Login as Anonymous</em> creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).</p>
<p class="pv-note"><i class="fa-solid fa-key"></i> <span><strong>Hardware security keys work too.</strong> A FIDO2 key such as a <strong>YubiKey</strong> (YubiKey 5 series, Security Key series) can hold the passkey: choose "security key" / "USB or NFC" when your browser asks where to save it, then plug in or tap the key. The private key is generated on the YubiKey and can never be exported from it, so the passkey is bound to that physical key and does not sync. The key may ask for its PIN and a touch. Since a lost key means a lost identity, register a second passkey (another YubiKey or a password manager) as a backup.</span></p>
<h3>Registration</h3>
<ol class="pv-steps">
<li>The server sends creation options: relying party <code>{{ pv.domain }}</code>, a random single-use challenge, algorithm <strong>ES256</strong> (ECDSA P-256, COSE <code>-7</code>), <code>attestation: "none"</code>, and a user handle of 16 random bytes named <code>anon@{{ pv.domain }}</code> / "Anonymous". Nothing about you goes into it.</li>
<li>Your authenticator generates a key pair. The <strong>private key never leaves the authenticator</strong>.</li>
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros).</li>
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros; for a YubiKey it can reveal the key model, e.g. "YubiKey 5 NFC", but never its serial number).</li>
<li>Your identity is derived from the credential ID: <code>SHA256:base64(SHA-256(credential_id))</code>; the account name is <code>anon_</code> plus the first 8 hex characters of that hash (e.g. <code>anon_1ad1e20c</code>).</li>
</ol>
<h3>Login</h3>
<p>The server issues a random single-use challenge; your authenticator signs it with the private key and the server verifies the signature with the stored public key. Up to 4 passkeys can be attached to one identity. If all of them are lost, the identity cannot be recovered: nothing else links it to you.</p>
</section>
{{-- ── Device fingerprint ── --}}
{{-- ── Device fingerprint (only when enabled) ── --}}
@if(pv.hw)
<section class="pv-sec">
<h2><i class="fa-solid fa-microchip"></i> Device fingerprint</h2>
<p>At anonymous login and when adding a passkey, your browser computes a device fingerprint used <strong>only for ban enforcement</strong> (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.</p>
@@ -101,11 +112,12 @@
</ul>
<p>The values are concatenated and hashed <strong>in the browser</strong> with SHA-256; only <code>HW:&lt;64 hex&gt;</code> is sent. The raw values never reach the server. The hash is cached in <code>localStorage</code> (<code>f0ck_anon_hw_fp</code>) and stored server-side in <code>anon_identities.hw_fingerprint</code> and the activity log, and compared against banned device hashes.</p>
</section>
@endif
{{-- ── Activity log ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-list-check"></i> Anonymous activity log</h2>
<p><code>anon_activity_log</code> records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint, device fingerprint, and the IP @if(pv.ip_mode === 'hashed')(hashed)@endif @if(pv.ip_mode === 'off')(empty, as IP logging is off)@endif. It exists for moderation and ban cascades.</p>
<p><code>anon_activity_log</code> records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint@if(pv.hw), device fingerprint@endif@if(pv.log_ips), IP@if(pv.hash_ips) (hashed)@endif@endif. It exists for moderation and ban cascades.</p>
</section>
@endif
@@ -115,7 +127,7 @@
<ul class="pv-list">
<li><strong><code>session</code> cookie</strong>: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: <code>HttpOnly</code>, <code>SameSite=Lax</code>@if(pv.https), <code>Secure</code>@endif.</li>
<li>Per session the server keeps: user agent string, creation time, last-used time, the last path requested, a CSRF token@if(pv.log_ips), and the IP@endif.</li>
<li><code>localStorage</code>: UI preferences, and for anonymous users the device fingerprint hash.</li>
<li><code>localStorage</code>: UI preferences@if(pv.hw), and for anonymous users the device fingerprint hash@endif.</li>
<li><code>f0ck_banned</code> cookie / <code>f0ck_anon_tombstone</code>: only set if you are banned, to show the ban notice.</li>
</ul>
<p>Registered accounts: passwords are hashed with <strong>scrypt</strong> (random 16-byte salt, 64-byte key). The plain password is never stored.</p>
@@ -123,7 +135,7 @@
<section class="pv-sec">
<h2><i class="fa-solid fa-ban"></i> Not collected</h2>
<p>For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.</p>
<p>For anonymous identities: no email, real name, phone number or password@if(pv.anon)@if(!pv.hw), and no device fingerprint: device fingerprinting is disabled on this instance@endif@endif. No third-party analytics, trackers or ad networks are involved in authentication.</p>
</section>
<p class="pv-foot">Questions? See <a href="/about">About</a>@if(mail) or write to <a href="mailto:{!! mail !!}">{!! mail !!}</a>@endif.</p>
@@ -152,10 +164,12 @@
.pv a { color: var(--pv-accent); }
.pv code { padding: 1px 5px; font-size: 0.86em; background: var(--pv-surface); border: 1px solid var(--pv-border); word-break: break-word; }
.pv-small { font-size: 0.88em; color: var(--pv-muted); }
.pv-note { display: flex; gap: 10px; margin: 0 0 12px; padding: 10px 14px; font-size: 0.9em; background: var(--pv-surface); border: 1px solid var(--pv-border); border-left: 3px solid var(--pv-accent); }
.pv-note > i { color: var(--pv-accent); margin-top: 4px; }
.pv-steps, .pv-list { margin: 0 0 12px; padding-left: 22px; }
.pv-steps li, .pv-list li { margin-bottom: 6px; }
.pv-status { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
.pv-status { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
.pv-stat { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--bg, #000); }
.pv-stat-label { font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
.pv-pill { align-self: flex-start; padding: 3px 9px; font-size: 0.78em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid currentColor; }
-2
View File
@@ -49,8 +49,6 @@
}
})();
</script>
@else
<h1>Rules</h1>
@endif
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
{{-- Server copy of the main comment input: must match comments.js renderInput() + setupEmojiPicker() (users) or renderGuestInput() (guests) exactly, so the client re-render swaps in an identical box without any layout shift --}}
@if(session)
<div class="comment-input main-input">
<textarea placeholder="{{ t('comments.write_comment') }}"@if(comment_max_length !== null && comment_max_length !== undefined) maxlength="{{ comment_max_length }}"@endif required></textarea>
<div class="comment-file-preview"></div>
<div class="input-actions">
@if(comment_max_length !== null && comment_max_length !== undefined)<span class="char-counter" data-max="{{ comment_max_length }}">0 / {{ comment_max_length }}</span>@endif
@if(allow_fileupload_comments && !session.is_anon || allow_fileupload_comments && anon_permissions.comment_attachments !== false)<button class="comment-attach-btn" title="{{ t('comments.attach_file') }}" type="button"><i class="fa-solid fa-paperclip"></i></button>@endif
@if(enable_comment_polls)<button class="comment-poll-btn" title="{{ t('comments.poll_btn_title') || 'Create poll' }}" type="button"><i class="fa-solid fa-chart-bar"></i></button>@endif
@if(is_mod_or_admin)<button id="lock-thread-btn" class="admin-lock-btn" title="{{ item.is_comments_locked ? 'Unlock Thread' : 'Lock Thread' }}">@if(item.is_comments_locked)<i class="fa-solid fa-lock"></i>@else<i class="fa-solid fa-lock-open"></i>@endif</button>@endif
<button class="spoiler-trigger" title="Insert spoiler tag">[S]</button>
<button class="emoji-trigger"><i class="fa-regular fa-face-smile"></i></button>
<button class="submit-comment"><span class="submit-label">{{ t('comments.post') }}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
</div>
</div>
@else
<div class="comment-input main-input is-guest" aria-disabled="true" title="Log in to comment" onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
<textarea placeholder="Log in to comment" readonly tabindex="-1" aria-disabled="true"></textarea>
<div class="comment-file-preview"></div>
<div class="input-actions">
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">{{ t('comments.post') }}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
</div>
</div>
@endif
+10 -7
View File
@@ -2,6 +2,7 @@
<div id="mod-action-modal" class="modal-overlay" style="display:none;">
<div class="modal-content ma-modal-content">
<div class="ma-header">
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('mod-action-cancel').click()">&times;</button>
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
</div>
<div class="ma-body">
@@ -36,7 +37,7 @@
color: var(--ma-text);
}
#mod-action-modal .ma-modal-content * { border-radius: 0 !important; }
#mod-action-modal .ma-header { flex-shrink: 0; padding: 18px 24px 14px; border-bottom: 1px solid var(--ma-border); }
#mod-action-modal .ma-header { position: relative; flex-shrink: 0; padding: 18px 64px 14px 24px; border-bottom: 1px solid var(--ma-border); }
#mod-action-modal .ma-header h3 { margin: 0; font-size: 1.2em; font-weight: 700; color: var(--ma-text); overflow-wrap: anywhere; }
#mod-action-modal .ma-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
@@ -143,6 +144,7 @@
<div id="report-modal" class="modal-overlay" style="display:none;">
<div class="modal-content report-modal-content">
<div class="rm-header">
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('report-cancel').click()">&times;</button>
<h3>{{ t('report.title') }}</h3>
<p>Select one or more reasons and optionally add details below.</p>
</div>
@@ -217,7 +219,7 @@
color: var(--rm-text);
}
#report-modal .report-modal-content * { border-radius: 0 !important; }
#report-modal .rm-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--rm-border); }
#report-modal .rm-header { position: relative; flex-shrink: 0; padding: 20px 64px 14px 24px; border-bottom: 1px solid var(--rm-border); }
#report-modal .rm-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
#report-modal .rm-body > :last-child { margin-bottom: 0; }
#report-modal .rm-header h3 { margin: 0; font-size: 1.25em; font-weight: 700; color: var(--rm-text); }
@@ -415,14 +417,15 @@
</div>
</div>
<div class="global-sidebar-right-footer">
<a href="/ranking">{{ t('footer.ranking') }}</a>
<a href="/rules">{{ t('footer.rules') }}</a>
<a href="/about">{{ t('footer.about') }}</a>
<a href="/privacy">Privacy</a>
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts">?</div>
<a href="/ranking" data-page-modal title="{{ t('footer.ranking') }}" aria-label="{{ t('footer.ranking') }}"><i class="fa-solid fa-users-between-lines"></i></a>
<a href="/rules" data-page-modal title="{{ t('footer.rules') }}" aria-label="{{ t('footer.rules') }}"><i class="fa-solid fa-scale-balanced"></i></a>
<a href="/about" data-page-modal title="{{ t('footer.about') }}" aria-label="{{ t('footer.about') }}"><i class="fa-solid fa-info"></i></a>
<a href="/privacy" data-page-modal title="Privacy" aria-label="Privacy"><i class="fa-solid fa-shield-halved"></i></a>
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts" aria-label="Keyboard Shortcuts"><i class="fa-solid fa-question"></i></div>
</div>
</div>
@endif
@include(snippets/page-modal)
@if(!private_society || session)
<script src="/s/js/sanitizer.js?v={{ ts }}"></script>
+1 -1
View File
@@ -67,7 +67,7 @@
@endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckCustomBrandText = "{{ custom_navbar_brand_text || '' }}"; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }};</script>
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckCustomBrandText = "{{ custom_navbar_brand_text || '' }}"; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }}; window.f0ckHwFingerprint = {{ hw_fingerprint_enabled ? 'true' : 'false' }};</script>
@if(!private_society || session)
@if(enable_comments)
<script src="/s/js/marked.min.js" defer></script>
+224 -31
View File
@@ -256,7 +256,7 @@
@endif
</a>
<div class="nav-collapse" id="navbarContent">
<div class="nav-collapse nav-collapse-guest" id="navbarContent">
<div class="nav-links">
<a href="/tags" title="Tags"><i class="fa-solid fa-tags"></i></a>
@if(halls_enabled)
@@ -294,7 +294,7 @@
<span id="nav-anon-label">@if(enable_anonymous_access && session && session.is_anon)anonymous@else guest @endif</span>
<span class="nav-avatar-caret">▾</span>
</button>
<div class="nav-user-menu" id="nav-visitor-menu" style="min-width: max-content; width: max-content; white-space: nowrap;">
<div class="nav-user-menu" id="nav-visitor-menu" style="min-width: 100%; width: max-content; white-space: nowrap;">
@if(enable_anonymous_access && session && session.is_anon)
<a href="/favs" id="nav-guest-favs-link" class="mobile-only">{{ t('nav.favs') }}</a>
@endif
@@ -304,7 +304,9 @@
<a href="/settings" id="nav-anon-settings-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a>
@endif
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
@if(registration_open)
<a href="#" id="nav-register-btn">{{ t('nav.register') }}</a>
@endif
@if(enable_anonymous_access)
<div class="nav-user-divider" id="nav-anon-divider" @if(!session || !session.is_anon) style="display:none;" @endif></div>
<a href="#" id="nav-anon-logout-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-right-from-bracket"></i> Logout (Guest)</a>
@@ -446,10 +448,6 @@ async function loginWithPasskey() {
<!-- Tab: Passkey -->
<div id="login-panel-passkey" class="lm-panel lm-panel-passkey" style="display: none;">
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
<p class="lm-passkey-text">
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
</p>
<button type="button" id="modal-login-passkey-btn" class="lm-btn lm-btn-primary" onclick="loginWithPasskey()">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
</button>
@@ -463,9 +461,11 @@ async function loginWithPasskey() {
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
<i class="fa-solid fa-user-secret"></i> Login as Anonymous
</button>
@if(registration_open)
<button type="button" id="modal-login-register-btn" class="lm-btn lm-btn-ghost" style="margin-top: 8px;">
<i class="fa-solid fa-user-plus"></i> Register
</button>
@endif
</div>
@endif
@@ -653,6 +653,10 @@ async function loginWithPasskey() {
background: var(--lm-surface); border: 1px solid var(--lm-border); border-left: 2px solid var(--lm-accent);
}
#anon-setup-modal #anon-setup-view .lm-note i { color: var(--lm-accent); margin-right: 4px; }
#anon-setup-modal #anon-setup-view .lm-title { margin-bottom: 18px; }
#anon-setup-modal #anon-setup-view .anon-consent { margin: 0 0 16px; text-align: left; }
/* Disabled until the privacy consent is ticked (spinner state keeps the progress cursor) */
#anon-setup-modal #anon-setup-view .lm-btn:disabled:not(:has(.fa-spinner)) { cursor: not-allowed; opacity: 0.45; }
#anon-setup-modal #anon-setup-view .lm-textbtn {
margin: 0; padding: 0; background: none; border: 0;
font: inherit; font-weight: 600; color: var(--lm-accent); cursor: pointer;
@@ -816,20 +820,16 @@ async function loginWithPasskey() {
<div id="anon-setup-view">
<!-- New user view (no passkey yet) -->
<div id="anon-setup-new" class="lm-panel lm-panel-passkey">
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
<h2 class="lm-title">Create an anonymous passkey</h2>
<p class="lm-passkey-text" style="margin-top: 12px;">
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
No account, no email, no password. Your comments and favorites are tied to this passkey.
</p>
<p class="lm-note">
<i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
</p>
<p class="lm-note">
<i class="fa-solid fa-shield-halved"></i> To stop ban evasion we store a hashed device fingerprint@if(privacy_ip_mode === 'hashed') and a hashed IP address@elseif(privacy_ip_mode === 'raw') and your IP address@endif. <a href="/privacy" target="_blank" class="lm-link">What exactly is stored?</a>
</p>
<h2 class="lm-title">Create passkey</h2>
<div class="lm-row anon-consent">
<label class="lm-check lm-check-multiline" for="anon-consent-new">
<input type="checkbox" id="anon-consent-new" class="anon-consent-input" />
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
<span>I have read the <a href="/privacy" class="lm-link anon-consent-open">privacy page</a> and accept how my data is processed.</span>
</label>
</div>
<button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary">
<i class="fa-solid fa-fingerprint"></i> Create my passkey
<i class="fa-solid fa-fingerprint"></i> Create
</button>
<div id="anon-setup-new-error" class="lm-error" style="display: none;"></div>
<p class="lm-footer">
@@ -839,13 +839,9 @@ async function loginWithPasskey() {
<!-- Returning user view (has passkey) -->
<div id="anon-setup-returning" class="lm-panel lm-panel-passkey" style="display: none;">
<div class="lm-passkey-icon"><i class="fa-solid fa-user-secret"></i></div>
<h2 class="lm-title">Use your anonymous passkey</h2>
<p class="lm-passkey-text" style="margin-top: 12px;">
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
</p>
<h2 class="lm-title">Authenticate with passkey</h2>
<button type="button" id="anon-setup-auth-btn" class="lm-btn lm-btn-primary">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
<i class="fa-solid fa-fingerprint"></i> Authenticate
</button>
<div id="anon-setup-ret-error" class="lm-error" style="display: none;"></div>
<p class="lm-footer">
@@ -855,6 +851,64 @@ async function loginWithPasskey() {
</div>
</div>
</div>
<!-- Privacy consent modal: opened from the "Create passkey" consent checkbox; must be read to the end -->
<div id="privacy-consent-modal" style="display: none;">
<div class="pc-content">
<div class="pc-header">
<h3><i class="fa-solid fa-shield-halved"></i> Privacy</h3>
<button type="button" id="privacy-consent-close" class="pc-close" aria-label="Close">&times;</button>
</div>
<div class="pc-body" id="privacy-consent-body"></div>
<div class="pc-footer">
<span class="pc-hint" id="privacy-consent-hint">Scroll to the end to accept.</span>
<button type="button" id="privacy-consent-accept" class="pc-accept" disabled><i class="fa-solid fa-check"></i> Accept</button>
</div>
</div>
</div>
<style>
#privacy-consent-modal {
position: fixed; inset: 0; z-index: 100000;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(4px);
}
#privacy-consent-modal .pc-content {
width: 100vw; height: 100vh; height: 100dvh;
display: flex; flex-direction: column; overflow: hidden;
background: var(--bg); border: 0; border-radius: 0;
}
#privacy-consent-modal .pc-header {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 20px; border-bottom: 1px solid var(--accent);
}
#privacy-consent-modal .pc-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; }
#privacy-consent-modal .pc-header h3 i { color: var(--accent); }
#privacy-consent-modal .pc-close {
width: 36px; height: 36px; margin: 0; padding: 0; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
background: none; border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 0;
color: var(--text-color, #fff); font-size: 1.6em; line-height: 1; cursor: pointer; transition: color 0.15s, border-color 0.15s;
}
#privacy-consent-modal .pc-close:hover { color: var(--accent); border-color: var(--accent); }
#privacy-consent-modal .pc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
#privacy-consent-modal .pc-body .pv { padding-top: 20px; }
#privacy-consent-modal .pc-loading { padding: 40px 20px; text-align: center; color: var(--text-muted, #aaa); }
#privacy-consent-modal .pc-footer {
flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; gap: 16px;
padding: 12px 20px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2);
}
#privacy-consent-modal .pc-hint { font-size: 0.85em; color: var(--text-muted, #aaa); }
#privacy-consent-modal .pc-accept {
height: 42px; padding: 0 22px; margin: 0; font: inherit; font-size: 0.85em; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.08em; cursor: pointer; border-radius: 0;
display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: #000; border: 1px solid var(--accent); transition: background 0.18s, color 0.18s;
}
#privacy-consent-modal .pc-accept:hover:not(:disabled) { background: transparent; color: var(--accent); }
#privacy-consent-modal .pc-accept:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 600px) {
#privacy-consent-modal .pc-footer { flex-direction: column; align-items: stretch; gap: 8px; text-align: center; }
#privacy-consent-modal .pc-accept { justify-content: center; }
}
</style>
<script>
(function() {
function closeSetupModal() {
@@ -874,10 +928,95 @@ async function loginWithPasskey() {
document.getElementById('anon-setup-returning').style.display = 'none';
document.getElementById('anon-setup-new').style.display = '';
});
// Privacy consent (new passkeys only): the checkbox can only be ticked by reading the privacy page in
// the consent modal to the end. Accepting is remembered in localStorage and the checkbox never shows again.
var CONSENT_KEY = 'f0ck_privacy_accepted';
var consentInput = document.getElementById('anon-consent-new');
var hasConsent = function() {
try { return !!localStorage.getItem(CONSENT_KEY); } catch (e) { return false; }
};
var syncConsent = function() {
var accepted = hasConsent();
document.querySelectorAll('#anon-setup-modal .anon-consent').forEach(function(el) { el.style.display = accepted ? 'none' : ''; });
var btn = document.getElementById('anon-setup-create-btn');
if (btn && !btn.querySelector('.fa-spinner')) btn.disabled = !(accepted || (consentInput && consentInput.checked));
};
var recordConsent = function() {
if (hasConsent()) return true;
if (!consentInput || !consentInput.checked) return false;
try { localStorage.setItem(CONSENT_KEY, JSON.stringify({ accepted_at: new Date().toISOString() })); } catch (e) {}
return true;
};
var pcModal = document.getElementById('privacy-consent-modal');
var pcBody = document.getElementById('privacy-consent-body');
var pcAccept = document.getElementById('privacy-consent-accept');
var pcHint = document.getElementById('privacy-consent-hint');
var pcLoaded = false;
var pcCheckEnd = function() {
if (!pcLoaded) return;
var atEnd = pcBody.scrollTop + pcBody.clientHeight >= pcBody.scrollHeight - 8;
if (atEnd) {
pcAccept.disabled = false;
if (pcHint) pcHint.textContent = 'You reached the end.';
}
};
var loadPrivacy = async function() {
if (pcLoaded) return;
pcBody.innerHTML = '<div class="pc-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>';
try {
var r = await fetch('/privacy', { credentials: 'same-origin' });
var doc = new DOMParser().parseFromString(await r.text(), 'text/html');
var pv = doc.querySelector('#main .pv');
if (!r.ok || !pv) throw new Error('bad response');
pcBody.innerHTML = '';
doc.querySelectorAll('#main style').forEach(function(st) { pcBody.appendChild(st.cloneNode(true)); });
pcBody.appendChild(document.importNode(pv, true));
pcBody.querySelectorAll('a[href]').forEach(function(a) { a.target = '_blank'; a.rel = 'noopener'; });
} catch (e) {
pcBody.innerHTML = '<div class="pc-loading">Could not load the privacy page. <a href="/privacy" target="_blank" class="lm-link">Open it in a new tab</a>.</div>';
}
pcLoaded = true;
};
var openConsentModal = async function() {
if (!pcModal) return;
pcAccept.disabled = true;
if (pcHint) pcHint.textContent = 'Scroll to the end to accept.';
pcModal.style.display = 'flex';
await loadPrivacy();
pcBody.scrollTop = 0;
// Short content that doesn't scroll counts as read
requestAnimationFrame(pcCheckEnd);
};
var closeConsentModal = function() { if (pcModal) pcModal.style.display = 'none'; };
if (pcBody) pcBody.addEventListener('scroll', pcCheckEnd, { passive: true });
if (pcAccept) pcAccept.addEventListener('click', function() {
if (consentInput) consentInput.checked = true;
closeConsentModal();
syncConsent();
});
var pcClose = document.getElementById('privacy-consent-close');
if (pcClose) pcClose.addEventListener('click', closeConsentModal);
if (consentInput) consentInput.addEventListener('click', function(e) {
// Ticking goes through the privacy modal; unticking is always allowed
if (!consentInput.checked) return syncConsent();
e.preventDefault();
openConsentModal();
});
document.querySelectorAll('#anon-setup-modal .anon-consent-open').forEach(function(a) {
a.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); openConsentModal(); });
});
syncConsent();
// Re-sync whenever the modal is opened (another tab may have accepted meanwhile)
if (overlay) new MutationObserver(function() { if (overlay.style.display !== 'none') syncConsent(); }).observe(overlay, { attributes: true, attributeFilter: ['style'] });
var createBtn = document.getElementById('anon-setup-create-btn');
if (createBtn) createBtn.addEventListener('click', async function() {
var errEl = document.getElementById('anon-setup-new-error');
if (errEl) errEl.style.display = 'none';
if (!recordConsent()) return;
createBtn.disabled = true;
createBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving passkey...';
try {
@@ -893,7 +1032,7 @@ async function loginWithPasskey() {
}
}
createBtn.disabled = false;
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create my passkey';
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create';
}
});
var authBtn = document.getElementById('anon-setup-auth-btn');
@@ -915,7 +1054,7 @@ async function loginWithPasskey() {
}
}
authBtn.disabled = false;
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey';
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Authenticate';
}
});
})();
@@ -925,10 +1064,12 @@ async function loginWithPasskey() {
@if(!private_society || session)
<!-- Shortcuts Modal -->
<div id="shortcuts-modal" style="display: none;">
<div class="login-modal-content">
<button id="shortcuts-modal-close">&times;</button>
<div class="shortcuts-content">
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('shortcuts.title') }}</h2>
<div class="login-modal-content sc-content">
<div class="sc-header">
<h2><i class="fa-solid fa-keyboard"></i> {{ t('shortcuts.title') }}</h2>
<button type="button" id="shortcuts-modal-close" aria-label="Close">&times;</button>
</div>
<div class="shortcuts-content sc-body">
<div class="shortcut-list">
<div class="shortcut-item"><span><kbd>k</kbd></span><span>{{ t('shortcuts.search') }}</span></div>
<div class="shortcut-item"><span><kbd>m</kbd></span><span>{{ t('shortcuts.main_page') }}</span></div>
@@ -963,6 +1104,58 @@ async function loginWithPasskey() {
</div>
</div>
<style>
/* Keyboard shortcuts modal: style guide (square, flat, accent border), fixed header, only the list scrolls */
#shortcuts-modal .sc-content {
--sc-muted: var(--text-muted, #8a8f98);
--sc-border: rgba(255, 255, 255, 0.1);
width: min(520px, 92vw); max-width: none; max-height: min(90vh, 90dvh);
display: flex; flex-direction: column; overflow: hidden;
margin: 0; padding: 0; text-align: left;
background: var(--bg) !important; border: 1px solid var(--accent) !important;
border-radius: 0 !important; box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
}
#shortcuts-modal .sc-content * { border-radius: 0 !important; }
#shortcuts-modal .sc-header {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 16px 20px; border-bottom: 1px solid var(--sc-border);
}
#shortcuts-modal .sc-header h2 {
display: flex; align-items: center; gap: 10px; margin: 0;
font-size: 1.05em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#shortcuts-modal .sc-header h2 i { color: var(--accent); }
#shortcuts-modal #shortcuts-modal-close {
position: static; width: 36px; height: 36px; margin: 0; padding: 0; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: none; border: 1px solid rgba(255, 255, 255, 0.25); color: var(--text-color, #fff);
font-size: 1.6em; line-height: 1; opacity: 1; cursor: pointer; transition: color 0.15s, border-color 0.15s;
}
#shortcuts-modal #shortcuts-modal-close:hover { color: var(--accent); border-color: var(--accent); }
#shortcuts-modal .sc-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding: 6px 20px 16px; scrollbar-width: thin; scrollbar-color: var(--accent) transparent;
}
#shortcuts-modal .shortcut-list { gap: 0; margin: 0; }
#shortcuts-modal .shortcut-item {
align-items: center; margin: 0; padding: 10px 0; border-bottom: 1px solid var(--sc-border);
}
#shortcuts-modal .shortcut-item:last-child { border-bottom: 0; }
#shortcuts-modal .shortcut-item span:last-child { color: var(--sc-muted); opacity: 1; }
#shortcuts-modal .shortcut-item kbd {
display: inline-block; min-width: 22px; padding: 2px 7px; text-align: center;
font-size: 0.85em; font-family: inherit; font-weight: 700;
background: transparent; border: 1px solid var(--accent); color: var(--accent); box-shadow: none;
}
@media (max-width: 600px) {
#shortcuts-modal { padding: 0 !important; align-items: stretch !important; }
#shortcuts-modal .sc-content {
width: 100vw; height: 100vh; height: 100dvh; max-height: none;
border: 0 !important; box-shadow: none !important;
}
#shortcuts-modal .sc-body { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
}
</style>
<div id="flash-container"></div>
@endif
+44
View File
@@ -0,0 +1,44 @@
{{-- Fullscreen modal for info pages opened from the sidebar footer (see /s/js/page-modal.js) --}}
<div id="page-modal" style="display: none;" role="dialog" aria-modal="true">
<div class="pgm-header">
<h3 class="pgm-title"></h3>
<div class="pgm-actions">
<button type="button" class="pgm-close" title="Close" aria-label="Close">&times;</button>
</div>
</div>
<div class="pgm-body"></div>
</div>
<style>
#page-modal {
/* Covers only the content area: below the navbar, between the sidebars (page-modal.js sets the insets).
Just below the sidebar drag handles (899) and sidebars (900) so they keep working while it's open. */
position: fixed; z-index: 895;
top: var(--pgm-top, 0px); right: var(--pgm-right, 0px); bottom: 0; left: var(--pgm-left, 0px);
flex-direction: column;
background: var(--bg); color: var(--text-color, #fff);
}
#page-modal .pgm-header {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid var(--accent);
}
#page-modal .pgm-title {
margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#page-modal .pgm-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
#page-modal .pgm-close {
display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
margin: 0; padding: 0; background: none; border: 1px solid transparent; border-radius: 0;
color: var(--text-color, #fff); font-size: 1em; line-height: 1; cursor: pointer; text-decoration: none;
transition: color 0.15s, border-color 0.15s;
}
#page-modal .pgm-close { font-size: 1.6em; border-color: rgba(255, 255, 255, 0.25); }
#page-modal .pgm-close:hover { color: var(--accent); border-color: var(--accent); }
#page-modal .pgm-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
#page-modal .pgm-loading { padding: 60px 20px; text-align: center; color: var(--text-muted, #aaa); }
#page-modal .pgm-loading a { color: var(--accent); }
</style>
<script src="/s/js/page-modal.js?v={{ ts }}"></script>
+2 -1
View File
@@ -2,6 +2,7 @@
<div id="visibility-modal" class="modal-overlay" style="display: none;">
<div class="modal-content vis-modal-content">
<div class="vis-header">
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('visibility-modal-cancel').click()">&times;</button>
<h3><i class="fa-solid fa-eye"></i> Change visibility</h3>
<p>Choose who can see this post.</p>
</div>
@@ -74,7 +75,7 @@
}
#visibility-modal .vis-modal-content * { border-radius: 0 !important; }
#visibility-modal .vis-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--vm-border); }
#visibility-modal .vis-header { position: relative; flex-shrink: 0; padding: 20px 64px 14px 24px; border-bottom: 1px solid var(--vm-border); }
#visibility-modal .vis-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.2em; font-weight: 700; color: var(--vm-text); }
#visibility-modal .vis-header h3 i { color: var(--vm-accent); font-size: 0.9em; }
#visibility-modal .vis-header p { margin: 6px 0 0; font-size: 0.85em; color: var(--vm-muted); }