fdsafs
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
+53
-35
@@ -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,46 +241,57 @@
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
try {
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs && res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
|
||||
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 {
|
||||
favcontainer.hidden = true;
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+141
-17
@@ -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,8 +630,15 @@
|
||||
reason: data.reason,
|
||||
expires: data.expires
|
||||
});
|
||||
if (window.location.pathname !== '/banned') {
|
||||
window.location.href = data.redirect || '/banned';
|
||||
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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+681
-101
File diff suppressed because it is too large
Load Diff
+76
-15
@@ -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]);
|
||||
|
||||
@@ -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)}"` : '';
|
||||
|
||||
|
||||
+58
-42
@@ -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,50 +211,59 @@
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
try {
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs && res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
else {
|
||||
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(errMsg, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
} catch (err) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to update favorite.', 3000, 'error');
|
||||
}
|
||||
|
||||
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 {
|
||||
// lul
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
|
||||
+200
-75
@@ -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,87 +1135,179 @@ 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`
|
||||
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,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(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
|
||||
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 NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
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
|
||||
),
|
||||
all_users AS (
|
||||
SELECT * FROM filtered_users
|
||||
UNION ALL
|
||||
SELECT * FROM ghost_users
|
||||
),
|
||||
paginated_users AS (
|
||||
SELECT * FROM all_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,
|
||||
COALESCE(cc.comment_count, 0) as comment_count,
|
||||
COALESCE(la.failed_attempts, 0) 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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as comment_count
|
||||
FROM comments
|
||||
WHERE user_id = pu.id AND is_deleted = false
|
||||
) cc ON pu.id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as failed_attempts
|
||||
FROM login_attempts
|
||||
WHERE username = pu.login
|
||||
AND success = false
|
||||
AND type = 'login'
|
||||
AND attempted_at > now() - interval '10 hours'
|
||||
) la ON true
|
||||
`;
|
||||
`;
|
||||
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})`;
|
||||
}
|
||||
}
|
||||
|
||||
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``}
|
||||
`;
|
||||
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);
|
||||
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,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(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
|
||||
WHERE true
|
||||
${qCond ? db`AND ${qCond}` : db``}
|
||||
${statusCond ? db`AND ${statusCond}` : db``}
|
||||
${roleCond ? db`AND ${roleCond}` : db``}
|
||||
),
|
||||
paginated_users AS (
|
||||
SELECT * FROM filtered_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,
|
||||
COALESCE(cc.comment_count, 0) as comment_count,
|
||||
COALESCE(la.failed_attempts, 0) 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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as comment_count
|
||||
FROM comments
|
||||
WHERE user_id = pu.id AND is_deleted = false
|
||||
) cc ON pu.id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as failed_attempts
|
||||
FROM login_attempts
|
||||
WHERE username = pu.login
|
||||
AND success = false
|
||||
AND type = 'login'
|
||||
AND attempted_at > now() - interval '10 hours'
|
||||
) la ON true
|
||||
`;
|
||||
|
||||
const totalCountActual = await db`
|
||||
SELECT COUNT(*) as c FROM "user" u
|
||||
WHERE true
|
||||
${qCond ? db`AND ${qCond}` : db``}
|
||||
${statusCond ? db`AND ${statusCond}` : db``}
|
||||
${roleCond ? db`AND ${roleCond}` : db``}
|
||||
`;
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint: hwFingerprint || tombstoneHw,
|
||||
bannedBy: activeTombstoneBan.banned_by,
|
||||
reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`,
|
||||
expires: activeTombstoneBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (!alreadyFpBanned) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint: hwFingerprint || tombstoneHw,
|
||||
bannedBy: activeTombstoneBan.banned_by,
|
||||
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) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: hwBan.banned_by,
|
||||
reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`,
|
||||
expires: hwBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (!alreadyFpBanned) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: hwBan.banned_by,
|
||||
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) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: fpBan.banned_by,
|
||||
reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`,
|
||||
expires: fpBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
|
||||
if (!alreadyHwBanned) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: fpBan.banned_by,
|
||||
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,15 +175,19 @@ 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];
|
||||
await security.banAnonymousUser({
|
||||
userId,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
reason: u.ban_reason || 'Banned',
|
||||
expires: u.ban_expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (!alreadyFpBanned) {
|
||||
await security.banAnonymousUser({
|
||||
userId,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
reason: u.ban_reason || 'Banned',
|
||||
expires: u.ban_expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
}
|
||||
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
|
||||
+100
-13
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -317,8 +317,8 @@ export default new class {
|
||||
}
|
||||
|
||||
// 3. Ban the hardware fingerprint
|
||||
const associatedHws = new Set();
|
||||
if (banHardware) {
|
||||
const associatedHws = new Set();
|
||||
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
|
||||
|
||||
if (userId) {
|
||||
@@ -348,9 +348,8 @@ export default new class {
|
||||
}
|
||||
|
||||
// 4. Cascade to associated IPs
|
||||
const associatedIps = new Set();
|
||||
if (banIps) {
|
||||
const associatedIps = new Set();
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+265
-17
@@ -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,28 +329,60 @@
|
||||
</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;">
|
||||
<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;"
|
||||
value="{{ q }}">
|
||||
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; 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">
|
||||
<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%; 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: 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>
|
||||
|
||||
<!-- 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 class="upload-form">
|
||||
<table class="admin-users-table responsive-table">
|
||||
<thead>
|
||||
@@ -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);
|
||||
|
||||
+66
-9
@@ -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>
|
||||
<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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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) <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) <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>
|
||||
|
||||
@@ -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) <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) <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
@@ -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">
|
||||
|
||||
@@ -1,40 +1,75 @@
|
||||
<div id="excluded-tags-overlay" style="display: none;">
|
||||
<div id="excluded-tags-close">×</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
|
||||
|
||||
@@ -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') }}",
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user