This commit is contained in:
2026-09-13 04:05:59 +02:00
parent 90860b9279
commit 340c825019
48 changed files with 2727 additions and 532 deletions
+29
View File
@@ -18,6 +18,7 @@
"invite_secret": "YOUR_SECRET_HERE",
"hide_comments_from_public": false,
"guest_anonymize": false,
"anon_anonymize": false,
"timezone": "UTC",
"development": true
},
@@ -36,6 +37,34 @@
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"enable_anonymous_access": true,
"anonymous_permissions": {
"upload": false,
"comment": true,
"comment_attachments": false,
"comment_vote": true,
"poll_vote": true,
"tag": true,
"tag_vote": true,
"favorite": true,
"rate_item": false,
"filter": true,
"exclude_tags": true,
"anonymize_users": false,
"allowed_modes": [
"sfw",
"nsfw",
"untagged",
"all",
"nsfl"
],
"allowed_mimes": [
"image",
"video",
"audio",
"flash",
"pdf"
]
},
"onara": false,
"nsfl_tag_id": 4,
"allowedMimes": [
+229 -13
View File
@@ -7486,6 +7486,112 @@ a.removetag i {
vertical-align: middle;
}
/* Tag Badge & Hover-to-Exclude Action */
.tag-badge {
display: inline-flex;
align-items: center;
position: relative;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
vertical-align: middle;
}
.tag-badge a.tag-name {
color: inherit;
text-decoration: none;
transition: opacity 0.15s ease;
}
.tag-badge .tag-exclude-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
outline: none;
transition: all 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (hover: hover) {
.tag-badge:not(.tag-is-excluded) .tag-exclude-btn {
opacity: 0;
width: 0;
max-width: 0;
margin-left: 0;
overflow: hidden;
transform: scale(0.5);
pointer-events: none;
}
.tag-badge:hover .tag-exclude-btn,
.tag-badge:focus-within .tag-exclude-btn {
opacity: 0.85;
width: 14px;
max-width: 14px;
margin-left: 5px;
overflow: visible;
transform: scale(1);
pointer-events: auto;
}
}
@media (hover: none) {
.tag-badge .tag-exclude-btn {
opacity: 0.7;
width: 14px;
max-width: 14px;
margin-left: 4px;
overflow: visible;
pointer-events: auto;
transform: scale(1);
}
}
.tag-badge .tag-exclude-btn:hover,
.tag-badge .tag-exclude-btn:focus-visible {
opacity: 1;
color: #ff4757 !important;
transform: scale(1.28);
filter: drop-shadow(0 0 6px rgba(255, 71, 87, 0.75));
}
/* Excluded Tag State */
.tag-badge.tag-is-excluded {
opacity: 0.75;
box-shadow: inset 0 0 0 1px rgba(255, 71, 87, 0.55), 0 0 8px rgba(255, 71, 87, 0.2);
background-color: rgba(255, 71, 87, 0.15) !important;
}
.tag-badge.tag-is-excluded a.tag-name {
text-decoration: line-through;
text-decoration-color: rgba(255, 71, 87, 0.8);
text-decoration-thickness: 1.5px;
opacity: 0.85;
}
.tag-badge.tag-is-excluded .tag-exclude-btn {
opacity: 0.95;
width: 14px;
max-width: 14px;
margin-left: 5px;
overflow: visible;
pointer-events: auto;
transform: scale(1);
color: #ff4757;
}
.tag-badge.tag-is-excluded .tag-exclude-btn:hover {
color: #2ed573 !important;
filter: drop-shadow(0 0 6px rgba(46, 213, 115, 0.75));
}
.badge-greentext {
color: #789922;
text-shadow: inherit !important;
@@ -10106,6 +10212,26 @@ input#s_avatar {
cursor: pointer;
}
.nav-mime-item.locked {
cursor: not-allowed !important;
opacity: 0.85;
}
.nav-mime-item.locked:hover {
background: transparent !important;
color: #ccc !important;
}
.nav-mime-item.locked input[type="checkbox"] {
cursor: not-allowed !important;
pointer-events: none !important;
}
.filter-pill.locked {
cursor: not-allowed !important;
opacity: 0.85;
}
.nav-mime-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
@@ -11715,23 +11841,19 @@ html[theme="f0ck95d"] .badge-dark {
vertical-align: middle;
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease;
transition: opacity 0.15s ease;
}
.steuerung a:hover,
.steuerung button:hover {
color: var(--accent);
opacity: 0.85;
transform: translateY(-1px);
opacity: 0.8;
}
.steuerung a:active,
.steuerung button:active,
.steuerung a.is-clicked,
.steuerung button.is-clicked {
transform: scale(0.92) translateY(1px);
opacity: 0.65;
transition-duration: 0.05s;
opacity: 0.6;
}
.steuerung.steuerung-icon {
@@ -11745,7 +11867,7 @@ html[theme="f0ck95d"] .badge-dark {
align-items: center;
justify-content: center;
width: 1.6em;
transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease;
transition: opacity 0.15s ease;
background: none;
border: none;
padding: 0;
@@ -11755,17 +11877,14 @@ html[theme="f0ck95d"] .badge-dark {
.steuerung.steuerung-icon a:hover,
.steuerung.steuerung-icon button:hover {
color: var(--accent);
transform: translateY(-1px);
opacity: 0.8;
}
.steuerung.steuerung-icon a:active,
.steuerung.steuerung-icon button:active,
.steuerung.steuerung-icon a.is-clicked,
.steuerung.steuerung-icon button.is-clicked {
transform: scale(0.88) translateY(1px);
opacity: 0.65;
transition-duration: 0.05s;
opacity: 0.6;
}
html[theme='light'] .steuerung a,
@@ -12503,6 +12622,11 @@ input:checked+.slider:before {
grid-column: 1;
}
.rating-selector.locked {
cursor: not-allowed;
opacity: 0.9;
}
.rating-toggle-btn {
display: inline-block;
background: transparent;
@@ -12528,6 +12652,12 @@ input:checked+.slider:before {
background: rgba(255, 255, 255, 0.08);
}
.rating-toggle-btn.locked {
cursor: not-allowed !important;
pointer-events: none !important;
opacity: 0.85 !important;
}
/* Per-rating active colours */
.rating-toggle-btn.active[data-rating="sfw"] {
background: #2e7d32;
@@ -15670,6 +15800,41 @@ span.gchat-post-card--loading {
gap: 5px;
}
/* Excluded Tags Filter Modal Section */
.excluded-tags-section-header {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.excluded-tags-section-title {
font-size: 1.05em;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
gap: 7px;
}
.excluded-tags-section-title i {
color: #ff4757;
font-size: 0.9em;
}
.excluded-tags-count-badge {
background: #ff4757;
color: #fff;
font-size: 0.72em;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
line-height: 1.2;
box-shadow: 0 2px 6px rgba(255, 71, 87, 0.4);
}
#nav_excluded_tags_list {
margin-bottom: 15px;
display: flex;
@@ -15677,6 +15842,57 @@ span.gchat-post-card--loading {
gap: 8px;
justify-content: center;
max-width: 100%;
min-height: 28px;
align-items: center;
}
.excluded-tag-chip {
padding: 5px 12px;
border-radius: 20px;
background: rgba(255, 71, 87, 0.12);
border: 1px solid rgba(255, 71, 87, 0.35);
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
color: #f1f2f6;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.excluded-tag-chip:hover {
background: rgba(255, 71, 87, 0.2);
border-color: rgba(255, 71, 87, 0.55);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(255, 71, 87, 0.25);
}
.excluded-tag-chip .remove-excluded-tag {
color: #ff4757;
text-decoration: none;
font-weight: bold;
font-size: 1.15em;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
transition: all 0.2s ease;
cursor: pointer;
}
.excluded-tag-chip .remove-excluded-tag:hover {
color: #fff;
background: #ff4757;
transform: scale(1.18);
}
.no-excluded-tags-hint {
color: rgba(255, 255, 255, 0.4);
font-size: 0.9em;
font-style: italic;
}
.nav-exclude {
+21 -3
View File
@@ -99,7 +99,7 @@
if (canManage) {
span.classList.add('can-cycle');
}
} else if (tag.display_name || tag.user) {
} else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
span.setAttribute('tooltip', tag.display_name || tag.user);
}
@@ -226,6 +226,13 @@
const toggleFavEvent = async (e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
}
return;
}
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
@@ -234,10 +241,11 @@
const favoBtn = document.querySelector("#a_favo");
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
try {
const res = await post('/api/v2/togglefav', {
postid: postid
});
if (res.success) {
if (res && res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
@@ -251,7 +259,7 @@
const favcontainer = document.querySelector('#favs');
favcontainer.innerHTML = "";
if (res.favs.length > 0) {
if (res.favs && res.favs.length > 0) {
res.favs.forEach(f => {
const a = document.createElement('a');
a.href = `/user/${f.user}`;
@@ -274,6 +282,16 @@
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
if (navigator.vibrate) navigator.vibrate(50);
} else {
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
}
}
} catch (err) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to update favorite.', 3000, 'error');
}
}
};
+139 -15
View File
@@ -84,6 +84,9 @@
this.rawSeed = null;
this.isSessionReady = false;
this.hwFingerprint = null;
try {
this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null;
} catch (e) {}
}
/**
@@ -92,6 +95,13 @@
*/
async getHardwareFingerprint() {
if (this.hwFingerprint) return this.hwFingerprint;
try {
const cached = localStorage.getItem('f0ck_anon_hw_fp');
if (cached) {
this.hwFingerprint = cached;
return this.hwFingerprint;
}
} catch (e) {}
try {
let glVendor = '';
@@ -117,16 +127,67 @@
}
} catch (e) {}
// WebGPU adapter architecture (modern GPUs)
let gpuArch = '';
try {
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.info) {
gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
}
}
} catch (e) {}
// CPU & Memory
const concurrency = navigator.hardwareConcurrency || 0;
const memory = navigator.deviceMemory || 0;
const platform = navigator.platform || '';
// Display, Gamut & Dynamic Range
const screenInfo = [
window.screen ? window.screen.width : 0,
window.screen ? window.screen.height : 0,
window.screen ? window.screen.availWidth : 0,
window.screen ? window.screen.availHeight : 0,
window.screen ? window.screen.colorDepth : 0,
window.screen ? window.screen.pixelDepth : 0,
window.devicePixelRatio || 1
window.devicePixelRatio || 1,
window.matchMedia && window.matchMedia('(color-gamut: p3)').matches ? 'p3' : (window.matchMedia && window.matchMedia('(color-gamut: rec2020)').matches ? 'rec2020' : 'srgb'),
window.matchMedia && window.matchMedia('(dynamic-range: high)').matches ? 'hdr' : 'sdr'
].join('x');
// Input & Peripheral Hardware (touch, mouse, stylus)
const touchPoints = (typeof navigator !== 'undefined' && 'maxTouchPoints' in navigator) ? navigator.maxTouchPoints : 0;
const pointerType = (window.matchMedia && window.matchMedia('(pointer: fine)').matches) ? 'fine' : ((window.matchMedia && window.matchMedia('(pointer: coarse)').matches) ? 'coarse' : 'none');
const hoverType = (window.matchMedia && window.matchMedia('(hover: hover)').matches) ? 'hover' : 'none';
const inputHardware = `${touchPoints}:${pointerType}:${hoverType}`;
// 2D Canvas Font Rasterizer & Subpixel Geometry
let canvasFp = '';
try {
const c2d = document.createElement('canvas');
c2d.width = 240;
c2d.height = 60;
const ctx = c2d.getContext('2d');
if (ctx) {
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(10, 5, 60, 20);
ctx.fillStyle = '#069';
ctx.font = '14pt Arial, sans-serif';
ctx.fillText('f0ck.dev <canvas> 😃', 4, 35);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.font = '16pt Times, serif';
ctx.fillText('f0ck.dev <canvas> 😃', 20, 48);
const imgData = ctx.getImageData(0, 0, 240, 60).data;
let sum = 0;
for (let i = 0; i < imgData.length; i += 4) {
sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
}
canvasFp = sum.toString(16);
}
} catch (e) {}
// Web Audio DSP Floating-Point Math
let audioFp = '';
try {
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
@@ -159,14 +220,26 @@
}
} catch (e) {}
// FPU Math Microarchitecture Precision
const mathPrecision = [
Math.tan(-1e300).toString().substring(0, 10),
Math.sinh(1).toString().substring(0, 10),
Math.acos(0.123456789).toString().substring(0, 10)
].join(';');
const rawHardware = [
glVendor,
glRenderer,
glLimits,
gpuArch,
concurrency,
memory,
platform,
screenInfo,
audioFp
inputHardware,
canvasFp,
audioFp,
mathPrecision
].join('~~~');
const encoder = new TextEncoder();
@@ -174,6 +247,9 @@
const hashBuf = await window.crypto.subtle.digest('SHA-256', data);
const hashHex = bytesToHex(new Uint8Array(hashBuf));
this.hwFingerprint = `HW:${hashHex}`;
try {
localStorage.setItem('f0ck_anon_hw_fp', this.hwFingerprint);
} catch (e) {}
return this.hwFingerprint;
} catch (err) {
console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', err);
@@ -483,7 +559,7 @@
localStorage.setItem(STORAGE_KEY_PUB, this.pubkey);
localStorage.setItem(STORAGE_KEY_FP, this.fingerprint);
await this.ensureSession(true);
await this.ensureSession(true, isExplicitLogin);
return this.getIdentity();
}
@@ -514,7 +590,11 @@
/**
* Authenticate to backend and establish/refresh anonymous session
*/
async ensureSession(force = false) {
async ensureSession(force = false, isExplicitLogin = false) {
if (window.location.pathname === '/banned') {
return;
}
// If user is already logged in as a real registered user, don't overwrite session!
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) {
return;
@@ -550,9 +630,16 @@
reason: data.reason,
expires: data.expires
});
this.clearStoredIdentity();
this.updateNavUI(false);
// ONLY redirect to /banned if user was explicitly trying to login!
if (isExplicitLogin) {
const anonModal = document.getElementById('anon-ssh-modal');
if (anonModal) anonModal.style.display = 'none';
if (window.location.pathname !== '/banned') {
window.location.href = data.redirect || '/banned';
}
}
return;
}
@@ -594,6 +681,9 @@
setTombstone(tombstone) {
try {
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone));
if (tombstone && tombstone.banned) {
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstone))}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
} catch (e) {}
}
@@ -653,6 +743,7 @@
localStorage.removeItem(STORAGE_KEY_PRIV);
localStorage.removeItem(STORAGE_KEY_PUB);
localStorage.removeItem(STORAGE_KEY_FP);
document.cookie = 'f0ck_banned=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
this.cryptoKey = null;
this.rawSeed = null;
this.rawPub = null;
@@ -669,11 +760,27 @@
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
return;
}
// If client has a ban tombstone, redirect to /banned directly on login attempt
const tombstone = this.getTombstone();
if (tombstone && tombstone.banned) {
const anonModal = document.getElementById('anon-ssh-modal');
if (anonModal) anonModal.style.display = 'none';
if (window.location.pathname !== '/banned') {
window.location.href = '/banned';
}
return;
}
try {
if (!this.pubkey) {
await this.generateIdentity();
await this.generateIdentity(true);
} else {
await this.ensureSession(true, true);
}
await this.ensureSession(true);
if (!this.isSessionReady) return;
// Sync any guest favorites saved in localStorage to this anon account
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
await window.f0ckGuestFavs.importToAccount();
@@ -698,12 +805,16 @@
this.clearStoredIdentity();
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
} finally {
window.location.href = '/';
this.updateNavUI(false);
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged out from anonymous session');
}
window.location.reload();
}
}
/**
* Update visitor navbar UI elements
* Update navigation bar elements based on anonymous authentication state
*/
updateNavUI(isAnon) {
const icon = document.getElementById('nav-visitor-icon');
@@ -765,8 +876,12 @@
* Initialize on page startup
*/
async init() {
if (window.location.pathname === '/banned') {
return;
}
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (window.f0ckSession.is_anon) {
if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
@@ -780,6 +895,15 @@
return;
}
// If client has a ban tombstone, do NOT attempt background session handshake on page load/reload!
const tombstone = this.getTombstone();
if (tombstone && tombstone.banned) {
this.clearStoredIdentity();
this.updateNavUI(false);
this.attachUIListeners();
return;
}
const storedPriv = localStorage.getItem(STORAGE_KEY_PRIV);
const storedPub = localStorage.getItem(STORAGE_KEY_PUB);
const storedFp = localStorage.getItem(STORAGE_KEY_FP);
@@ -808,12 +932,12 @@
// User previously logged in as anonymous with this key
// If backend session is already active, avoid redundant session churn
if (window.f0ckSession && window.f0ckSession.is_anon) {
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
this.isSessionReady = true;
this.updateNavUI(true);
} else {
await this.ensureSession();
this.updateNavUI(true);
// Unauthenticated guest state: do NOT auto-login as anonymous on page load!
this.updateNavUI(false);
}
} catch (err) {
console.warn('[ANON_SSH] Failed to restore stored key:', err);
@@ -822,7 +946,7 @@
}
} else {
// Clean guest state! Do NOT auto-generate or establish session!
if (window.f0ckSession && window.f0ckSession.is_anon) {
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
// Stale backend anon session cookie without corresponding local key: clear cookie
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
@@ -853,7 +977,7 @@
}
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
if (logoutTarget && ((window.f0ckSession && window.f0ckSession.is_anon) || this.pubkey)) {
if (logoutTarget && ((window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) || this.pubkey)) {
e.preventDefault();
this.logoutAnonymous();
return;
+8 -2
View File
@@ -2230,7 +2230,7 @@ class CommentSystem {
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
if (isAnonGuest) bannerStyle = '';
const avatarHtml = isAnonGuest
@@ -2366,7 +2366,8 @@ class CommentSystem {
const counter = (maxLen !== null && maxLen !== undefined)
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
: '';
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments;
const anonAttachmentsDisabled = session.is_anon && session.anon_permissions?.comment_attachments === false;
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments && !anonAttachmentsDisabled;
const multiFile = session.fileupload_comments_multifile;
const attachBtn = fileUploadEnabled
? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">`
@@ -3350,6 +3351,11 @@ class CommentSystem {
return;
}
if (submitBtn.classList.contains('loading') || submitBtn.disabled) return;
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions?.comment === false) {
if (window.flashMessage) window.flashMessage('Anonymous commenting is disabled.', 3000, 'error');
else alert('Anonymous commenting is disabled.');
return;
}
// ── Upload all staged files now (at submit time) ───────────────────────
const fileIds = [];
+674 -94
View File
File diff suppressed because it is too large Load Diff
+76 -15
View File
@@ -97,7 +97,11 @@
let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10));
let leftHandEnabled = prefs.leftHand === true;
let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null };
const isScrollerAnon = !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon);
const scrollerAllowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const scrollerSingleMime = (isScrollerAnon && Array.isArray(scrollerAllowedMimes) && scrollerAllowedMimes.length === 1) ? scrollerAllowedMimes[0] : null;
let applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
let pending = { ...applied, tags: [] };
// Volume / mute
@@ -192,7 +196,8 @@
if (hid && !cache.items.some(item => String(item.id) === hid)) return false;
if (cache.filters) {
applied = { mode: defaultMode, mime: '', order: 'random', tags: [], ...cache.filters };
applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], ...cache.filters };
if (scrollerSingleMime) applied.mime = scrollerSingleMime;
applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : [];
pending = { ...applied, tags: [...applied.tags] };
}
@@ -1141,7 +1146,23 @@
}
async function toggleFav(slide) {
if (!window.scrollerLoggedIn) return;
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
const errMsg = 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
} else if (typeof showShareToast === 'function') {
showShareToast(errMsg);
}
return;
}
if (!window.scrollerLoggedIn) {
if (typeof showShareToast === 'function') {
showShareToast('Login to favorite posts');
} else if (typeof window.flashMessage === 'function') {
window.flashMessage('Login to favorite posts', 3000, 'warning');
}
return;
}
const id = slide.dataset.localId || slide.dataset.id;
// External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted
if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; }
@@ -1157,10 +1178,6 @@
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1));
if (nowFaved) flashFav(slide);
}
if (!window.scrollerLoggedIn && (!window.f0ckSession || !window.f0ckSession.user)) {
showShareToast('Login to favorite posts');
return;
}
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', {
@@ -1171,7 +1188,24 @@
},
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json();
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
// Rollback optimistic update
if (favBtn) {
favBtn.classList.toggle('faved', wasFaved);
const icon = favBtn.querySelector('i');
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
}
const errMsg = (data && (data.msg || data.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
} else if (typeof showShareToast === 'function') {
showShareToast(errMsg);
}
return;
}
// Sync count to server truth (handles race conditions)
if (data.success && favBtn && data.favs) {
const countEl = favBtn.querySelector('.scroll-btn-count');
@@ -1183,6 +1217,8 @@
favBtn.classList.toggle('faved', wasFaved);
const icon = favBtn.querySelector('i');
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
}
}
}
@@ -1349,7 +1385,7 @@
const meta = document.createElement('div'); meta.className = 'scroll-meta';
// esc() the color value: a raw '"' in username_color would break out of the
// style attribute and allow arbitrary HTML injection (XSS).
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const colorStyle = (!isAnonGuest && item.username_color) ? `color:${esc(item.username_color)}` : '';
const ratingHtml = `<span class="scroll-rating ${esc(item.rating_class)}" data-item-id="${item.id}" data-rating="${esc(item.rating_class)}">${esc(item.rating_label)}</span>`;
const ocHtml = item.is_oc ? `<span class="scroll-oc"><i class="fa-solid fa-star" style="font-size:.6rem"></i> OC</span>` : '';
@@ -2176,7 +2212,7 @@
function renderCommentEl(c, canReply) {
const el = document.createElement('div'); el.className = 'comment-item';
el.dataset.commentId = c.id || '';
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const av = isAnonGuest ? '/a/default.png' : (c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'));
const nc = (!isAnonGuest && c.username_color) ? `color:${esc(c.username_color)}` : '';
const _i = window.f0ckI18n || {};
@@ -3003,6 +3039,10 @@
});
function syncPanelUI() {
if (scrollerSingleMime) {
pending.mime = scrollerSingleMime;
applied.mime = scrollerSingleMime;
}
document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode));
document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime));
document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order));
@@ -3011,9 +3051,15 @@
}
function makePillListener(groupId, key, transform) {
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
const el = document.getElementById(groupId);
if (!el) return;
el.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
if (groupId === 'mime-pills' && scrollerSingleMime) {
e.preventDefault();
return;
}
el.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active'); pending[key] = transform ? transform(pill.dataset[key]) : pill.dataset[key];
});
});
@@ -3078,7 +3124,7 @@
}
}
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
function updateFilterSummary() {
const is4chan = !!applied.externalUrl;
@@ -3500,11 +3546,26 @@
const initScrollerSSE = () => {
if (sseEs) sseEs.close();
sseEs = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
let sseUrl = `/api/notifications/stream?tabId=${tabId}`;
try {
const fp = localStorage.getItem('f0ck_anon_ssh_fp');
if (fp) sseUrl += `&fp=${encodeURIComponent(fp)}`;
const hw = localStorage.getItem('f0ck_anon_hw_fp');
if (hw) sseUrl += `&hw=${encodeURIComponent(hw)}`;
} catch (e) {}
sseEs = new EventSource(sseUrl);
sseEs.onopen = () => { sseRetryCount = 0; };
sseEs.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'banned') {
try {
const tombstoneData = { banned: true, reason: data.data?.reason, expires: data.data?.expires };
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstoneData))}; Path=/; Max-Age=31536000; SameSite=Lax`;
} catch (err) {}
window.location.href = data.data?.redirect || '/banned';
return;
}
if (data.type === 'notify') {
pollNotifCount(); // instant re-fetch on SSE push
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
+23 -5
View File
@@ -562,7 +562,7 @@
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const effectiveBannerStyle = isAnonGuest ? '' : bannerStyle;
const authorAvatarHtml = isAnonGuest
? `<span class="sidebar-avatar-link"><img src="/a/default.png" class="sidebar-avatar" loading="eager" /></span>`
@@ -1031,13 +1031,31 @@
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
const getCurrentMimeFilter = () => {
const allowed = window.f0ckSession?.is_anon && Array.isArray(window.f0ckSession?.anon_permissions?.allowed_mimes) ? window.f0ckSession.anon_permissions.allowed_mimes : null;
const urlParams = new URLSearchParams(window.location.search);
const qMime = urlParams.get('mime');
if (qMime !== null) return qMime.trim();
let qMime = urlParams.get('mime');
if (qMime !== null) {
qMime = qMime.trim();
if (allowed) {
const parts = qMime.split(',').filter(m => allowed.includes(m));
return parts.length > 0 ? parts.join(',') : (allowed.length < 5 ? allowed.join(',') : '');
}
return qMime;
}
const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime='));
if (cookieMime) {
const val = cookieMime.split('=')[1];
if (typeof val === 'string') return decodeURIComponent(val).trim();
if (typeof val === 'string') {
const cMime = decodeURIComponent(val).trim();
if (allowed) {
const parts = cMime.split(',').filter(m => allowed.includes(m));
return parts.length > 0 ? parts.join(',') : (allowed.length < 5 ? allowed.join(',') : '');
}
return cMime;
}
}
if (allowed && allowed.length < 5) {
return allowed.join(',');
}
return '';
};
@@ -1176,7 +1194,7 @@
displayTitle = `${videoKey}`;
}
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username);
const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : '';
+20 -4
View File
@@ -95,7 +95,7 @@
if (canManage) {
span.classList.add('can-cycle');
}
} else if (tag.display_name || tag.user) {
} else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
span.setAttribute('tooltip', tag.display_name || tag.user);
}
@@ -196,6 +196,13 @@
const toggleFavEvent = async (e) => {
// e is the click event or undefined
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
}
return;
}
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
@@ -204,10 +211,11 @@
const favoBtn = document.querySelector("#a_favo");
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
try {
const res = await post('/api/v2/togglefav', {
postid: postid
});
if (res.success) {
if (res && res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
@@ -222,7 +230,7 @@
// span#favs
const favcontainer = document.querySelector('#favs');
favcontainer.innerHTML = "";
if (res.favs.length > 0) {
if (res.favs && res.favs.length > 0) {
res.favs.forEach(f => {
const a = document.createElement('a');
a.href = `/user/${f.user}`;
@@ -247,7 +255,15 @@
if (navigator.vibrate) navigator.vibrate(50);
}
else {
// lul
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
}
}
} catch (err) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to update favorite.', 3000, 'error');
}
}
};
+7
View File
@@ -5,6 +5,7 @@ import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import { collectBody } from "./inc/multipart.mjs";
import { canAnonDo, isAnonSession } from "./inc/settings.mjs";
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
@@ -141,6 +142,12 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('comment') || !canAnonDo('comment_attachments')) {
return sendJson(res, { success: false, msg: 'Anonymous comment file uploads are disabled' }, 403);
}
}
// Check if comment file upload is enabled
if (!cfg.websrv.allow_fileupload_comments) {
return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403);
+7 -2
View File
@@ -4,7 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess } from "./settings.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs";
@@ -285,7 +285,8 @@ export default new class {
return false;
};
async getTags(itemid, session = null) {
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
const isAnonymized = isAnonymizeSession(session);
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
const tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
@@ -296,6 +297,8 @@ export default new class {
`;
let hasRating = false;
const cleanTags = [];
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags');
for (let t = 0; t < tags.length; t++) {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
if (isRating) {
@@ -303,6 +306,8 @@ export default new class {
hasRating = true;
}
tags[t].badge = this.getBadge(tags[t]);
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
tags[t].can_exclude = canExclude;
if (!hasSession) {
delete tags[t].user;
delete tags[t].display_name;
+6
View File
@@ -270,6 +270,12 @@
"start_export": "Export generieren (ZIP)"
},
"filter": {
"excluded_tags": "Ausgeschlossene Tags",
"exclude_tag": "Tag ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Aufheben)",
"no_tags_excluded": "Noch keine Tags ausgeschlossen",
"tag_excluded_msg": "Tag '{tag}' zu ausgeschlossenen Tags hinzugefügt",
"tag_unexcluded_msg": "Tag '{tag}' aus ausgeschlossenen Tags entfernt",
"tag_placeholder": "Tag ausschließen",
"random_mode": "RAND",
"min_xd_score": "Min. xD-Score",
+6
View File
@@ -270,6 +270,12 @@
"start_export": "Generate Export (ZIP)"
},
"filter": {
"excluded_tags": "Excluded Tags",
"exclude_tag": "Exclude tag",
"unexclude_tag": "Excluded (click to unexclude)",
"no_tags_excluded": "No tags excluded yet",
"tag_excluded_msg": "Tag '{tag}' added to excluded tags",
"tag_unexcluded_msg": "Tag '{tag}' removed from excluded tags",
"tag_placeholder": "Tag to exclude",
"random_mode": "RAND",
"min_xd_score": "Min xD Score",
+6
View File
@@ -268,6 +268,12 @@
"start_export": "Export genereren (ZIP)"
},
"filter": {
"excluded_tags": "Uitgesloten Tags",
"exclude_tag": "Tag uitsluiten",
"unexclude_tag": "Uitgesloten (klik om te herstellen)",
"no_tags_excluded": "Nog geen tags uitgesloten",
"tag_excluded_msg": "Tag '{tag}' toegevoegd aan uitgesloten tags",
"tag_unexcluded_msg": "Tag '{tag}' verwijderd uit uitgesloten tags",
"tag_placeholder": "Tag om uit te sluiten",
"random_mode": "WILLEKEURIG",
"min_xd_score": "Min xD-score",
+6
View File
@@ -266,6 +266,12 @@
"start_export": "Paket schnüren"
},
"filter": {
"excluded_tags": "Ausgeschlossene Etiketten",
"exclude_tag": "Etikett ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Wiederherstellen)",
"no_tags_excluded": "Noch keine Etiketten ausgeschlossen",
"tag_excluded_msg": "Etikett '{tag}' zu ausgeschlossenen Etiketten hinzugefügt",
"tag_unexcluded_msg": "Etikett '{tag}' aus ausgeschlossenen Etiketten entfernt",
"tag_placeholder": "Auszuschließendes Etikett",
"random_mode": "ZUFA",
"min_xd_score": "Min. xD-Punktestand",
+133 -52
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
@@ -15,7 +15,7 @@ const getGlobalfilter = () => {
};
const computeBaseMode = (mode, ratings, session) => {
const effMode = Number(mode ?? 0);
let effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
@@ -28,6 +28,32 @@ const computeBaseMode = (mode, ratings, session) => {
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const canFilter = canAnonDo('filter');
if (!canFilter) {
safeRatingsArr = null;
effMode = 0;
} else if (safeRatingsArr) {
safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
}
const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const currentModeName = modeNames[effMode] || 'sfw';
if (effMode === 3) {
if (allowedModes.length < 5) {
safeRatingsArr = [...allowedModes];
}
} else if (!allowedModes.includes(currentModeName)) {
const fallbackModeName = allowedModes[0] || 'sfw';
const fallbackModeIdx = modeNames.indexOf(fallbackModeName);
effMode = fallbackModeIdx >= 0 ? fallbackModeIdx : 0;
}
}
let baseMode;
@@ -67,6 +93,21 @@ const computeBaseMode = (mode, ratings, session) => {
} else if (effMode === 4) {
baseMode = "1 = 0";
}
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (!allowedModes.includes('nsfl')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
}
if (!allowedModes.includes('nsfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 2)`;
}
if (!allowedModes.includes('untagged')) {
baseMode = `(${baseMode}) and exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
}
if (!allowedModes.includes('sfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
}
}
return baseMode;
};
@@ -87,6 +128,36 @@ const resolveNumericItemId = async (itemIdOrSlug) => {
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
const resolveMimeSQL = (rawMime, session, itemAlias = 'items') => {
let mimeParts = (rawMime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
if (isAnonSession(session)) {
const allowedMimes = getAnonAllowedMimes();
const canFilter = canAnonDo('filter');
if (!canFilter || allowedMimes.length === 1) {
mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : [];
} else {
if (mimeParts.length > 0) {
mimeParts = mimeParts.filter(m => allowedMimes.includes(m));
if (mimeParts.length === 0) {
mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : [];
}
} else if (allowedMimes.length < 5) {
mimeParts = [...allowedMimes];
}
}
}
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? (itemAlias === 'i' ? flashMimes.map(fm => db`i.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) : flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`))
: db`false`)
: (m === 'pdf' ? (itemAlias === 'i' ? db`i.mime = 'application/pdf'` : db`items.mime = 'application/pdf'`) : (itemAlias === 'i' ? db`i.mime ilike ${m + '/%'}` : db`items.mime ilike ${m + '/%'}`))).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
return { mimeParts, mimeSQL };
};
// ── Count cache ─────────────────────────────────────────────────────────────
// The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan).
// Cache it per unique filter combination for 90 seconds so that navigating
@@ -300,16 +371,7 @@ const buildFeedFilters = async ({
if (uhData.length) userHallObj = uhData[0];
}
const mime = rawMime ?? null;
// Support multiple MIME types (comma separated)
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = session && exclude ? (exclude || []) : [];
const newerThan = newer ? parseInt(newer) : null;
@@ -853,14 +915,7 @@ const f0cklib = {
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = exclude || [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
@@ -1404,14 +1459,7 @@ const f0cklib = {
}
// Support multiple MIME types (comma separated)
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = session && exclude ? (exclude || []) : [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
@@ -2113,14 +2161,7 @@ const f0cklib = {
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
let rows;
if (mimeParts.length > 0) {
@@ -2298,6 +2339,59 @@ const f0cklib = {
}
},
updateUserTagAffinity: async ({ user_id, tag, scoreDelta = 2.0 }) => {
if (!user_id || !tag || !scoreDelta) return;
try {
const rawList = typeof tag === 'string'
? tag.split(',')
: (Array.isArray(tag) ? tag : [tag]);
const tagsList = rawList
.map(t => typeof t === 'string' ? t.trim().toLowerCase() : '')
.filter(t => t && !t.startsWith('title:') && !t.startsWith('src:'))
.slice(0, 10);
if (tagsList.length === 0) return;
const slugList = tagsList.map(t => lib.slugify(t)).filter(Boolean);
const tagRows = await db`
SELECT DISTINCT id FROM tags
WHERE LOWER(tag) = ANY(${tagsList}::text[])
OR (normalized != '' AND normalized = ANY(${slugList}::text[]))
`;
if (tagRows.length === 0) return;
const tagIds = tagRows.map(r => r.id);
// Guard: Only increment score and interaction_count if last_interacted was more than 10s ago,
// avoiding duplicate score inflation from rapid page refreshes or dual beacon/page loads.
await db`
INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted)
SELECT
${user_id},
unnest(${tagIds}::int[]),
${scoreDelta},
1,
now()
ON CONFLICT (user_id, tag_id) DO UPDATE SET
score = CASE
WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds'
THEN GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score))
ELSE user_tag_affinity.score
END,
interaction_count = CASE
WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds'
THEN user_tag_affinity.interaction_count + 1
ELSE user_tag_affinity.interaction_count
END,
last_interacted = now()
`;
} catch (err) {
console.error("[AFFINITY] Failed to update user tag affinity from search:", err);
}
},
decayUserAffinities: async () => {
try {
await db`
@@ -2455,14 +2549,7 @@ const f0cklib = {
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
let personalizedItems = [];
if (personalizedTarget > 0 && targetTagIds.length > 0) {
@@ -2671,14 +2758,7 @@ const f0cklib = {
? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))`
: db`AND COALESCE(items.visibility, 0) = 0`);
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
const isStrict = !!strict || (tag && tag.includes(','));
@@ -2835,6 +2915,7 @@ const f0cklib = {
processEmbeds,
computeXdScore,
xdScoreMeta,
resolveMimeSQL,
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
clearCountCache: () => countCache.clear()
};
+164 -39
View File
@@ -77,7 +77,15 @@ export default (router, tpl) => {
} else {
const reason = user[0].ban_reason || 'none';
const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never';
return fail(`You are banned! reason: ${reason} expire: ${expires}`);
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: false,
banned: true,
msg: `You are banned! reason: ${reason} expire: ${expires}`,
redirect: '/banned'
}));
}
return res.writeHead(302, { Location: '/banned' }).end();
}
}
@@ -452,6 +460,14 @@ export default (router, tpl) => {
await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration });
// Broadcast ban event via SSE
await db.notify('bans', JSON.stringify({
ip,
ipHash,
reason: (reason || 'Banned by moderator').substring(0, 300),
expires
})).catch(() => {});
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
@@ -682,6 +698,16 @@ export default (router, tpl) => {
expires,
banIps: true
});
} else {
// Broadcast ban to registered user's active SSE sessions
const userIps = await db`SELECT distinct ip FROM user_ips WHERE user_id = ${+user_id}`;
const ips = userIps.map(r => r.ip).filter(Boolean);
await db.notify('bans', JSON.stringify({
userId: +user_id,
reason: (reason || 'Violation of community rules').substring(0, 300),
expires,
ips
})).catch(() => {});
}
// Log it in audit
@@ -1109,8 +1135,98 @@ export default (router, tpl) => {
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const limit = 50;
const offset = (page - 1) * limit;
const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim();
const rawRole = (req.url.qs?.role || '').toLowerCase().trim();
const users = await db`
const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' ||
req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' ||
req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' ||
rawStatus === 'legacy';
const status = onlyLegacy ? '' : rawStatus;
const role = onlyLegacy ? '' : rawRole;
let users;
let total;
if (onlyLegacy) {
users = await db`
WITH ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
GROUP BY i.username
),
paginated_users AS (
SELECT * FROM ghost_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
0::bigint as comment_count,
0::bigint as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
`;
total = parseInt(totalCountGhost[0].c);
} else {
let qCond = null;
if (q) {
if (exactMatch) {
qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`;
} else {
const pattern = '%' + lib.escapeLike(q) + '%';
qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`;
}
}
let statusCond = null;
if (status === 'banned') {
statusCond = db`u.banned = true`;
} else if (status === 'active') {
statusCond = db`(u.activated = true AND u.banned = false)`;
} else if (status === 'pending') {
statusCond = db`(u.activated = false AND u.banned = false)`;
}
let roleCond = null;
if (role === 'staff' || status === 'staff') {
roleCond = db`(u.admin = true OR u.is_moderator = true)`;
} else if (role === 'admin') {
roleCond = db`u.admin = true`;
} else if (role === 'mod') {
roleCond = db`(u.is_moderator = true AND u.admin = false)`;
} else if (role === 'user') {
roleCond = db`(u.admin = false AND u.is_moderator = false)`;
}
users = await db`
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
@@ -1118,31 +1234,13 @@ export default (router, tpl) => {
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
),
ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
GROUP BY i.username
),
all_users AS (
SELECT * FROM filtered_users
UNION ALL
SELECT * FROM ghost_users
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
),
paginated_users AS (
SELECT * FROM all_users
SELECT * FROM filtered_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
@@ -1175,21 +1273,41 @@ export default (router, tpl) => {
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
`;
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
total = parseInt(totalCountActual[0].c);
}
let totalLabel = 'registered members';
let emptyMsg = 'No users matched your search.';
if (onlyLegacy) {
totalLabel = 'legacy accounts';
emptyMsg = 'No legacy users matched your search.';
} else if (status === 'banned') {
totalLabel = 'banned members';
emptyMsg = 'No banned users found.';
} else if (status === 'pending') {
totalLabel = 'pending members';
emptyMsg = 'No pending users found.';
} else if (status === 'active') {
totalLabel = 'active members';
emptyMsg = 'No active users found.';
} else if (role === 'staff' || status === 'staff') {
totalLabel = 'staff members';
emptyMsg = 'No staff members found.';
} else if (role === 'admin') {
totalLabel = 'admin members';
emptyMsg = 'No admin users found.';
} else if (role === 'mod') {
totalLabel = 'moderator members';
emptyMsg = 'No moderator users found.';
} else if (role === 'user') {
totalLabel = 'regular users';
emptyMsg = 'No regular users found.';
}
const data = {
session: req.session,
@@ -1198,6 +1316,11 @@ export default (router, tpl) => {
page,
total,
hasMore: users.length === limit,
onlyLegacy,
status,
role,
totalLabel,
emptyMsg,
totals: await lib.countf0cks(),
log_user_ips: getLogUserIps(),
tmp: null
@@ -1205,6 +1328,8 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.setHeader('X-Total-Count', total.toString());
res.setHeader('X-Total-Label', totalLabel);
res.setHeader('X-Empty-Msg', emptyMsg);
res.setHeader('X-Has-More', (users.length === limit).toString());
return res.reply({
body: tpl.render("admin/users_list", data, req)
+3 -2
View File
@@ -2,6 +2,7 @@ import f0cklib from "../routeinc/f0cklib.mjs";
import url from "url";
import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
import { isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
@@ -140,8 +141,8 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
+38 -3
View File
@@ -12,6 +12,24 @@ export default router => {
* POST /api/v2/anon/session
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous session.
*/
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
while (/^Cascade ban from (device|hardware ID|key) \((.*)\)$/.test(clean)) {
clean = clean.replace(/^Cascade ban from (device|hardware ID|key) \((.*)\)$/, '$2');
}
return `${prefix} (${clean || 'Violation of community rules'})`;
};
const setBanCookie = (res, reason, expires) => {
const payload = encodeURIComponent(JSON.stringify({
banned: true,
reason: reason || 'Banned',
expires: expires ? new Date(expires).toISOString() : null
}));
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
@@ -21,6 +39,7 @@ export default router => {
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({
success: false,
banned: true,
@@ -64,15 +83,19 @@ export default router => {
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
return res.json({
success: false,
banned: true,
@@ -90,15 +113,19 @@ export default router => {
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
return res.json({
success: false,
banned: true,
@@ -116,16 +143,20 @@ export default router => {
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
if (fpBan) {
if (hwFingerprint) {
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
expires: fpBan.expires,
banIps: true,
banHardware: true
});
}
}
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
return res.json({
success: false,
banned: true,
@@ -144,6 +175,8 @@ export default router => {
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
userId,
fingerprint: parsed.fingerprint,
@@ -153,6 +186,8 @@ export default router => {
banIps: true,
banHardware: true
});
}
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({
success: false,
banned: true,
+100 -13
View File
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession, isAnonymizeSession } from '../../settings.mjs';
import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs';
import path from "path";
@@ -583,7 +583,8 @@ export default router => {
const userHall = req.url.qs.userHall || null;
const userHallOwner= req.url.qs.userHallOwner|| null;
const user = req.url.qs.user || null;
const mime = req.url.qs.mime || null;
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs.mime !== 'undefined') ? (req.url.qs.mime || null) : (cookieMime || null);
const isFav = req.url.qs.fav === 'true';
const isStrict = req.url.qs.strict === '1';
const mode = req.mode ?? 0;
@@ -598,7 +599,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -616,7 +617,10 @@ export default router => {
const user = req.url.qs.user || pathUser || null;
const pathMime = allowedMimes.includes(pathParts[4]) ? pathParts[4] : "";
const mime = req.url.qs.mime || pathMime || (req.cookies.mime || null);
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs?.mime !== 'undefined')
? (req.url.qs.mime || null)
: (cookieMime || (pathMime || null));
const tag = req.url.qs.tag || null;
const hall = req.url.qs.hall || null;
@@ -639,7 +643,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -667,7 +671,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -743,7 +747,7 @@ export default router => {
limit,
mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin,
@@ -755,6 +759,7 @@ export default router => {
prefer_personalized: preferPersonalized
});
const isAnonUser = isAnonymizeSession(req.session);
res.json({
success: true,
items: items.map(item => ({
@@ -764,9 +769,9 @@ export default router => {
dest: item.dest,
mime: item.mime,
stamp: item.stamp,
username: item.username,
display_name: item.display_name,
username_color: item.username_color,
username: isAnonUser ? 'anonymous' : item.username,
display_name: isAnonUser ? null : item.display_name,
username_color: isAnonUser ? null : item.username_color,
has_coverart: item.has_coverart,
rating_class: item.rating_class,
xd_score: item.xd_score,
@@ -833,6 +838,37 @@ export default router => {
}
});
// Track tag search interest signal
group.post(/\/track\/search$/, async (req, res) => {
try {
let payload = req.post || {};
if (!payload || Object.keys(payload).length === 0) {
try {
const body = await collectBody(req);
if (body && body.length > 0) payload = JSON.parse(body.toString());
} catch (_) {}
}
const tag = (payload.tag || payload.q || req.url.qs?.tag || req.url.qs?.q);
if (!tag || typeof tag !== 'string' || !tag.trim()) {
return res.json({ success: false, error: "Invalid tag parameter" }, 400);
}
if (req.session?.id) {
f0cklib.updateUserTagAffinity({
user_id: req.session.id,
tag: tag.trim(),
scoreDelta: 2.0
}).catch(err => console.error("[TRACK] Search affinity update failed:", err));
}
return res.json({ success: true });
} catch (err) {
console.error("[TRACK] Search tracking error:", err);
return res.json({ success: false, error: "Tracking failed" }, 500);
}
});
group.get(/\/tag-feed$/, async (req, res) => {
try {
const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null;
@@ -866,7 +902,7 @@ export default router => {
focus_id: focusId,
mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin,
@@ -875,6 +911,17 @@ export default router => {
});
const effectiveOffset = result.offset ?? offset;
const isAnonUser = isAnonymizeSession(req.session);
const outItems = isAnonUser
? result.items.map(it => ({
...it,
username: 'anonymous',
display_name: 'anonymous',
username_color: null,
avatar: '/a/default.png',
avatar_file: null
}))
: result.items;
return res.json({
success: true,
@@ -883,7 +930,7 @@ export default router => {
total: result.total,
offset: effectiveOffset,
limit: result.limit ?? limit,
items: result.items,
items: outItems,
hasMore: (effectiveOffset + result.items.length) < result.total
});
} catch (err) {
@@ -899,6 +946,14 @@ export default router => {
group.get(/\/orakel\/user$/, async (req, res) => {
try {
if (isAnonymizeSession(req.session)) {
return res.json({
success: true,
username: 'anonymous',
display_name: 'Anonymous',
id: 0
});
}
const now = ~~(Date.now() / 1000);
const sevenDaysAgo = now - 604800; // 7 days in seconds
@@ -1088,6 +1143,13 @@ export default router => {
prev: prev[0]?.id ?? null
};
if (isAnonymizeSession(req.session)) {
rows.username = 'anonymous';
rows.display_name = null;
rows.user = 'anonymous';
rows.src = null;
}
return res.json({
success: true,
rows
@@ -1095,6 +1157,9 @@ export default router => {
});
group.get(/\/user\/(?<user>[^\/]+)(\/(?<eps>\d+))?$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.json({ success: false, msg: 'access denied' });
}
const user = req.params.user;
const eps = +req.params.eps || 50;
@@ -1147,6 +1212,9 @@ export default router => {
group.get(/\/users\/suggest$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.json({ success: true, suggestions: [] });
}
const searchString = req.url.qs.q;
if (!searchString || searchString.length < 1) {
return res.json({ success: false, suggestions: [] });
@@ -1357,6 +1425,9 @@ export default router => {
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
}
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
if (rawPostid === undefined || rawPostid === null) {
return res.json({ success: false, msg: 'Missing postid' }, 400);
@@ -1431,6 +1502,9 @@ export default router => {
});
group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
}
try {
const rawIds = req.post?.ids ?? req.body?.ids;
let ids = [];
@@ -1704,7 +1778,20 @@ export default router => {
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.registeredUser, async (req, res) => {
const ratingAuth = (req, res, next) => {
if (!req.session) {
return res.json({ success: false, msg: 'Unauthorized' }, 401);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
return next();
}
return lib.registeredUser(req, res, next);
};
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, ratingAuth, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+22 -6
View File
@@ -4,6 +4,7 @@ import cfg from '../../config.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -74,6 +75,9 @@ export default router => {
});
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
@@ -83,10 +87,16 @@ export default router => {
});
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagname = req.post?.tagname || req.body?.tagname;
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tagId = req.post?.tag_id || req.body?.tag_id;
if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
const tag = tagId
? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
@@ -110,12 +120,18 @@ export default router => {
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
return res.json({ success: true, tags, tag }, 200);
});
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
const tagname = decodeURIComponent(req.params.tag);
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagParam = decodeURIComponent(req.params.tag);
const isNum = /^\d+$/.test(tagParam);
const tag = isNum
? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
@@ -135,7 +151,7 @@ export default router => {
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
return res.json({ success: true, tags, tag }, 200);
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
+7
View File
@@ -6,6 +6,7 @@ import cfg from "../../config.mjs";
import fs from "fs";
import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs";
export default router => {
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
@@ -26,6 +27,9 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (isAnonSession(req.session) && !canAnonDo('tag')) {
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
}
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({
@@ -106,6 +110,9 @@ export default router => {
});
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
const postid = +req.params.postid;
+16 -4
View File
@@ -3,7 +3,7 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.mjs';
import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
@@ -232,9 +232,21 @@ const collectBody = (req) => {
export default router => {
router.group(/^\/api\/v2/, group => {
const uploadApiAuth = (req, res, next) => {
if (!req.session) {
return res.json({ success: false, msg: 'Unauthorized' }, 401);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return res.json({ success: false, msg: 'Action requires a registered account or anonymous upload permission' }, 403);
}
return next();
}
return lib.registeredUser(req, res, next);
};
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.registeredUser, (req, res) => {
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, uploadApiAuth, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
@@ -307,7 +319,7 @@ export default router => {
return [...new Set(tags)];
};
group.get(/\/meta\/extract-url$/, lib.registeredUser, async (req, res) => {
group.get(/\/meta\/extract-url$/, uploadApiAuth, async (req, res) => {
const url = req.url.qs?.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
@@ -358,7 +370,7 @@ export default router => {
}
});
group.post(/\/upload-url$/, lib.registeredUser, async (req, res) => {
group.post(/\/upload-url$/, uploadApiAuth, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
+14 -6
View File
@@ -31,10 +31,15 @@ export default (router, tpl) => {
}
}
if (!isBanned) {
return res.writeHead(302, {
"Location": "/"
}).end();
if (req.cookies && req.cookies.f0ck_banned) {
try {
const bData = JSON.parse(decodeURIComponent(req.cookies.f0ck_banned));
if (bData && (bData.reason || bData.banned)) {
isBanned = true;
reason = bData.reason || reason;
expires = bData.expires || expires;
}
} catch (e) {}
}
res.reply({
@@ -42,8 +47,11 @@ export default (router, tpl) => {
session: req.session,
reason: reason,
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
ban_video: cfg.websrv.ban_video,
hideNavbar: true
isBanned: isBanned,
clientIp: clientIp,
page_meta: {
title: isBanned ? 'Banned' : 'Ban Status'
}
}, req)
});
});
+15 -5
View File
@@ -8,7 +8,7 @@ import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess } from "../settings.mjs";
import { getEnableAnonymousAccess, canAnonDo, isAnonSession, isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
@@ -65,7 +65,7 @@ export default (router, tpl) => {
}
// Transform for frontend if needed, or send as is
const anonymize = !req.session && cfg.main.guest_anonymize;
const anonymize = isAnonymizeSession(req.session);
const outComments = anonymize
? comments.map(c => ({
...c,
@@ -135,8 +135,8 @@ export default (router, tpl) => {
// Browse User Comments
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const user = decodeURIComponent(req.params.user);
@@ -413,6 +413,10 @@ export default (router, tpl) => {
}
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
if (isAnonSession(req.session) && !canAnonDo('comment')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
}
// Rate limit regular users (admins and mods are exempt)
if (!req.session.admin && !req.session.is_moderator) {
if (isCommentRateLimited(req.session.id)) {
@@ -1091,6 +1095,8 @@ export default (router, tpl) => {
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
: db`AND COALESCE(i.visibility, 0) = 0`);
const { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i');
const comments = await db`
SELECT
c.*,
@@ -1117,6 +1123,7 @@ export default (router, tpl) => {
WHERE c.is_deleted = false
AND i.active = true
AND i.is_deleted = false
${activityMimeSQL}
${visibilityFilter}
AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
@@ -1195,7 +1202,7 @@ export default (router, tpl) => {
}
}
const isAnonymized = !req.session && cfg.main.guest_anonymize;
const isAnonymized = isAnonymizeSession(req.session);
const processedComments = comments.map(c => {
let ratingLabel = '?';
let ratingClass = 'untagged';
@@ -1431,6 +1438,9 @@ export default (router, tpl) => {
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (isAnonSession(req.session) && !canAnonDo('poll_vote')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Anonymous poll voting is disabled' }) });
}
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
+36 -10
View File
@@ -4,6 +4,7 @@ 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 } from "../settings.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -13,9 +14,9 @@ const auth = async (req, res, next) => {
export default (router, tpl) => {
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
// When guest anonymization is active, user profiles must not be accessible without a session
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
// When anonymization is active, user profiles must not be accessible without an authenticated regular session
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const user = decodeURIComponent(req.params.user);
const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null);
@@ -228,17 +229,24 @@ export default (router, tpl) => {
return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
}
// When guest anonymization is active, user gallery pages must require authentication
if (cfg.main.guest_anonymize && !req.session && req.params.user && req.params.mode) {
return res.redirect('/login');
}
// Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs
if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) {
res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` });
return res.end();
}
// When anonymization is active, user gallery pages must require authentication and hide other users
if (isAnonymizeSession(req.session) && req.params.user && req.params.mode) {
const targetUser = decodeURIComponent(req.params.user).toLowerCase();
const isSelf = req.session?.user && (
targetUser === req.session.user.toLowerCase() ||
(req.session.login && targetUser === req.session.login.toLowerCase())
);
if (!isSelf) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
}
// Auto-persist strict mode from URL to session if it's there
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
@@ -247,6 +255,11 @@ export default (router, tpl) => {
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
// Track tag browsing interest in user affinity profile
if (reqTag && req.session?.id && !reqTag.startsWith('title:')) {
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag: reqTag, scoreDelta: 2.0 }).catch(() => {});
}
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
user: req.params.user,
tag: reqTag,
@@ -405,8 +418,8 @@ export default (router, tpl) => {
// Hall columns for display
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
@@ -632,6 +645,19 @@ export default (router, tpl) => {
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
const mode = modeMatch ? +modeMatch[1] : 0;
if (isAnonSession(req.session)) {
if (!canAnonDo('filter') || !canAnonMode(mode)) {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
code: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'Mode not permitted for anonymous users' })
});
}
return res.redirect('/');
}
}
if (cfg.allowedModes[mode]) {
if (req.session) {
req.session.mode = mode;
+133 -2
View File
@@ -3,10 +3,65 @@ import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
import security from "../security.mjs";
export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId
export function broadcastBan(data) {
if (!data) return;
const targetUserIds = new Set();
if (data.userId) targetUserIds.add(+data.userId);
if (Array.isArray(data.userIds)) data.userIds.forEach(id => targetUserIds.add(+id));
const targetFps = new Set();
if (data.fingerprint) targetFps.add(data.fingerprint);
if (Array.isArray(data.fingerprints)) data.fingerprints.forEach(fp => targetFps.add(fp));
const targetHws = new Set();
if (data.hwFingerprint) targetHws.add(data.hwFingerprint);
if (Array.isArray(data.hwFingerprints)) data.hwFingerprints.forEach(hw => targetHws.add(hw));
const targetIps = new Set();
if (data.ip) targetIps.add(data.ip);
if (Array.isArray(data.ips)) data.ips.forEach(ip => targetIps.add(ip));
const targetIpHashes = new Set();
if (data.ipHash) targetIpHashes.add(data.ipHash);
if (Array.isArray(data.ipHashes)) data.ipHashes.forEach(h => targetIpHashes.add(h));
for (const client of clients) {
let isMatch = false;
if (client.userId && targetUserIds.has(+client.userId)) {
isMatch = true;
} else if (client.fingerprint && targetFps.has(client.fingerprint)) {
isMatch = true;
} else if (client.hwFingerprint && targetHws.has(client.hwFingerprint)) {
isMatch = true;
} else if (client.ip && targetIps.has(client.ip)) {
isMatch = true;
} else if (client.ipHash && (targetIpHashes.has(client.ipHash) || targetIps.has(client.ipHash))) {
isMatch = true;
}
if (isMatch) {
console.log(`[SSE] Delivering instant ban to client (userId: ${client.userId}, ip: ${client.ip}, tab: ${client.tabId})`);
client.send({
type: 'banned',
data: {
reason: data.reason || 'Violation of community rules',
expires: data.expires ? (isNaN(new Date(data.expires).getTime()) ? data.expires : new Date(data.expires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => {
client.close();
}, 1000);
}
}
}
// Broadcast the deduplicated online-user list to all connected clients
function broadcastChatPresence() {
const seen = new Set();
@@ -83,6 +138,17 @@ db.listen('warnings', (payload) => {
}
}).catch(err => console.error('DB Listen Warning error:', err));
// Global listener for bans
db.listen('bans', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Received ban event via database notify:`, data);
broadcastBan(data);
} catch (e) {
console.error('[SSE] Ban broadcast error:', e);
}
}).catch(err => console.error('[SSE] DB Listen Ban error:', err));
// Global listener for profile updates (display name changes etc.)
db.listen('profile_update', (payload) => {
try {
@@ -603,8 +669,16 @@ export default (router, tpl) => {
res.writeHead(200, headers);
res.write(': ok\n\n'); // Warmup
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 clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
const client = {
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
userId: clientUserId,
fingerprint: clientFp,
hwFingerprint: clientHw,
username: req.session?.user || null,
display_name: req.session?.display_name || null,
avatar_file: req.session?.avatar_file || null,
@@ -617,7 +691,8 @@ export default (router, tpl) => {
do_not_disturb: req.session?.do_not_disturb === true,
sessionId,
tabId,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
ip: clientIp,
ipHash: clientIpHash,
send: (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -632,6 +707,62 @@ export default (router, tpl) => {
}
};
// Check if connecting client is already banned
(async () => {
let isBanned = false;
let banReason = 'Violation of community rules';
let banExpires = null;
if (client.userId) {
const u = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${client.userId} LIMIT 1`;
if (u[0]?.banned) {
isBanned = true;
banReason = u[0].ban_reason || banReason;
banExpires = u[0].ban_expires;
}
}
if (!isBanned && client.ip) {
const ipBan = await security.isIpBanned(client.ip);
if (ipBan) {
isBanned = true;
banReason = ipBan.reason || banReason;
banExpires = ipBan.expires;
}
}
if (!isBanned && client.fingerprint) {
const fpBan = await security.isFingerprintBanned(client.fingerprint);
if (fpBan) {
isBanned = true;
banReason = fpBan.reason || banReason;
banExpires = fpBan.expires;
}
}
if (!isBanned && client.hwFingerprint) {
const hwBan = await security.isHardwareBanned(client.hwFingerprint);
if (hwBan) {
isBanned = true;
banReason = hwBan.reason || banReason;
banExpires = hwBan.expires;
}
}
if (isBanned) {
console.log(`[SSE] Connecting client is already banned, pushing instant redirect to /banned`);
client.send({
type: 'banned',
data: {
reason: banReason,
expires: banExpires ? (isNaN(new Date(banExpires).getTime()) ? banExpires : new Date(banExpires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => client.close(), 1000);
}
})().catch(err => console.error('[SSE] Initial ban check error:', err));
// Send any unacknowledged warnings on connection
if (!isGuest && req.session?.id) {
db`
+6 -2
View File
@@ -43,17 +43,21 @@ export default (router, tpl) => {
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const reqQueryMime = req.url?.searchParams?.get('mime') || req.url?.qs?.mime;
const effectiveMime = (reqQueryMime !== undefined && reqQueryMime !== null) ? reqQueryMime : (cookieMime || opts.mime || null);
const data = await f0cklib.getRandom({
user: opts.user,
tag: opts.tag,
hall: opts.hall,
mime: opts.mime || (req.cookies.mime || null),
mime: effectiveMime,
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
ratings: ratingsArr,
strict: opts.strict,
session: !!req.session
session: req.session
});
console.log("data", data);
+2 -1
View File
@@ -2,6 +2,7 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
// Serve the scroller page
@@ -377,7 +378,7 @@ export default (router, tpl) => {
const lastItem = items[items.length - 1];
const nextCursor = lastItem ? lastItem.id : null;
const isAnonymized = !req.session && cfg.main.guest_anonymize;
const isAnonymized = isAnonymizeSession(req.session);
const outItems = isAnonymized
? items.map(item => ({
...item,
+4
View File
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import search from "../routeinc/search.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
const _eps = 20;
@@ -14,6 +15,9 @@ export default (router, tpl) => {
let pagination, link;
if (tag.length > 0) {
if (req.session?.id && typeof tag === 'string' && !tag.startsWith('src:') && !tag.startsWith('title:')) {
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag, scoreDelta: 2.0 }).catch(() => {});
}
if (tag.startsWith('src:')) {
total = (await db`
select count(*) as total
+15 -2
View File
@@ -1,10 +1,23 @@
import lib from "../lib.mjs";
import db from "../sql.mjs";
import cfg from "../config.mjs";
import { getMinTags } from "../settings.mjs";
import { getMinTags, canAnonDo, isAnonSession } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/upload$/, lib.userauth, async (req, res) => {
const uploadAuth = (req, res, next) => {
if (!req.session) {
return res.redirect('/login');
}
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return res.redirect('/login');
}
return next();
}
return lib.userauth(req, res, next);
};
router.get(/^\/upload$/, uploadAuth, async (req, res) => {
let maxfilesize = cfg.main.maxfilesize;
if (req.session.admin || req.session.is_moderator) {
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
+9 -8
View File
@@ -1,5 +1,6 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import fs from "fs/promises";
import path from "path";
@@ -43,8 +44,8 @@ export default (router, tpl) => {
// List halls for a user
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -83,8 +84,8 @@ export default (router, tpl) => {
// Item grid for a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -132,8 +133,8 @@ export default (router, tpl) => {
// Single item within a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -185,8 +186,8 @@ export default (router, tpl) => {
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
+6 -2
View File
@@ -1,6 +1,7 @@
import db from "../../inc/sql.mjs";
import lib from "../../inc/lib.mjs";
import cfg from "../../inc/config.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import url from "url";
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
@@ -100,6 +101,9 @@ export default (router, tpl) => {
// API endpoint for lazy loading tags for a user
router.get(/^\/api\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Access denied" }) });
}
const userParam = decodeURIComponent(req.params.user);
const u = await db`
@@ -158,8 +162,8 @@ export default (router, tpl) => {
// Main tags page
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const userParam = decodeURIComponent(req.params.user);
+22 -5
View File
@@ -317,8 +317,8 @@ export default new class {
}
// 3. Ban the hardware fingerprint
if (banHardware) {
const associatedHws = new Set();
if (banHardware) {
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
if (userId) {
@@ -348,9 +348,8 @@ export default new class {
}
// 4. Cascade to associated IPs
if (banIps) {
const associatedIps = new Set();
if (banIps) {
if (userId) {
const actIps = await db`select distinct ip from anon_activity_log where user_id = ${userId}`;
for (const r of actIps) if (r.ip) associatedIps.add(r.ip);
@@ -380,10 +379,12 @@ export default new class {
for (const ip of associatedIps) {
if (!ip || ip === 'unknown') continue;
const ipHash = this.hashIP(ip);
const isAlreadyHash = /^[a-f0-9]{64}$/i.test(ip);
const ipVal = ip;
const ipHash = isAlreadyHash ? ip : this.hashIP(ip);
await db`
insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at)
values (${ip}, ${ipHash}, ${bannedBy}, ${reason}, ${expires})
values (${ipVal}, ${ipHash}, ${bannedBy}, ${reason}, ${expires})
on conflict (ip) do update
set reason = excluded.reason,
expires_at = excluded.expires_at,
@@ -393,6 +394,22 @@ export default new class {
}
}
// 5. Broadcast ban immediately via Postgres notify
try {
const hwList = Array.from(associatedHws).slice(0, 50);
const ipList = Array.from(associatedIps).slice(0, 50);
await db.notify('bans', JSON.stringify({
userId,
fingerprint: targetFingerprint,
hwFingerprints: hwList,
ips: ipList,
reason: (reason || 'Banned anonymous identity').substring(0, 300),
expires
}));
} catch (e) {
console.error('[SECURITY] Error notifying bans channel:', e);
}
return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint };
}
};
+94
View File
@@ -35,6 +35,100 @@ export const getEnableAnonymousAccess = () => {
return true;
};
export const DEFAULT_ANON_PERMISSIONS = Object.freeze({
upload: false,
comment: true,
comment_attachments: false,
comment_vote: true,
poll_vote: true,
tag: true,
tag_vote: true,
favorite: true,
rate_item: false,
filter: true,
exclude_tags: true,
anonymize_users: false,
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
});
export const getAnonPermissions = () => {
const fromConfig = cfg.anonymous_permissions || cfg.websrv?.anonymous_permissions || {};
return {
...DEFAULT_ANON_PERMISSIONS,
...fromConfig
};
};
export const getAnonAnonymize = () => {
const perms = getAnonPermissions();
if (typeof perms.anonymize_users === 'boolean') return perms.anonymize_users;
if (typeof perms.anon_anonymize === 'boolean') return perms.anon_anonymize;
if (cfg.main && typeof cfg.main.anon_anonymize === 'boolean') return cfg.main.anon_anonymize;
if (cfg.main && typeof cfg.main.anonymous_anonymize === 'boolean') return cfg.main.anonymous_anonymize;
if (typeof cfg.anon_anonymize === 'boolean') return cfg.anon_anonymize;
if (typeof cfg.anonymous_anonymize === 'boolean') return cfg.anonymous_anonymize;
return false;
};
export const isAnonymizeSession = (session) => {
if (!session || typeof session !== 'object' || !session.user) {
return !!(cfg.main?.guest_anonymize ?? cfg.guest_anonymize);
}
if (session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))) {
return getAnonAnonymize();
}
return false;
};
export const canAnonDo = (action) => {
if (!getEnableAnonymousAccess()) return false;
const perms = getAnonPermissions();
if (action === 'exclude_tags' || action === 'exclude_tag' || action === 'tag_exclude') {
if (perms.exclude_tags !== undefined) return !!perms.exclude_tags;
if (perms.exclude_tag !== undefined) return !!perms.exclude_tag;
if (perms.tag_exclude !== undefined) return !!perms.tag_exclude;
return perms.filter !== undefined ? !!perms.filter : true;
}
return perms[action] !== undefined ? !!perms[action] : !!DEFAULT_ANON_PERMISSIONS[action];
};
export const getAnonAllowedModes = () => {
const perms = getAnonPermissions();
return Array.isArray(perms.allowed_modes) ? perms.allowed_modes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_modes;
};
export const getAnonAllowedMimes = () => {
const perms = getAnonPermissions();
return Array.isArray(perms.allowed_mimes) ? perms.allowed_mimes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_mimes;
};
export const canAnonMode = (mode) => {
if (!canAnonDo('filter')) return false;
const allowed = getAnonAllowedModes();
const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const name = typeof mode === 'number' ? modeNames[mode] : String(mode).toLowerCase();
return allowed.includes(name);
};
export const canAnonMime = (mime) => {
if (!canAnonDo('filter')) return false;
const allowed = getAnonAllowedMimes();
return allowed.includes(String(mime).toLowerCase());
};
export const isAnonSession = (session) => {
if (!session) return true;
if (session === true) return false;
if (typeof session !== 'object' || !session.user) return true;
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
};
export const checkAnonPermission = (session, action) => {
if (!isAnonSession(session)) return true;
return canAnonDo(action);
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
+13 -1
View File
@@ -20,7 +20,7 @@ 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, ensureAllItemsHaveSlugs } 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 } 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";
@@ -1636,6 +1636,8 @@ process.on('uncaughtException', err => {
domain: cfg.main.url.domain,
hide_comments_from_public: cfg.main.hide_comments_from_public,
guest_anonymize: !!cfg.main.guest_anonymize,
anon_anonymize: false,
is_anonymized: false,
git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown',
get motd() { return getMotd(); },
get manual_approval() { return getManualApproval(); },
@@ -1688,6 +1690,8 @@ process.on('uncaughtException', err => {
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
get enable_item_slugs() { return getEnableItemSlugs(); },
get enable_anonymous_access() { return getEnableAnonymousAccess(); },
get anon_permissions() { return getAnonPermissions(); },
get anon_permissions_json() { return JSON.stringify(getAnonPermissions()); },
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1828,10 +1832,16 @@ process.on('uncaughtException', err => {
? data.user_alternative_steuerung
: (cfg.websrv.user_alternative_steuerung !== false));
const activeSession = activeReq?.session || data?.session || null;
const isAnonymized = isAnonymizeSession(activeSession);
const anonAnonymize = getAnonAnonymize();
data = Object.assign({}, globals, data || {}, {
t: perRequestT,
lang: perRequestLang,
recaptcha_enabled: perRequestRecaptcha,
is_anonymized: isAnonymized,
anon_anonymize: anonAnonymize,
user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
@@ -1843,6 +1853,8 @@ process.on('uncaughtException', err => {
? data.comment_display_mode
: (cfg.websrv.default_comment_display_mode || 0))
});
globals.is_anonymized = isAnonymized;
globals.anon_anonymize = anonAnonymize;
// Random brand image per-render
const brand = cfg.websrv.custom_brand_image;
+5 -3
View File
@@ -6,7 +6,7 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs, canAnonDo, isAnonSession } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
@@ -121,8 +121,10 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
return sendJson(res, { success: false, msg: 'Uploading requires a registered account' }, 403);
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return sendJson(res, { success: false, msg: 'Uploading requires a registered account or anonymous upload permission' }, 403);
}
}
// CSRF validation — required for browser sessions, skipped for API key auth.
+260 -12
View File
@@ -87,6 +87,99 @@
.btn-role { background: rgba(180, 100, 255, 0.15); color: #c084fc; border: 1px solid rgba(180, 100, 255, 0.3); }
.btn-role:hover { background: rgba(180, 100, 255, 0.25); opacity: 1; }
.legacy-filter-btn {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 0 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 0.85rem;
color: #aaa;
white-space: nowrap;
user-select: none;
transition: all 0.2s ease;
height: 42px;
box-sizing: border-box;
}
.legacy-filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.legacy-filter-btn.active {
background: rgba(var(--accent-rgb, 0, 150, 255), 0.15);
border-color: var(--accent);
color: var(--accent);
}
.legacy-filter-btn input {
cursor: pointer;
accent-color: var(--accent);
width: 15px;
height: 15px;
margin: 0;
}
.admin-select-filter {
padding: 0 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #ccc;
font-size: 0.85rem;
outline: none;
cursor: pointer;
transition: all 0.2s ease;
height: 42px;
box-sizing: border-box;
}
.admin-select-filter:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.admin-select-filter:focus {
border-color: var(--accent);
}
.admin-select-filter.active {
background: rgba(var(--accent-rgb, 0, 150, 255), 0.15);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.admin-select-filter option {
background: #1a1a1a;
color: #fff;
}
.admin-select-filter:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-filter-reset {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 0 14px;
height: 42px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
transition: all 0.2s;
box-sizing: border-box;
white-space: nowrap;
}
.btn-filter-reset:hover {
background: rgba(255, 60, 60, 0.15);
border-color: rgba(255, 60, 60, 0.3);
color: #ff6b6b;
}
/* Create User Modal */
#create-user-modal {
display: none;
@@ -236,27 +329,59 @@
</div>
<div style="padding: 0 15px;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 25px; gap: 20px; flex-wrap: wrap;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> <span id="total-label">{{ totalLabel }}</span>.</p>
</div>
<div style="display: flex; gap: 10px; align-items: center; flex-grow: 1; max-width: 500px;">
<div style="flex-grow: 1; position: relative;">
<div>
<button class="btn-modern btn-create" onclick="openCreateUserModal()" style="padding: 11px 18px; font-size: 0.85rem; white-space: nowrap; height: 42px;" title="Create new user">
<i class="fa fa-user-plus"></i> Create User
</button>
</div>
</div>
<!-- Filter & Search Toolbar -->
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 25px; flex-wrap: wrap;">
<div style="flex-grow: 1; min-width: 240px; position: relative;">
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
style="width: 100%; height: 42px; padding: 0 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s; box-sizing: border-box;"
value="{{ q }}">
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
<div id="search-spinner" style="position: absolute; right: 15px; top: 11px; display: none;">
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5" stroke-dasharray="90,150" stroke-dashoffset="0" style="stroke-linecap: round;"></circle>
</svg>
</div>
</div>
<button class="btn-modern btn-create" onclick="openCreateUserModal()" style="padding: 10px 16px; font-size: 0.85rem; white-space: nowrap;" title="Create new user">
<i class="fa fa-user-plus"></i> Create User
<!-- Status Filter -->
<select id="status-filter" class="admin-select-filter {{ status ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by account status">
<option value="">All Statuses</option>
<option value="banned" {{ status === 'banned' ? 'selected' : '' }}>⛔ Banned</option>
<option value="active" {{ status === 'active' ? 'selected' : '' }}>🟢 Active</option>
<option value="pending" {{ status === 'pending' ? 'selected' : '' }}>⏳ Pending</option>
</select>
<!-- Role Filter -->
<select id="role-filter" class="admin-select-filter {{ role ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by user role">
<option value="">All Roles</option>
<option value="staff" {{ role === 'staff' ? 'selected' : '' }}>🛡️ Staff (All)</option>
<option value="admin" {{ role === 'admin' ? 'selected' : '' }}>⚡ Admins</option>
<option value="mod" {{ role === 'mod' ? 'selected' : '' }}>🗡️ Moderators</option>
<option value="user" {{ role === 'user' ? 'selected' : '' }}>👤 Regular Users</option>
</select>
<!-- Legacy Only Toggle -->
<label id="legacy-filter-label" class="legacy-filter-btn {{ onlyLegacy ? 'active' : '' }}" title="Toggle to only show legacy ghost users">
<input type="checkbox" id="legacy-filter" {{ onlyLegacy ? 'checked' : '' }}>
<span><i class="fa fa-ghost" style="opacity: 0.8; margin-right: 4px;"></i>Only legacy</span>
</label>
<!-- Reset Filters Button -->
<button id="reset-filters-btn" class="btn-filter-reset" style="display: {{ (q || status || role || onlyLegacy) ? 'inline-flex' : 'none' }};" title="Reset all filters and search">
<i class="fa fa-rotate-left"></i> Reset
</button>
</div>
</div>
<div class="upload-form">
<table class="admin-users-table responsive-table">
@@ -276,7 +401,7 @@
</table>
<div id="loading-trigger" style="height: 20px; margin-top: 10px;"></div>
<div id="no-users-msg" style="display: {{ users.length === 0 ? 'block' : 'none' }}; padding: 40px; text-align: center; color: #666;">
No users matched your search.
{{ emptyMsg }}
</div>
</div>
</div>
@@ -502,28 +627,81 @@
var hasMore = {!! hasMore ? 'true' : 'false' !!};
var isLoading = false;
var searchQuery = '{{ q }}';
var onlyLegacy = {!! onlyLegacy ? 'true' : 'false' !!};
var currentStatus = '{{ status }}';
var currentRole = '{{ role }}';
var searchInput = document.getElementById('user-search');
var statusFilter = document.getElementById('status-filter');
var roleFilter = document.getElementById('role-filter');
var legacyFilter = document.getElementById('legacy-filter');
var legacyFilterLabel = document.getElementById('legacy-filter-label');
var resetFiltersBtn = document.getElementById('reset-filters-btn');
var totalLabel = document.getElementById('total-label');
var tableBody = document.getElementById('user-table-body');
var loadingTrigger = document.getElementById('loading-trigger');
var noUsersMsg = document.getElementById('no-users-msg');
var spinner = document.getElementById('search-spinner');
var countSpan = document.getElementById('total-count');
function updateUrlAndControls() {
var urlParams = new URLSearchParams();
if (searchQuery) urlParams.set('q', searchQuery);
if (onlyLegacy) {
urlParams.set('legacy', '1');
} else {
if (currentStatus) urlParams.set('status', currentStatus);
if (currentRole) urlParams.set('role', currentRole);
}
var newUrl = window.location.pathname + (urlParams.toString() ? ('?' + urlParams.toString()) : '');
window.history.replaceState({}, '', newUrl);
// Update reset button
var hasFilter = !!(searchQuery || onlyLegacy || currentStatus || currentRole);
if (resetFiltersBtn) resetFiltersBtn.style.display = hasFilter ? 'inline-flex' : 'none';
// Update control states
if (statusFilter) {
statusFilter.disabled = onlyLegacy;
statusFilter.classList.toggle('active', !!currentStatus && !onlyLegacy);
}
if (roleFilter) {
roleFilter.disabled = onlyLegacy;
roleFilter.classList.toggle('active', !!currentRole && !onlyLegacy);
}
if (legacyFilterLabel) {
legacyFilterLabel.classList.toggle('active', onlyLegacy);
}
}
async function fetchUsers(page, q, append) {
if (isLoading) return;
isLoading = true;
if (!append) spinner.style.display = 'block';
try {
var url = '/admin/users?page=' + page + '&q=' + encodeURIComponent(q);
var params = new URLSearchParams();
params.set('page', page);
if (q) params.set('q', q);
if (onlyLegacy) {
params.set('legacy', '1');
} else {
if (currentStatus) params.set('status', currentStatus);
if (currentRole) params.set('role', currentRole);
}
var url = '/admin/users?' + params.toString();
var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
// Update state from headers
var total = res.headers.get('X-Total-Count');
var hasMoreHeader = res.headers.get('X-Has-More');
var totalLabelHeader = res.headers.get('X-Total-Label');
var emptyMsgHeader = res.headers.get('X-Empty-Msg');
if (total !== null) countSpan.textContent = total;
if (totalLabelHeader && totalLabel) totalLabel.textContent = totalLabelHeader;
if (emptyMsgHeader && noUsersMsg) noUsersMsg.textContent = emptyMsgHeader;
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
var html = await res.text();
@@ -548,12 +726,74 @@
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
searchQuery = searchInput.value;
searchQuery = searchInput.value.trim();
currentPage = 1;
updateUrlAndControls();
fetchUsers(1, searchQuery, false);
}, 300);
});
// Status Filter Handling
if (statusFilter) {
statusFilter.addEventListener('change', function() {
currentStatus = this.value;
if (currentStatus && onlyLegacy) {
onlyLegacy = false;
if (legacyFilter) legacyFilter.checked = false;
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Role Filter Handling
if (roleFilter) {
roleFilter.addEventListener('change', function() {
currentRole = this.value;
if (currentRole && onlyLegacy) {
onlyLegacy = false;
if (legacyFilter) legacyFilter.checked = false;
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Legacy Only Filter Handling
if (legacyFilter) {
legacyFilter.addEventListener('change', function() {
onlyLegacy = this.checked;
if (onlyLegacy) {
currentStatus = '';
currentRole = '';
if (statusFilter) statusFilter.value = '';
if (roleFilter) roleFilter.value = '';
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Reset All Filters
if (resetFiltersBtn) {
resetFiltersBtn.addEventListener('click', function() {
searchQuery = '';
searchInput.value = '';
onlyLegacy = false;
currentStatus = '';
currentRole = '';
if (legacyFilter) legacyFilter.checked = false;
if (statusFilter) statusFilter.value = '';
if (roleFilter) roleFilter.value = '';
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, '', false);
});
}
// Infinite Scroll
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting && hasMore && !isLoading) {
@@ -624,6 +864,14 @@
if (data.success) {
closeCreateUserModal();
showFlash(data.msg, 'success');
// If we were filtering by legacy or banned, reset filter so admin can see newly created user
if (onlyLegacy || currentStatus === 'banned') {
onlyLegacy = false;
currentStatus = '';
if (legacyFilter) legacyFilter.checked = false;
if (statusFilter) statusFilter.value = '';
updateUrlAndControls();
}
// Refresh the table from page 1
currentPage = 1;
fetchUsers(1, searchQuery, false);
+65 -8
View File
@@ -1,13 +1,70 @@
@include(snippets/header)
<div id="main" style="display: flex; justify-content: center; align-items: center; min-height: 80vh;">
<div class="banned-container" style="background: rgba(0,0,0,0.85); padding: 40px; margin: 25px; border: 2px solid var(--accent); text-align: center; position: relative; z-index: 10002;">
<h1 style="color: var(--accent); margin-bottom: 20px;">YOU ARE BANNED!</h1>
<video style="max-width: 100%;" src="{{ ban_video }}" autoplay loop controls></video>
<p style="font-size: 1.2em; margin-bottom: 20px;">Reason: <strong>{{ reason }}</strong></p>
<p style="font-size: 1.1em; color: rgba(255,255,255,0.7);">Ban expires: <strong>{{ expires }}</strong></p>
<div style="margin-top: 30px;">
<a href="/logout" class="btn btn-outline-danger" style="padding: 10px 20px; border: 1px solid var(--accent); color: var(--accent); text-decoration: none; border-radius: 4px;">Leave</a>
<div class="pagewrapper">
<div id="main" style="padding: 40px 15px; box-sizing: border-box;">
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
<!-- BANNED VIEW -->
<div id="banned-view" class="banned-container" style="display: {{ isBanned ? 'block' : 'none' }}; background: rgba(18, 18, 20, 0.95); padding: 40px 30px; margin: 0 auto; text-align: center; max-width: 520px; width: 100%; box-sizing: border-box;">
<h1 style="color: #ff4757; margin-bottom: 20px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase;">YOU ARE BANNED!</h1>
<img src="/s/img/banned_angry.png" alt="Banned" style="width: 170px; height: auto; margin: 0 auto 25px auto; display: block;" />
<div style="background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 14px 18px; margin-bottom: 25px; text-align: left;">
<p style="margin: 0 0 8px 0; font-size: 0.95em; color: #ddd;">
<span style="color: #888; font-size: 0.82em; text-transform: uppercase; display: block; margin-bottom: 2px;">Reason</span>
<strong id="ban-reason-display" style="color: #ff6b6b; word-break: break-word;">{{ reason }}</strong>
</p>
<p style="margin: 0; font-size: 0.95em; color: #ddd;">
<span style="color: #888; font-size: 0.82em; text-transform: uppercase; display: block; margin-bottom: 2px;">Ban expires</span>
<strong id="ban-expires-display" style="color: #bbb;">{{ expires }}</strong>
</p>
</div>
<div>
<a href="/logout" class="btn btn-outline-danger" style="padding: 10px 24px; border: 1px solid #e74c3c; color: #e74c3c; text-decoration: none; border-radius: 6px; font-weight: 600;">Leave</a>
</div>
</div>
<!-- UNBANNED / GOOD STANDING VIEW -->
<div id="unbanned-view" class="unbanned-container" style="display: {{ isBanned ? 'none' : 'block' }}; background: rgba(18, 18, 20, 0.95); padding: 40px 30px; margin: 0 auto; border: 2px solid #2ecc71; text-align: center; border-radius: 12px; max-width: 520px; width: 100%; box-shadow: 0 10px 50px rgba(46, 204, 113, 0.25); box-sizing: border-box;">
<div style="width: 80px; height: 80px; border-radius: 50%; background: rgba(46, 204, 113, 0.15); display: flex; align-items: center; justify-content: center; margin: 0 auto 20px auto; border: 2px solid rgba(46, 204, 113, 0.4);">
<i class="fa-solid fa-shield-halved" style="font-size: 38px; color: #2ecc71;"></i>
</div>
<h1 style="color: #2ecc71; margin-bottom: 10px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase; font-size: 1.7em;">YOU ARE NOT BANNED</h1>
<p style="font-size: 1em; color: #aaa; margin-bottom: 22px; line-height: 1.5;">
Your account, connection, and device are in good standing. There are no active bans on this browser.
</p>
<div style="background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 14px 18px; margin-bottom: 25px; text-align: left;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="color: #888; font-size: 0.88em; text-transform: uppercase;">Status</span>
<span style="color: #2ecc71; font-weight: 700; font-size: 0.9em;"><i class="fa-solid fa-circle" style="font-size: 0.6em; vertical-align: middle; margin-right: 4px;"></i> Clear / Active</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #888; font-size: 0.88em; text-transform: uppercase;">IP Address</span>
<code style="color: #ddd; font-family: monospace; font-size: 0.9em;">{{ clientIp || '127.0.0.1' }}</code>
</div>
</div>
<div>
<a href="/" class="btn" style="background: #2ecc71; border: 1px solid #27ae60; color: #fff; padding: 10px 28px; border-radius: 6px; font-size: 1em; font-weight: 600; text-decoration: none; display: inline-block;">Return to Home</a>
</div>
</div>
</div>
</div>
</div>
<script>
(async function() {
try {
const raw = localStorage.getItem('f0ck_anon_tombstone');
const tombstone = raw ? JSON.parse(raw) : null;
if (tombstone && tombstone.banned) {
const bView = document.getElementById('banned-view');
const uView = document.getElementById('unbanned-view');
if (bView) bView.style.display = 'block';
if (uView) uView.style.display = 'none';
const rEl = document.getElementById('ban-reason-display');
const eEl = document.getElementById('ban-expires-display');
if (rEl && tombstone.reason) rEl.textContent = tombstone.reason;
if (eEl && tombstone.expires) eEl.textContent = new Date(tombstone.expires).toLocaleString();
}
} catch (e) {}
})();
</script>
@include(snippets/footer)
+1 -1
View File
@@ -3,7 +3,7 @@
@include(snippets/page-title)
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
@each(items as item)
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.is_onara_active ? 'onara-active' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="@if(session || !guest_anonymize){!! item.display_name || item.username !!}@else anonymous@endif" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.is_onara_active ? 'onara-active' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="@if(!is_anonymized){!! item.display_name || item.username !!}@else anonymous@endif" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
+11 -9
View File
@@ -93,10 +93,10 @@
<div class="blahlol">
@if(user_alternative_infobox)
<div class="user-infobox-block" data-banner-user="@if(session || !guest_anonymize){{ item.username || '' }}@endif" style="--author-accent: @if(session || !guest_anonymize)@if(item.author_color){{ item.author_color }}@else var(--accent) @endif@else var(--accent) @endif; --author-border: @if(session || !guest_anonymize)@if(item.author_color){{ item.author_color }}@else var(--accent) @endif@else var(--accent) @endif;@if(session || !guest_anonymize)@if(item.author_banner_file && user_banner_enabled) --author-banner: url('/a/{{ item.author_banner_file }}'); --author-banner-position: {{ item.author_banner_position === 'center' ? 'center top' : (item.author_banner_position || 'center top') }}; --author-banner-size: {{ item.author_banner_size || 'cover' }};@endif@endif">
<div class="user-infobox-block" data-banner-user="@if(!is_anonymized){{ item.username || '' }}@endif" style="--author-accent: @if(!is_anonymized)@if(item.author_color){{ item.author_color }}@else var(--accent) @endif@else var(--accent) @endif; --author-border: @if(!is_anonymized)@if(item.author_color){{ item.author_color }}@else var(--accent) @endif@else var(--accent) @endif;@if(!is_anonymized)@if(item.author_banner_file && user_banner_enabled) --author-banner: url('/a/{{ item.author_banner_file }}'); --author-banner-position: {{ item.author_banner_position === 'center' ? 'center top' : (item.author_banner_position || 'center top') }}; --author-banner-size: {{ item.author_banner_size || 'cover' }};@endif@endif">
<div class="user-infobox-avatar">
@if(session || !guest_anonymize)
@if(!is_anonymized)
<a href="/user/{{ (item.username || '').toLowerCase() }}">
@if(item.author_avatar_file)
<img src="/a/{{ item.author_avatar_file }}" />
@@ -115,8 +115,8 @@
<div class="user-infobox-info">
<div class="user-infobox-header">
<div class="user-infobox-username-container">
@if(session || !guest_anonymize)
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
@if(!is_anonymized)
<a id="a_username" data-username="{{ item.username || '' }}" @if(session && !session.is_anon) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && !session.is_anon) tooltip="ID: {{ item.author_id }}" @endif class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
@else
<span class="user-infobox-username">anonymous</span>
@endif
@@ -125,7 +125,7 @@
</div>
<div class="user-infobox-body">
<div class="user-infobox-description">
@if(session || !guest_anonymize){!! item.author_description || '' !!}@endif
@if(!is_anonymized){!! item.author_description || '' !!}@endif
</div>
<div class="user-infobox-actions">
@if(session)
@@ -145,7 +145,7 @@
<span class="badge badge-dark">
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a>
@if(!user_alternative_infobox) — [@if(session || !guest_anonymize)<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>@else<span>anonymous</span>@endif] @endif
@if(!user_alternative_infobox) — [@if(!is_anonymized)<a id="a_username" data-username="{{ item.username || '' }}" @if(session && !session.is_anon) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && !session.is_anon && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>@else<span>anonymous</span>@endif] @endif
@if(!user_alternative_infobox)
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
@endif
@@ -205,8 +205,8 @@
<span class="rating-label">{!! tag.tag !!}</span>
</span>
@else
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
<span @if(!is_anonymized && session && !session.is_anon && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }} tag-badge @if(tag.is_excluded) tag-is-excluded @endif" data-tag-id="{{ tag.id }}" data-tag="{{ tag.tag }}" data-tag-normalized="{{ tag.normalized }}">
<a href="/tag/{{ tag.normalized }}" class="tag-name">{!! tag.tag !!}</a>@if(tag.can_exclude)<button type="button" class="tag-exclude-btn" title="@if(tag.is_excluded){{ t('filter.unexclude_tag') || 'Excluded (click to unexclude)' }}@else{{ t('filter.exclude_tag') || 'Exclude tag' }}@endif" aria-label="Exclude tag"><i class="fa-solid @if(tag.is_excluded) fa-circle-check @else fa-ban @endif"></i></button>@endif@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endif
@endeach
@@ -227,7 +227,7 @@
@each(item.favorites as fav)
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@elseif(!session && guest_anonymize)
@elseif(is_anonymized)
<a class="ghost-fav" tooltip="anonymous" flow="up" style="cursor: default;"><img src="/a/default.png" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@@ -252,6 +252,8 @@
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(item.is_comments_locked && !is_mod_or_admin)
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
@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>
+6 -4
View File
@@ -21,6 +21,8 @@
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(item.is_comments_locked && !is_mod_or_admin)
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
@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>
@@ -51,8 +53,8 @@
<span class="rating-label">{!! tag.tag !!}</span>
</span>
@else
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
<span @if(!is_anonymized && session && !session.is_anon && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }} tag-badge @if(tag.is_excluded) tag-is-excluded @endif" data-tag-id="{{ tag.id }}" data-tag="{{ tag.tag }}" data-tag-normalized="{{ tag.normalized }}">
<a href="/tag/{{ tag.normalized }}" class="tag-name">{!! tag.tag !!}</a>@if(tag.can_exclude)<button type="button" class="tag-exclude-btn" title="@if(tag.is_excluded){{ t('filter.unexclude_tag') || 'Excluded (click to unexclude)' }}@else{{ t('filter.exclude_tag') || 'Exclude tag' }}@endif" aria-label="Exclude tag"><i class="fa-solid @if(tag.is_excluded) fa-circle-check @else fa-ban @endif"></i></button>@endif@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endif
@endeach
@@ -138,7 +140,7 @@
</div>
<div class="blahlol">
<span class="badge badge-dark">
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [@if(session || !guest_anonymize)<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>@else<span>anonymous</span>@endif] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [@if(!is_anonymized)<a id="a_username" data-username="{{ item.username || '' }}" @if(session && !session.is_anon) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && !session.is_anon && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>@else<span>anonymous</span>@endif] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
</span>
<span id="hall-badge-container">@if(halls_enabled && item.primaryHall)<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
@@ -185,7 +187,7 @@
@each(item.favorites as fav)
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@elseif(!session && guest_anonymize)
@elseif(is_anonymized)
<a class="ghost-fav" tooltip="anonymous" flow="up" style="cursor: default;"><img src="/a/default.png" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
+43 -4
View File
@@ -942,6 +942,19 @@
window.scrollerLoggedIn = @if(typeof session !== 'undefined' && session)true@else false@endif;
window.scrollerIsMod = @if(typeof session !== 'undefined' && session && (session.admin || session.is_moderator))true@else false@endif;
window.scrollerCsrf = "@if(typeof session !== 'undefined' && session){{ session.csrf_token || '' }}@else@endif";
window.f0ckSession = {
logged_in: window.scrollerLoggedIn,
user: @if(typeof session !== 'undefined' && session && session.user)"{{ session.user }}"@else null@endif,
is_anon: @if(typeof session !== 'undefined' && session && session.is_anon)true@else false@endif,
anon_permissions: {{ anon_permissions_json || '{}' }},
csrf_token: window.scrollerCsrf,
guest_anonymize: @if(guest_anonymize) true @else false @endif,
anon_anonymize: @if(anon_anonymize) true @else false @endif,
is_anonymized: @if(is_anonymized) true @else false @endif,
excluded_tags: @if(typeof session !== 'undefined' && session && session.excluded_tags) {{ JSON.stringify(session.excluded_tags) }} @else [] @endif,
development: @if(development) true @else false @endif
};
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.scrollerEnableNsfl = {{ enable_nsfl ? 'true' : 'false' }};
window.scrollerEnableSwf = {{ enable_swf ? 'true' : 'false' }};
window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif;
@@ -1110,27 +1123,52 @@
<button class="filter-reset-btn" id="filter-reset-btn">{{ t('scroller.reset_all') }}</button>
</div>
<div class="filter-scroll-area">
@js
const _isScrollerAnon = !(typeof session !== 'undefined' && session && session.user && !session.is_anon);
const _scrollerAllowedModes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const _scrollerShowSfw = !_isScrollerAnon || _scrollerAllowedModes.includes('sfw');
const _scrollerShowNsfw = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfw'));
const _scrollerShowNsfl = enable_nsfl && ((typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfl')));
const _scrollerShowAll = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('all'));
const _scrollerShowUntagged = (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isScrollerAnon && _scrollerAllowedModes.includes('untagged'));
const _scrollerActiveModes = [_scrollerShowSfw ? 'sfw' : null, _scrollerShowNsfw ? 'nsfw' : null, _scrollerShowNsfl ? 'nsfl' : null, _scrollerShowUntagged ? 'untagged' : null].filter(Boolean);
const _scrollerIsSingleMode = _isScrollerAnon && _scrollerActiveModes.length === 1 && !_scrollerShowAll;
const _scrollerSingleMode = _scrollerIsSingleMode ? _scrollerActiveModes[0] : null;
const _scrollerAllowedMimes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_mimes) || ['image', 'video', 'audio', 'flash', 'pdf'];
const _scrollerIsSingleMime = _isScrollerAnon && _scrollerAllowedMimes.length === 1;
const _scrollerSingleMime = _scrollerIsSingleMime ? _scrollerAllowedMimes[0] : null;
@endjs
<div class="filter-section">
<div class="filter-section-label">{{ t('scroller.rating') }}</div>
<div class="pill-group" id="mode-pills">
@if(_scrollerIsSingleMode)
<button class="filter-pill active locked" data-mode="{{ _scrollerSingleMode === 'sfw' ? 0 : (_scrollerSingleMode === 'nsfw' ? 1 : (_scrollerSingleMode === 'nsfl' ? 4 : 2)) }}" style="cursor: not-allowed; pointer-events: none;" title="Locked by site permissions"><i class="fa-solid fa-lock" style="margin-right: 4px;"></i>{{ _scrollerSingleMode.toUpperCase() }}</button>
@else
@if(_scrollerShowSfw)
<button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button>
@if(session)
@endif
@if(_scrollerShowNsfw)
<button class="filter-pill" data-mode="1"><i class="fa-solid fa-fire"></i>NSFW</button>
@endif
@if(session && enable_nsfl)
@if(_scrollerShowNsfl)
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
@endif
@if(session)
@if(_scrollerShowAll)
<button class="filter-pill" data-mode="3">{{ t('scroller.all') }}</button>
@endif
@if(session && (session.admin || session.is_moderator))
@if(_scrollerShowUntagged)
<button class="filter-pill" data-mode="2">{{ t('scroller.untagged') }}</button>
@endif
@endif
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">{{ t('scroller.media_type') }}</div>
<div class="pill-group" id="mime-pills">
@if(_scrollerIsSingleMime)
<button class="filter-pill active locked" data-mime="{{ _scrollerSingleMime }}" style="cursor: not-allowed; pointer-events: none;" title="Locked by site permissions"><i class="fa-solid fa-lock" style="margin-right: 4px;"></i>{{ t('scroller.' + _scrollerSingleMime) || _scrollerSingleMime }}</button>
@else
<button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>{{ t('scroller.all') }}</button>
@if(scroller_mime_cats.includes('video'))
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>{{ t('scroller.video') }}</button>
@@ -1141,6 +1179,7 @@
@if(scroller_mime_cats.includes('audio'))
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>{{ t('scroller.audio') }}</button>
@endif
@endif
</div>
</div>
<div class="filter-section">
+52 -17
View File
@@ -1,40 +1,75 @@
<div id="excluded-tags-overlay" style="display: none;">
<div id="excluded-tags-close">&times;</div>
<div class="search-container">
@js
const _isAnonUser = !(typeof session !== 'undefined' && session && session.user && !session.is_anon);
const _canExcludeTags = !_isAnonUser || (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.exclude_tags !== false && anon_permissions.filter !== false);
const _allowedModes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const _showSfw = !_isAnonUser || _allowedModes.includes('sfw');
const _showNsfw = !_isAnonUser || _allowedModes.includes('nsfw');
const _showNsfl = enable_nsfl && (!_isAnonUser || _allowedModes.includes('nsfl'));
const _showUntagged = (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isAnonUser && _allowedModes.includes('untagged'));
const _showAll = !_isAnonUser || _allowedModes.includes('all');
const _activeRatingList = [_showSfw ? 'sfw' : null, _showNsfw ? 'nsfw' : null, _showNsfl ? 'nsfl' : null, _showUntagged ? 'untagged' : null].filter(Boolean);
const _isSingleRating = _isAnonUser && _activeRatingList.length === 1 && !_showAll;
const _singleRating = _isSingleRating ? _activeRatingList[0] : null;
const _allowedMimes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_mimes) || ['image', 'video', 'audio', 'flash', 'pdf'];
const _isSingleMime = _isAnonUser && _allowedMimes.length === 1;
const _singleMime = _isSingleMime ? _allowedMimes[0] : null;
const _hasMimeCats = typeof scroller_mime_cats !== 'undefined' && Array.isArray(scroller_mime_cats);
const _showVideo = _hasMimeCats && scroller_mime_cats.includes('video') && (!_isAnonUser || _allowedMimes.includes('video'));
const _showAudio = _hasMimeCats && scroller_mime_cats.includes('audio') && (!_isAnonUser || _allowedMimes.includes('audio'));
const _showImage = _hasMimeCats && scroller_mime_cats.includes('image') && (!_isAnonUser || _allowedMimes.includes('image'));
const _showFlash = enable_swf && (!_isAnonUser || _allowedMimes.includes('flash'));
@endjs
@if(_canExcludeTags)
<div class="excluded-tags-section-header">
<span class="excluded-tags-section-title"><i class="fa-solid fa-ban"></i> {{ t('filter.excluded_tags') || 'Excluded Tags' }}</span>
<span id="nav_excluded_tags_count" class="badge badge-danger excluded-tags-count-badge" style="display: none;">0</span>
</div>
<div id="nav_excluded_tags_list"></div>
<div class="nav-exclude">
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
<div id="nav_exclude_suggestions" class="tag-suggestions"></div>
</div>
@endif
<!-- modes -->
<div class="mode-filter">
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
<div class="rating-selector" id="rating-selector">
<button class="rating-toggle-btn" data-rating="sfw" id="rating-btn-sfw" title="SFW">SFW</button>
<button class="rating-toggle-btn" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW">NSFW</button>
@if(enable_nsfl)
<button class="rating-toggle-btn" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL">NSFL</button>
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"@if(_isSingleRating) disabled style="cursor: not-allowed; opacity: 0.6;" title="Locked by site permissions"@endif><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
<div class="rating-selector @if(_isSingleRating) locked @endif" id="rating-selector" @if(_isSingleRating) data-locked-rating="{{ _singleRating }}" @endif>
@if(_showSfw)
<button class="rating-toggle-btn @if(_isSingleRating && _singleRating === 'sfw') active locked @endif" data-rating="sfw" id="rating-btn-sfw" title="SFW"@if(_isSingleRating) disabled style="cursor: not-allowed; opacity: 0.85;"@endif>SFW@if(_isSingleRating && _singleRating === 'sfw')<i class="fa-solid fa-lock" style="margin-left: 5px; font-size: 0.75em; opacity: 0.7;"></i>@endif</button>
@endif
@if(session && session.admin || session && session.is_moderator)
<button class="rating-toggle-btn" data-rating="untagged" id="rating-btn-untagged" title="Untagged">UNT</button>
@if(_showNsfw)
<button class="rating-toggle-btn @if(_isSingleRating && _singleRating === 'nsfw') active locked @endif" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW"@if(_isSingleRating) disabled style="cursor: not-allowed; opacity: 0.85;"@endif>NSFW@if(_isSingleRating && _singleRating === 'nsfw')<i class="fa-solid fa-lock" style="margin-left: 5px; font-size: 0.75em; opacity: 0.7;"></i>@endif</button>
@endif
@if(_showNsfl)
<button class="rating-toggle-btn @if(_isSingleRating && _singleRating === 'nsfl') active locked @endif" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL"@if(_isSingleRating) disabled style="cursor: not-allowed; opacity: 0.85;"@endif>NSFL@if(_isSingleRating && _singleRating === 'nsfl')<i class="fa-solid fa-lock" style="margin-left: 5px; font-size: 0.75em; opacity: 0.7;"></i>@endif</button>
@endif
@if(_showUntagged)
<button class="rating-toggle-btn @if(_isSingleRating && _singleRating === 'untagged') active locked @endif" data-rating="untagged" id="rating-btn-untagged" title="Untagged"@if(_isSingleRating) disabled style="cursor: not-allowed; opacity: 0.85;"@endif>UNT@if(_isSingleRating && _singleRating === 'untagged')<i class="fa-solid fa-lock" style="margin-left: 5px; font-size: 0.75em; opacity: 0.7;"></i>@endif</button>
@endif
@if(_showAll && !_isSingleRating)
<button class="rating-toggle-btn rating-toggle-all" id="rating-btn-all" title="Show All Ratings">ALL</button>
@endif
</div>
</div>
<!-- mimes -->
@if(show_mime_picker)
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
@if(scroller_mime_cats && scroller_mime_cats.includes('video'))
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu" @if(_isSingleMime) data-locked-mime="{{ _singleMime }}" @endif>
@if(_showVideo)
<label class="nav-mime-item @if(_isSingleMime) locked @endif" @if(_isSingleMime) style="cursor: not-allowed; opacity: 0.85;" title="Locked by site permissions" @endif><input type="checkbox" value="video" id="mime-video" @if(_isSingleMime) checked disabled @endif>{{ t('filter.video') }}@if(_isSingleMime)<i class="fa-solid fa-lock" style="margin-left: 6px; font-size: 0.8em; opacity: 0.7;"></i>@endif</label>
@endif
@if(scroller_mime_cats && scroller_mime_cats.includes('audio'))
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
@if(_showAudio)
<label class="nav-mime-item @if(_isSingleMime) locked @endif" @if(_isSingleMime) style="cursor: not-allowed; opacity: 0.85;" title="Locked by site permissions" @endif><input type="checkbox" value="audio" id="mime-audio" @if(_isSingleMime) checked disabled @endif>{{ t('filter.audio') }}@if(_isSingleMime)<i class="fa-solid fa-lock" style="margin-left: 6px; font-size: 0.8em; opacity: 0.7;"></i>@endif</label>
@endif
@if(scroller_mime_cats && scroller_mime_cats.includes('image'))
<label class="nav-mime-item"><input type="checkbox" value="image" id="mime-image">{{ t('filter.image') }}</label>
@if(_showImage)
<label class="nav-mime-item @if(_isSingleMime) locked @endif" @if(_isSingleMime) style="cursor: not-allowed; opacity: 0.85;" title="Locked by site permissions" @endif><input type="checkbox" value="image" id="mime-image" @if(_isSingleMime) checked disabled @endif>{{ t('filter.image') }}@if(_isSingleMime)<i class="fa-solid fa-lock" style="margin-left: 6px; font-size: 0.8em; opacity: 0.7;"></i>@endif</label>
@endif
@if(enable_swf)
<label class="nav-mime-item"><input type="checkbox" value="flash" id="mime-flash">{{ t('filter.flash') }}</label>
@if(_showFlash)
<label class="nav-mime-item @if(_isSingleMime) locked @endif" @if(_isSingleMime) style="cursor: not-allowed; opacity: 0.85;" title="Locked by site permissions" @endif><input type="checkbox" value="flash" id="mime-flash" @if(_isSingleMime) checked disabled @endif>{{ t('filter.flash') }}@if(_isSingleMime)<i class="fa-solid fa-lock" style="margin-left: 6px; font-size: 0.8em; opacity: 0.7;"></i>@endif</label>
@endif
</div>
@endif
+11 -2
View File
@@ -178,7 +178,7 @@
@endif
@if(private_society && !session)
<script>
window.f0ckSession = { logged_in: false, onara: @if(onara) true @else false @endif, enable_item_slugs: @if(enable_item_slugs) true @else false @endif, enable_anonymous_access: @if(enable_anonymous_access) true @else false @endif, hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif, guest_anonymize: @if(guest_anonymize) true @else false @endif, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif, public_untagged: @if(public_untagged) true @else false @endif, public_nsfw: @if(public_nsfw) true @else false @endif };
window.f0ckSession = { logged_in: false, is_anon: false, excluded_tags: [], onara: @if(onara) true @else false @endif, enable_item_slugs: @if(enable_item_slugs) true @else false @endif, enable_anonymous_access: @if(enable_anonymous_access) true @else false @endif, anon_permissions: {{ anon_permissions_json || '{}' }}, hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif, guest_anonymize: @if(guest_anonymize) true @else false @endif, anon_anonymize: @if(anon_anonymize) true @else false @endif, is_anonymized: @if(is_anonymized) true @else false @endif, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif, public_untagged: @if(public_untagged) true @else false @endif, public_nsfw: @if(public_nsfw) true @else false @endif };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
(() => {
@@ -422,10 +422,14 @@
public_nsfw: @if(public_nsfw) true @else false @endif,
enable_item_slugs: @if(enable_item_slugs) true @else false @endif,
enable_anonymous_access: @if(enable_anonymous_access) true @else false @endif,
anon_permissions: {{ anon_permissions_json || '{}' }},
strict_mode: @if(session && session.strict_mode) true @else false @endif,
logged_in: @if(session) true @else false @endif,
excluded_tags: @if(session && session.excluded_tags) {{ JSON.stringify(session.excluded_tags) }} @else [] @endif,
hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif,
guest_anonymize: @if(guest_anonymize) true @else false @endif,
anon_anonymize: @if(anon_anonymize) true @else false @endif,
is_anonymized: @if(is_anonymized) true @else false @endif,
use_new_layout: @if(session)@if(session.use_new_layout) true @else false @endif@else @if(default_layout === 'legacy')false @else true @endif@endif,
csrf_token: "{{ csrf_token }}",
default_theme: "{{ default_theme }}",
@@ -477,7 +481,7 @@
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif,
hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif,
mode: {{ mode !== undefined ? mode : 0 }}
mode: {{ typeof mode !== 'undefined' ? mode : 0 }}
};
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
@@ -557,6 +561,11 @@
zomg_on: "{{ t('toast.zomg_on') }}",
zomg_off: "{{ t('toast.zomg_off') }}",
copied: "{{ t('toast.copied') }}",
exclude_tag: "{{ t('filter.exclude_tag') || 'Exclude tag' }}",
unexclude_tag: "{{ t('filter.unexclude_tag') || 'Excluded (click to unexclude)' }}",
no_tags_excluded: "{{ t('filter.no_tags_excluded') || 'No tags excluded yet' }}",
tag_excluded_msg: "{{ t('filter.tag_excluded_msg') || 'Tag {tag} added to excluded tags' }}",
tag_unexcluded_msg: "{{ t('filter.tag_unexcluded_msg') || 'Tag {tag} removed from excluded tags' }}",
// actions
fav_added: "{{ t('toast.fav_added') }}",
fav_removed: "{{ t('toast.fav_removed') }}",
+1 -1
View File
@@ -104,7 +104,7 @@
<canvas class="hidden-xs" id="bg"></canvas>
@endif
@include(snippets/navbar)
@if(session)
@if(session || (enable_anonymous_access && anon_permissions.filter))
@include(snippets/excluded-tags-modal)
@endif
+1 -1
View File
@@ -1,5 +1,5 @@
@each(items as item)
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="@if(session || !guest_anonymize){!! item.display_name || item.username !!}@else anonymous@endif" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="@if(!is_anonymized){!! item.display_name || item.username !!}@else anonymous@endif" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
+9 -1
View File
@@ -14,7 +14,7 @@
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
<div class="nav-collapse" id="navbarContent">
<div class="nav-links">
@if(!session.is_anon)
@if(!session.is_anon || (enable_anonymous_access && anon_permissions.upload))
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-angle-up"></i> {{ t('nav.upload') }}</a>
@endif
@if(meme_creator)
@@ -118,6 +118,7 @@
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a>
<!-- Filter -->
@if(!session.is_anon || (enable_anonymous_access && anon_permissions.filter))
<a href="#" id="nav-filter-btn" title="Filter" style="position: relative; display: inline-flex; align-items: center; justify-content: center; z-index: 1000;">
<i class="fa-solid fa-filter"></i>
@if(session.mode == 0)
@@ -134,6 +135,7 @@
<span id="nav-filter-badge" class="filter-badge filter-badge-sfw">SFW</span>
@endif
</a>
@endif
<!-- Logout -->
<a href="/logout" title="Logout" class="desktop-only"><i class="fa-solid fa-right-from-bracket"></i></a>
@@ -267,6 +269,12 @@
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
@endif
<a href="#" id="nav-search-btn" title="Search"><i class="fa-solid fa-magnifying-glass"></i></a>
@if(enable_anonymous_access && anon_permissions.filter)
<a href="#" id="nav-filter-btn" title="Filter" style="position: relative; display: inline-flex; align-items: center; justify-content: center; z-index: 1000;">
<i class="fa-solid fa-filter"></i>
<span id="nav-filter-badge" class="filter-badge filter-badge-sfw">SFW</span>
</a>
@endif
</div>
</div>