fdsafs
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"invite_secret": "YOUR_SECRET_HERE",
|
"invite_secret": "YOUR_SECRET_HERE",
|
||||||
"hide_comments_from_public": false,
|
"hide_comments_from_public": false,
|
||||||
"guest_anonymize": false,
|
"guest_anonymize": false,
|
||||||
|
"anon_anonymize": false,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"development": true
|
"development": true
|
||||||
},
|
},
|
||||||
@@ -36,6 +37,34 @@
|
|||||||
"allow_user_upload_visibility": true,
|
"allow_user_upload_visibility": true,
|
||||||
"enable_item_slugs": true,
|
"enable_item_slugs": true,
|
||||||
"enable_anonymous_access": 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,
|
"onara": false,
|
||||||
"nsfl_tag_id": 4,
|
"nsfl_tag_id": 4,
|
||||||
"allowedMimes": [
|
"allowedMimes": [
|
||||||
|
|||||||
+229
-13
@@ -7486,6 +7486,112 @@ a.removetag i {
|
|||||||
vertical-align: middle;
|
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 {
|
.badge-greentext {
|
||||||
color: #789922;
|
color: #789922;
|
||||||
text-shadow: inherit !important;
|
text-shadow: inherit !important;
|
||||||
@@ -10106,6 +10212,26 @@ input#s_avatar {
|
|||||||
cursor: pointer;
|
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 {
|
.nav-mime-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
@@ -11715,23 +11841,19 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
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 a:hover,
|
||||||
.steuerung button:hover {
|
.steuerung button:hover {
|
||||||
color: var(--accent);
|
opacity: 0.8;
|
||||||
opacity: 0.85;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.steuerung a:active,
|
.steuerung a:active,
|
||||||
.steuerung button:active,
|
.steuerung button:active,
|
||||||
.steuerung a.is-clicked,
|
.steuerung a.is-clicked,
|
||||||
.steuerung button.is-clicked {
|
.steuerung button.is-clicked {
|
||||||
transform: scale(0.92) translateY(1px);
|
opacity: 0.6;
|
||||||
opacity: 0.65;
|
|
||||||
transition-duration: 0.05s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.steuerung.steuerung-icon {
|
.steuerung.steuerung-icon {
|
||||||
@@ -11745,7 +11867,7 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.6em;
|
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;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -11755,17 +11877,14 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
|
|
||||||
.steuerung.steuerung-icon a:hover,
|
.steuerung.steuerung-icon a:hover,
|
||||||
.steuerung.steuerung-icon button:hover {
|
.steuerung.steuerung-icon button:hover {
|
||||||
color: var(--accent);
|
opacity: 0.8;
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.steuerung.steuerung-icon a:active,
|
.steuerung.steuerung-icon a:active,
|
||||||
.steuerung.steuerung-icon button:active,
|
.steuerung.steuerung-icon button:active,
|
||||||
.steuerung.steuerung-icon a.is-clicked,
|
.steuerung.steuerung-icon a.is-clicked,
|
||||||
.steuerung.steuerung-icon button.is-clicked {
|
.steuerung.steuerung-icon button.is-clicked {
|
||||||
transform: scale(0.88) translateY(1px);
|
opacity: 0.6;
|
||||||
opacity: 0.65;
|
|
||||||
transition-duration: 0.05s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme='light'] .steuerung a,
|
html[theme='light'] .steuerung a,
|
||||||
@@ -12503,6 +12622,11 @@ input:checked+.slider:before {
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rating-selector.locked {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.rating-toggle-btn {
|
.rating-toggle-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -12528,6 +12652,12 @@ input:checked+.slider:before {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
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 */
|
/* Per-rating active colours */
|
||||||
.rating-toggle-btn.active[data-rating="sfw"] {
|
.rating-toggle-btn.active[data-rating="sfw"] {
|
||||||
background: #2e7d32;
|
background: #2e7d32;
|
||||||
@@ -15670,6 +15800,41 @@ span.gchat-post-card--loading {
|
|||||||
gap: 5px;
|
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 {
|
#nav_excluded_tags_list {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -15677,6 +15842,57 @@ span.gchat-post-card--loading {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 100%;
|
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 {
|
.nav-exclude {
|
||||||
|
|||||||
+21
-3
@@ -99,7 +99,7 @@
|
|||||||
if (canManage) {
|
if (canManage) {
|
||||||
span.classList.add('can-cycle');
|
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);
|
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,13 @@
|
|||||||
|
|
||||||
|
|
||||||
const toggleFavEvent = async (e) => {
|
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();
|
const ctx = getContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { postid } = ctx;
|
const { postid } = ctx;
|
||||||
@@ -234,10 +241,11 @@
|
|||||||
const favoBtn = document.querySelector("#a_favo");
|
const favoBtn = document.querySelector("#a_favo");
|
||||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await post('/api/v2/togglefav', {
|
const res = await post('/api/v2/togglefav', {
|
||||||
postid: postid
|
postid: postid
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res && res.success) {
|
||||||
if (window.invalidateItemCache) {
|
if (window.invalidateItemCache) {
|
||||||
window.invalidateItemCache(postid);
|
window.invalidateItemCache(postid);
|
||||||
}
|
}
|
||||||
@@ -251,7 +259,7 @@
|
|||||||
|
|
||||||
const favcontainer = document.querySelector('#favs');
|
const favcontainer = document.querySelector('#favs');
|
||||||
favcontainer.innerHTML = "";
|
favcontainer.innerHTML = "";
|
||||||
if (res.favs.length > 0) {
|
if (res.favs && res.favs.length > 0) {
|
||||||
res.favs.forEach(f => {
|
res.favs.forEach(f => {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = `/user/${f.user}`;
|
a.href = `/user/${f.user}`;
|
||||||
@@ -274,6 +282,16 @@
|
|||||||
|
|
||||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
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);
|
if (navigator.vibrate) navigator.vibrate(50);
|
||||||
|
} else {
|
||||||
|
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
|
||||||
|
if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage(errMsg, 3000, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage('Failed to update favorite.', 3000, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+139
-15
@@ -84,6 +84,9 @@
|
|||||||
this.rawSeed = null;
|
this.rawSeed = null;
|
||||||
this.isSessionReady = false;
|
this.isSessionReady = false;
|
||||||
this.hwFingerprint = null;
|
this.hwFingerprint = null;
|
||||||
|
try {
|
||||||
|
this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null;
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +95,13 @@
|
|||||||
*/
|
*/
|
||||||
async getHardwareFingerprint() {
|
async getHardwareFingerprint() {
|
||||||
if (this.hwFingerprint) return this.hwFingerprint;
|
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 {
|
try {
|
||||||
let glVendor = '';
|
let glVendor = '';
|
||||||
@@ -117,16 +127,67 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} 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 concurrency = navigator.hardwareConcurrency || 0;
|
||||||
const memory = navigator.deviceMemory || 0;
|
const memory = navigator.deviceMemory || 0;
|
||||||
|
const platform = navigator.platform || '';
|
||||||
|
|
||||||
|
// Display, Gamut & Dynamic Range
|
||||||
const screenInfo = [
|
const screenInfo = [
|
||||||
window.screen ? window.screen.width : 0,
|
window.screen ? window.screen.width : 0,
|
||||||
window.screen ? window.screen.height : 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.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');
|
].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 = '';
|
let audioFp = '';
|
||||||
try {
|
try {
|
||||||
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||||
@@ -159,14 +220,26 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} 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 = [
|
const rawHardware = [
|
||||||
glVendor,
|
glVendor,
|
||||||
glRenderer,
|
glRenderer,
|
||||||
glLimits,
|
glLimits,
|
||||||
|
gpuArch,
|
||||||
concurrency,
|
concurrency,
|
||||||
memory,
|
memory,
|
||||||
|
platform,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
audioFp
|
inputHardware,
|
||||||
|
canvasFp,
|
||||||
|
audioFp,
|
||||||
|
mathPrecision
|
||||||
].join('~~~');
|
].join('~~~');
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -174,6 +247,9 @@
|
|||||||
const hashBuf = await window.crypto.subtle.digest('SHA-256', data);
|
const hashBuf = await window.crypto.subtle.digest('SHA-256', data);
|
||||||
const hashHex = bytesToHex(new Uint8Array(hashBuf));
|
const hashHex = bytesToHex(new Uint8Array(hashBuf));
|
||||||
this.hwFingerprint = `HW:${hashHex}`;
|
this.hwFingerprint = `HW:${hashHex}`;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('f0ck_anon_hw_fp', this.hwFingerprint);
|
||||||
|
} catch (e) {}
|
||||||
return this.hwFingerprint;
|
return this.hwFingerprint;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', 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_PUB, this.pubkey);
|
||||||
localStorage.setItem(STORAGE_KEY_FP, this.fingerprint);
|
localStorage.setItem(STORAGE_KEY_FP, this.fingerprint);
|
||||||
|
|
||||||
await this.ensureSession(true);
|
await this.ensureSession(true, isExplicitLogin);
|
||||||
return this.getIdentity();
|
return this.getIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +590,11 @@
|
|||||||
/**
|
/**
|
||||||
* Authenticate to backend and establish/refresh anonymous session
|
* 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 user is already logged in as a real registered user, don't overwrite session!
|
||||||
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) {
|
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) {
|
||||||
return;
|
return;
|
||||||
@@ -550,9 +630,16 @@
|
|||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
expires: data.expires
|
expires: data.expires
|
||||||
});
|
});
|
||||||
|
this.clearStoredIdentity();
|
||||||
|
this.updateNavUI(false);
|
||||||
|
// ONLY redirect to /banned if user was explicitly trying to login!
|
||||||
|
if (isExplicitLogin) {
|
||||||
|
const anonModal = document.getElementById('anon-ssh-modal');
|
||||||
|
if (anonModal) anonModal.style.display = 'none';
|
||||||
if (window.location.pathname !== '/banned') {
|
if (window.location.pathname !== '/banned') {
|
||||||
window.location.href = data.redirect || '/banned';
|
window.location.href = data.redirect || '/banned';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +681,9 @@
|
|||||||
setTombstone(tombstone) {
|
setTombstone(tombstone) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone));
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +743,7 @@
|
|||||||
localStorage.removeItem(STORAGE_KEY_PRIV);
|
localStorage.removeItem(STORAGE_KEY_PRIV);
|
||||||
localStorage.removeItem(STORAGE_KEY_PUB);
|
localStorage.removeItem(STORAGE_KEY_PUB);
|
||||||
localStorage.removeItem(STORAGE_KEY_FP);
|
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.cryptoKey = null;
|
||||||
this.rawSeed = null;
|
this.rawSeed = null;
|
||||||
this.rawPub = null;
|
this.rawPub = null;
|
||||||
@@ -669,11 +760,27 @@
|
|||||||
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
||||||
return;
|
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 {
|
try {
|
||||||
if (!this.pubkey) {
|
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
|
// Sync any guest favorites saved in localStorage to this anon account
|
||||||
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
|
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
|
||||||
await window.f0ckGuestFavs.importToAccount();
|
await window.f0ckGuestFavs.importToAccount();
|
||||||
@@ -698,12 +805,16 @@
|
|||||||
this.clearStoredIdentity();
|
this.clearStoredIdentity();
|
||||||
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||||
} finally {
|
} 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) {
|
updateNavUI(isAnon) {
|
||||||
const icon = document.getElementById('nav-visitor-icon');
|
const icon = document.getElementById('nav-visitor-icon');
|
||||||
@@ -765,8 +876,12 @@
|
|||||||
* Initialize on page startup
|
* Initialize on page startup
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
|
if (window.location.pathname === '/banned') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
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(() => {});
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
@@ -780,6 +895,15 @@
|
|||||||
return;
|
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 storedPriv = localStorage.getItem(STORAGE_KEY_PRIV);
|
||||||
const storedPub = localStorage.getItem(STORAGE_KEY_PUB);
|
const storedPub = localStorage.getItem(STORAGE_KEY_PUB);
|
||||||
const storedFp = localStorage.getItem(STORAGE_KEY_FP);
|
const storedFp = localStorage.getItem(STORAGE_KEY_FP);
|
||||||
@@ -808,12 +932,12 @@
|
|||||||
|
|
||||||
// User previously logged in as anonymous with this key
|
// User previously logged in as anonymous with this key
|
||||||
// If backend session is already active, avoid redundant session churn
|
// 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.isSessionReady = true;
|
||||||
this.updateNavUI(true);
|
this.updateNavUI(true);
|
||||||
} else {
|
} else {
|
||||||
await this.ensureSession();
|
// Unauthenticated guest state: do NOT auto-login as anonymous on page load!
|
||||||
this.updateNavUI(true);
|
this.updateNavUI(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ANON_SSH] Failed to restore stored key:', err);
|
console.warn('[ANON_SSH] Failed to restore stored key:', err);
|
||||||
@@ -822,7 +946,7 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clean guest state! Do NOT auto-generate or establish session!
|
// 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
|
// Stale backend anon session cookie without corresponding local key: clear cookie
|
||||||
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -853,7 +977,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
|
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();
|
e.preventDefault();
|
||||||
this.logoutAnonymous();
|
this.logoutAnonymous();
|
||||||
return;
|
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 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 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 = '';
|
if (isAnonGuest) bannerStyle = '';
|
||||||
|
|
||||||
const avatarHtml = isAnonGuest
|
const avatarHtml = isAnonGuest
|
||||||
@@ -2366,7 +2366,8 @@ class CommentSystem {
|
|||||||
const counter = (maxLen !== null && maxLen !== undefined)
|
const counter = (maxLen !== null && maxLen !== undefined)
|
||||||
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
|
? `<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 multiFile = session.fileupload_comments_multifile;
|
||||||
const attachBtn = fileUploadEnabled
|
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;">`
|
? `<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;
|
return;
|
||||||
}
|
}
|
||||||
if (submitBtn.classList.contains('loading') || submitBtn.disabled) 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) ───────────────────────
|
// ── Upload all staged files now (at submit time) ───────────────────────
|
||||||
const fileIds = [];
|
const fileIds = [];
|
||||||
|
|||||||
+674
-94
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 autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10));
|
||||||
let leftHandEnabled = prefs.leftHand === true;
|
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: [] };
|
let pending = { ...applied, tags: [] };
|
||||||
|
|
||||||
// Volume / mute
|
// Volume / mute
|
||||||
@@ -192,7 +196,8 @@
|
|||||||
if (hid && !cache.items.some(item => String(item.id) === hid)) return false;
|
if (hid && !cache.items.some(item => String(item.id) === hid)) return false;
|
||||||
|
|
||||||
if (cache.filters) {
|
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] : [];
|
applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : [];
|
||||||
pending = { ...applied, tags: [...applied.tags] };
|
pending = { ...applied, tags: [...applied.tags] };
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1146,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFav(slide) {
|
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;
|
const id = slide.dataset.localId || slide.dataset.id;
|
||||||
// External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted
|
// 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; }
|
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 (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1));
|
||||||
if (nowFaved) flashFav(slide);
|
if (nowFaved) flashFav(slide);
|
||||||
}
|
}
|
||||||
if (!window.scrollerLoggedIn && (!window.f0ckSession || !window.f0ckSession.user)) {
|
|
||||||
showShareToast('Login to favorite posts');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch('/api/v2/togglefav', {
|
const resp = await fetch('/api/v2/togglefav', {
|
||||||
@@ -1171,7 +1188,24 @@
|
|||||||
},
|
},
|
||||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
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)
|
// Sync count to server truth (handles race conditions)
|
||||||
if (data.success && favBtn && data.favs) {
|
if (data.success && favBtn && data.favs) {
|
||||||
const countEl = favBtn.querySelector('.scroll-btn-count');
|
const countEl = favBtn.querySelector('.scroll-btn-count');
|
||||||
@@ -1183,6 +1217,8 @@
|
|||||||
favBtn.classList.toggle('faved', wasFaved);
|
favBtn.classList.toggle('faved', wasFaved);
|
||||||
const icon = favBtn.querySelector('i');
|
const icon = favBtn.querySelector('i');
|
||||||
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
|
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';
|
const meta = document.createElement('div'); meta.className = 'scroll-meta';
|
||||||
// esc() the color value: a raw '"' in username_color would break out of the
|
// esc() the color value: a raw '"' in username_color would break out of the
|
||||||
// style attribute and allow arbitrary HTML injection (XSS).
|
// 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 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 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>` : '';
|
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) {
|
function renderCommentEl(c, canReply) {
|
||||||
const el = document.createElement('div'); el.className = 'comment-item';
|
const el = document.createElement('div'); el.className = 'comment-item';
|
||||||
el.dataset.commentId = c.id || '';
|
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 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 nc = (!isAnonGuest && c.username_color) ? `color:${esc(c.username_color)}` : '';
|
||||||
const _i = window.f0ckI18n || {};
|
const _i = window.f0ckI18n || {};
|
||||||
@@ -3003,6 +3039,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function syncPanelUI() {
|
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('#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('#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));
|
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) {
|
function makePillListener(groupId, key, transform) {
|
||||||
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => {
|
const el = document.getElementById(groupId);
|
||||||
pill.addEventListener('click', () => {
|
if (!el) return;
|
||||||
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
|
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];
|
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() {
|
function updateFilterSummary() {
|
||||||
const is4chan = !!applied.externalUrl;
|
const is4chan = !!applied.externalUrl;
|
||||||
@@ -3500,11 +3546,26 @@
|
|||||||
|
|
||||||
const initScrollerSSE = () => {
|
const initScrollerSSE = () => {
|
||||||
if (sseEs) sseEs.close();
|
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.onopen = () => { sseRetryCount = 0; };
|
||||||
sseEs.onmessage = (e) => {
|
sseEs.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
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') {
|
if (data.type === 'notify') {
|
||||||
pollNotifCount(); // instant re-fetch on SSE push
|
pollNotifCount(); // instant re-fetch on SSE push
|
||||||
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
|
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;"`
|
? `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 effectiveBannerStyle = isAnonGuest ? '' : bannerStyle;
|
||||||
const authorAvatarHtml = isAnonGuest
|
const authorAvatarHtml = isAnonGuest
|
||||||
? `<span class="sidebar-avatar-link"><img src="/a/default.png" class="sidebar-avatar" loading="eager" /></span>`
|
? `<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;
|
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||||
|
|
||||||
const getCurrentMimeFilter = () => {
|
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 urlParams = new URLSearchParams(window.location.search);
|
||||||
const qMime = urlParams.get('mime');
|
let qMime = urlParams.get('mime');
|
||||||
if (qMime !== null) return qMime.trim();
|
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='));
|
const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime='));
|
||||||
if (cookieMime) {
|
if (cookieMime) {
|
||||||
const val = cookieMime.split('=')[1];
|
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 '';
|
return '';
|
||||||
};
|
};
|
||||||
@@ -1176,7 +1194,7 @@
|
|||||||
displayTitle = `${videoKey}`;
|
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 authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username);
|
||||||
const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : '';
|
const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : '';
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -95,7 +95,7 @@
|
|||||||
if (canManage) {
|
if (canManage) {
|
||||||
span.classList.add('can-cycle');
|
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);
|
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +196,13 @@
|
|||||||
|
|
||||||
const toggleFavEvent = async (e) => {
|
const toggleFavEvent = async (e) => {
|
||||||
// e is the click event or undefined
|
// 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();
|
const ctx = getContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { postid } = ctx;
|
const { postid } = ctx;
|
||||||
@@ -204,10 +211,11 @@
|
|||||||
const favoBtn = document.querySelector("#a_favo");
|
const favoBtn = document.querySelector("#a_favo");
|
||||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await post('/api/v2/togglefav', {
|
const res = await post('/api/v2/togglefav', {
|
||||||
postid: postid
|
postid: postid
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res && res.success) {
|
||||||
if (window.invalidateItemCache) {
|
if (window.invalidateItemCache) {
|
||||||
window.invalidateItemCache(postid);
|
window.invalidateItemCache(postid);
|
||||||
}
|
}
|
||||||
@@ -222,7 +230,7 @@
|
|||||||
// span#favs
|
// span#favs
|
||||||
const favcontainer = document.querySelector('#favs');
|
const favcontainer = document.querySelector('#favs');
|
||||||
favcontainer.innerHTML = "";
|
favcontainer.innerHTML = "";
|
||||||
if (res.favs.length > 0) {
|
if (res.favs && res.favs.length > 0) {
|
||||||
res.favs.forEach(f => {
|
res.favs.forEach(f => {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = `/user/${f.user}`;
|
a.href = `/user/${f.user}`;
|
||||||
@@ -247,7 +255,15 @@
|
|||||||
if (navigator.vibrate) navigator.vibrate(50);
|
if (navigator.vibrate) navigator.vibrate(50);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// lul
|
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
|
||||||
|
if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage(errMsg, 3000, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage('Failed to update favorite.', 3000, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cfg from "./inc/config.mjs";
|
|||||||
import queue from "./inc/queue.mjs";
|
import queue from "./inc/queue.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { collectBody } from "./inc/multipart.mjs";
|
import { collectBody } from "./inc/multipart.mjs";
|
||||||
|
import { canAnonDo, isAnonSession } from "./inc/settings.mjs";
|
||||||
|
|
||||||
// Helper for JSON response
|
// Helper for JSON response
|
||||||
const sendJson = (res, data, code = 200) => {
|
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);
|
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
|
// Check if comment file upload is enabled
|
||||||
if (!cfg.websrv.allow_fileupload_comments) {
|
if (!cfg.websrv.allow_fileupload_comments) {
|
||||||
return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403);
|
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 cfg from "./config.mjs";
|
||||||
import { createI18n } from "./i18n.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;
|
return false;
|
||||||
};
|
};
|
||||||
async getTags(itemid, session = null) {
|
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`
|
const tags = await db`
|
||||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||||
from "tags_assign"
|
from "tags_assign"
|
||||||
@@ -296,6 +297,8 @@ export default new class {
|
|||||||
`;
|
`;
|
||||||
let hasRating = false;
|
let hasRating = false;
|
||||||
const cleanTags = [];
|
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++) {
|
for (let t = 0; t < tags.length; t++) {
|
||||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
|
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
|
||||||
if (isRating) {
|
if (isRating) {
|
||||||
@@ -303,6 +306,8 @@ export default new class {
|
|||||||
hasRating = true;
|
hasRating = true;
|
||||||
}
|
}
|
||||||
tags[t].badge = this.getBadge(tags[t]);
|
tags[t].badge = this.getBadge(tags[t]);
|
||||||
|
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
|
||||||
|
tags[t].can_exclude = canExclude;
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
delete tags[t].user;
|
delete tags[t].user;
|
||||||
delete tags[t].display_name;
|
delete tags[t].display_name;
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"start_export": "Export generieren (ZIP)"
|
"start_export": "Export generieren (ZIP)"
|
||||||
},
|
},
|
||||||
"filter": {
|
"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",
|
"tag_placeholder": "Tag ausschließen",
|
||||||
"random_mode": "RAND",
|
"random_mode": "RAND",
|
||||||
"min_xd_score": "Min. xD-Score",
|
"min_xd_score": "Min. xD-Score",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"start_export": "Generate Export (ZIP)"
|
"start_export": "Generate Export (ZIP)"
|
||||||
},
|
},
|
||||||
"filter": {
|
"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",
|
"tag_placeholder": "Tag to exclude",
|
||||||
"random_mode": "RAND",
|
"random_mode": "RAND",
|
||||||
"min_xd_score": "Min xD Score",
|
"min_xd_score": "Min xD Score",
|
||||||
|
|||||||
@@ -268,6 +268,12 @@
|
|||||||
"start_export": "Export genereren (ZIP)"
|
"start_export": "Export genereren (ZIP)"
|
||||||
},
|
},
|
||||||
"filter": {
|
"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",
|
"tag_placeholder": "Tag om uit te sluiten",
|
||||||
"random_mode": "WILLEKEURIG",
|
"random_mode": "WILLEKEURIG",
|
||||||
"min_xd_score": "Min xD-score",
|
"min_xd_score": "Min xD-score",
|
||||||
|
|||||||
@@ -266,6 +266,12 @@
|
|||||||
"start_export": "Paket schnüren"
|
"start_export": "Paket schnüren"
|
||||||
},
|
},
|
||||||
"filter": {
|
"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",
|
"tag_placeholder": "Auszuschließendes Etikett",
|
||||||
"random_mode": "ZUFA",
|
"random_mode": "ZUFA",
|
||||||
"min_xd_score": "Min. xD-Punktestand",
|
"min_xd_score": "Min. xD-Punktestand",
|
||||||
|
|||||||
+133
-52
@@ -1,7 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import cfg from "../config.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 { updateHallsCache } from "../halls_cache.mjs";
|
||||||
import queue from "../queue.mjs";
|
import queue from "../queue.mjs";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -15,7 +15,7 @@ const getGlobalfilter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const computeBaseMode = (mode, ratings, session) => {
|
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;
|
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||||
|
|
||||||
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
|
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
|
||||||
@@ -28,6 +28,32 @@ const computeBaseMode = (mode, ratings, session) => {
|
|||||||
if (safeRatingsArr.length === 0) {
|
if (safeRatingsArr.length === 0) {
|
||||||
return "1 = 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;
|
let baseMode;
|
||||||
@@ -67,6 +93,21 @@ const computeBaseMode = (mode, ratings, session) => {
|
|||||||
} else if (effMode === 4) {
|
} else if (effMode === 4) {
|
||||||
baseMode = "1 = 0";
|
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;
|
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)
|
// 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 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 ─────────────────────────────────────────────────────────────
|
// ── Count cache ─────────────────────────────────────────────────────────────
|
||||||
// The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan).
|
// The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan).
|
||||||
// Cache it per unique filter combination for 90 seconds so that navigating
|
// 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];
|
if (uhData.length) userHallObj = uhData[0];
|
||||||
}
|
}
|
||||||
const mime = rawMime ?? null;
|
const mime = rawMime ?? null;
|
||||||
|
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
// 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 excludedTags = session && exclude ? (exclude || []) : [];
|
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||||
const newerThan = newer ? parseInt(newer) : null;
|
const newerThan = newer ? parseInt(newer) : null;
|
||||||
@@ -853,14 +915,7 @@ const f0cklib = {
|
|||||||
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
|
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
|
||||||
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${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 { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
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 excludedTags = exclude || [];
|
const excludedTags = exclude || [];
|
||||||
|
|
||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
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)
|
// Support multiple MIME types (comma separated)
|
||||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
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 excludedTags = session && exclude ? (exclude || []) : [];
|
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||||
|
|
||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
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`AND items.id != ALL(${excludeItemIds}::int[])`
|
||||||
: db``;
|
: db``;
|
||||||
|
|
||||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
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``;
|
|
||||||
|
|
||||||
let rows;
|
let rows;
|
||||||
if (mimeParts.length > 0) {
|
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 () => {
|
decayUserAffinities: async () => {
|
||||||
try {
|
try {
|
||||||
await db`
|
await db`
|
||||||
@@ -2455,14 +2549,7 @@ const f0cklib = {
|
|||||||
? db`AND items.id != ALL(${excludeItemIds}::int[])`
|
? db`AND items.id != ALL(${excludeItemIds}::int[])`
|
||||||
: db``;
|
: db``;
|
||||||
|
|
||||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
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``;
|
|
||||||
|
|
||||||
let personalizedItems = [];
|
let personalizedItems = [];
|
||||||
if (personalizedTarget > 0 && targetTagIds.length > 0) {
|
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 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))`
|
||||||
: db`AND COALESCE(items.visibility, 0) = 0`);
|
: db`AND COALESCE(items.visibility, 0) = 0`);
|
||||||
|
|
||||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
|
||||||
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 terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
const isStrict = !!strict || (tag && tag.includes(','));
|
const isStrict = !!strict || (tag && tag.includes(','));
|
||||||
@@ -2835,6 +2915,7 @@ const f0cklib = {
|
|||||||
processEmbeds,
|
processEmbeds,
|
||||||
computeXdScore,
|
computeXdScore,
|
||||||
xdScoreMeta,
|
xdScoreMeta,
|
||||||
|
resolveMimeSQL,
|
||||||
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
||||||
clearCountCache: () => countCache.clear()
|
clearCountCache: () => countCache.clear()
|
||||||
};
|
};
|
||||||
|
|||||||
+164
-39
@@ -77,7 +77,15 @@ export default (router, tpl) => {
|
|||||||
} else {
|
} else {
|
||||||
const reason = user[0].ban_reason || 'none';
|
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';
|
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 });
|
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 });
|
return res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.json({ success: false, msg: err.message });
|
return res.json({ success: false, msg: err.message });
|
||||||
@@ -682,6 +698,16 @@ export default (router, tpl) => {
|
|||||||
expires,
|
expires,
|
||||||
banIps: true
|
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
|
// Log it in audit
|
||||||
@@ -1109,8 +1135,98 @@ export default (router, tpl) => {
|
|||||||
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim();
|
||||||
|
const rawRole = (req.url.qs?.role || '').toLowerCase().trim();
|
||||||
|
|
||||||
const users = await db`
|
const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' ||
|
||||||
|
req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' ||
|
||||||
|
req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' ||
|
||||||
|
rawStatus === 'legacy';
|
||||||
|
|
||||||
|
const status = onlyLegacy ? '' : rawStatus;
|
||||||
|
const role = onlyLegacy ? '' : rawRole;
|
||||||
|
|
||||||
|
let users;
|
||||||
|
let total;
|
||||||
|
|
||||||
|
if (onlyLegacy) {
|
||||||
|
users = await db`
|
||||||
|
WITH ghost_users AS (
|
||||||
|
SELECT
|
||||||
|
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||||
|
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
||||||
|
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||||
|
FROM items i
|
||||||
|
WHERE i.username IS NOT NULL AND i.username != ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||||
|
${q ? (exactMatch
|
||||||
|
? db`AND lower(i.username) = lower(${q})`
|
||||||
|
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||||
|
) : db``}
|
||||||
|
GROUP BY i.username
|
||||||
|
),
|
||||||
|
paginated_users AS (
|
||||||
|
SELECT * FROM ghost_users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
pu.*,
|
||||||
|
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
|
||||||
|
COALESCE(ic.upload_count, 0) as upload_count,
|
||||||
|
0::bigint as comment_count,
|
||||||
|
0::bigint as failed_attempts
|
||||||
|
FROM paginated_users pu
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) as upload_count
|
||||||
|
FROM items
|
||||||
|
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
|
||||||
|
) ic ON true
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalCountGhost = await db`
|
||||||
|
SELECT COUNT(DISTINCT i.username) as c
|
||||||
|
FROM items i
|
||||||
|
WHERE i.username IS NOT NULL AND i.username != ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||||
|
${q ? (exactMatch
|
||||||
|
? db`AND lower(i.username) = lower(${q})`
|
||||||
|
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||||
|
) : db``}
|
||||||
|
`;
|
||||||
|
total = parseInt(totalCountGhost[0].c);
|
||||||
|
} else {
|
||||||
|
let qCond = null;
|
||||||
|
if (q) {
|
||||||
|
if (exactMatch) {
|
||||||
|
qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`;
|
||||||
|
} else {
|
||||||
|
const pattern = '%' + lib.escapeLike(q) + '%';
|
||||||
|
qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusCond = null;
|
||||||
|
if (status === 'banned') {
|
||||||
|
statusCond = db`u.banned = true`;
|
||||||
|
} else if (status === 'active') {
|
||||||
|
statusCond = db`(u.activated = true AND u.banned = false)`;
|
||||||
|
} else if (status === 'pending') {
|
||||||
|
statusCond = db`(u.activated = false AND u.banned = false)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roleCond = null;
|
||||||
|
if (role === 'staff' || status === 'staff') {
|
||||||
|
roleCond = db`(u.admin = true OR u.is_moderator = true)`;
|
||||||
|
} else if (role === 'admin') {
|
||||||
|
roleCond = db`u.admin = true`;
|
||||||
|
} else if (role === 'mod') {
|
||||||
|
roleCond = db`(u.is_moderator = true AND u.admin = false)`;
|
||||||
|
} else if (role === 'user') {
|
||||||
|
roleCond = db`(u.admin = false AND u.is_moderator = false)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
users = await db`
|
||||||
WITH filtered_users AS (
|
WITH filtered_users AS (
|
||||||
SELECT
|
SELECT
|
||||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
||||||
@@ -1118,31 +1234,13 @@ export default (router, tpl) => {
|
|||||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||||
FROM "user" u
|
FROM "user" u
|
||||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
${q ? (exactMatch
|
WHERE true
|
||||||
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
${qCond ? db`AND ${qCond}` : db``}
|
||||||
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
${statusCond ? db`AND ${statusCond}` : db``}
|
||||||
) : db``}
|
${roleCond ? db`AND ${roleCond}` : db``}
|
||||||
),
|
|
||||||
ghost_users AS (
|
|
||||||
SELECT
|
|
||||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
|
||||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
|
||||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
|
||||||
FROM items i
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
|
||||||
${q ? (exactMatch
|
|
||||||
? db`AND lower(i.username) = lower(${q})`
|
|
||||||
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
|
||||||
) : db``}
|
|
||||||
GROUP BY i.username
|
|
||||||
),
|
|
||||||
all_users AS (
|
|
||||||
SELECT * FROM filtered_users
|
|
||||||
UNION ALL
|
|
||||||
SELECT * FROM ghost_users
|
|
||||||
),
|
),
|
||||||
paginated_users AS (
|
paginated_users AS (
|
||||||
SELECT * FROM all_users
|
SELECT * FROM filtered_users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
)
|
)
|
||||||
@@ -1175,21 +1273,41 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const totalCountActual = await db`
|
const totalCountActual = await db`
|
||||||
SELECT COUNT(*) as c FROM "user" u
|
SELECT COUNT(*) as c FROM "user" u
|
||||||
${q ? (exactMatch
|
WHERE true
|
||||||
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
${qCond ? db`AND ${qCond}` : db``}
|
||||||
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
${statusCond ? db`AND ${statusCond}` : db``}
|
||||||
) : db``}
|
${roleCond ? db`AND ${roleCond}` : db``}
|
||||||
`;
|
`;
|
||||||
const totalCountGhost = await db`
|
total = parseInt(totalCountActual[0].c);
|
||||||
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)
|
let totalLabel = 'registered members';
|
||||||
${q ? (exactMatch
|
let emptyMsg = 'No users matched your search.';
|
||||||
? db`AND lower(i.username) = lower(${q})`
|
if (onlyLegacy) {
|
||||||
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
totalLabel = 'legacy accounts';
|
||||||
) : db``}
|
emptyMsg = 'No legacy users matched your search.';
|
||||||
`;
|
} else if (status === 'banned') {
|
||||||
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
|
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 = {
|
const data = {
|
||||||
session: req.session,
|
session: req.session,
|
||||||
@@ -1198,6 +1316,11 @@ export default (router, tpl) => {
|
|||||||
page,
|
page,
|
||||||
total,
|
total,
|
||||||
hasMore: users.length === limit,
|
hasMore: users.length === limit,
|
||||||
|
onlyLegacy,
|
||||||
|
status,
|
||||||
|
role,
|
||||||
|
totalLabel,
|
||||||
|
emptyMsg,
|
||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
log_user_ips: getLogUserIps(),
|
log_user_ips: getLogUserIps(),
|
||||||
tmp: null
|
tmp: null
|
||||||
@@ -1205,6 +1328,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||||
res.setHeader('X-Total-Count', total.toString());
|
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());
|
res.setHeader('X-Has-More', (users.length === limit).toString());
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("admin/users_list", data, req)
|
body: tpl.render("admin/users_list", data, req)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import f0cklib from "../routeinc/f0cklib.mjs";
|
|||||||
import url from "url";
|
import url from "url";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { createI18n } from "../i18n.mjs";
|
import { createI18n } from "../i18n.mjs";
|
||||||
|
import { isAnonymizeSession } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
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) {
|
if (data.item) {
|
||||||
const session = data.session;
|
const session = data.session;
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
if (item.src) item.src = null;
|
if (item.src) item.src = null;
|
||||||
item.username = 'anonymous';
|
item.username = 'anonymous';
|
||||||
item.author_banner_file = null;
|
item.author_banner_file = null;
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ export default router => {
|
|||||||
* POST /api/v2/anon/session
|
* POST /api/v2/anon/session
|
||||||
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous 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) => {
|
group.post(/\/session$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!getEnableAnonymousAccess()) {
|
if (!getEnableAnonymousAccess()) {
|
||||||
@@ -21,6 +39,7 @@ export default router => {
|
|||||||
const clientIp = security.getRealIP(req);
|
const clientIp = security.getRealIP(req);
|
||||||
const ipBan = await security.isIpBanned(clientIp);
|
const ipBan = await security.isIpBanned(clientIp);
|
||||||
if (ipBan) {
|
if (ipBan) {
|
||||||
|
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
banned: true,
|
banned: true,
|
||||||
@@ -64,15 +83,19 @@ export default router => {
|
|||||||
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
|
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
|
||||||
|
|
||||||
if (activeTombstoneBan) {
|
if (activeTombstoneBan) {
|
||||||
|
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||||
|
if (!alreadyFpBanned) {
|
||||||
await security.banAnonymousUser({
|
await security.banAnonymousUser({
|
||||||
fingerprint: parsed.fingerprint,
|
fingerprint: parsed.fingerprint,
|
||||||
hwFingerprint: hwFingerprint || tombstoneHw,
|
hwFingerprint: hwFingerprint || tombstoneHw,
|
||||||
bannedBy: activeTombstoneBan.banned_by,
|
bannedBy: activeTombstoneBan.banned_by,
|
||||||
reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`,
|
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
|
||||||
expires: activeTombstoneBan.expires,
|
expires: activeTombstoneBan.expires,
|
||||||
banIps: true,
|
banIps: true,
|
||||||
banHardware: true
|
banHardware: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
banned: true,
|
banned: true,
|
||||||
@@ -90,15 +113,19 @@ export default router => {
|
|||||||
if (hwFingerprint) {
|
if (hwFingerprint) {
|
||||||
const hwBan = await security.isHardwareBanned(hwFingerprint);
|
const hwBan = await security.isHardwareBanned(hwFingerprint);
|
||||||
if (hwBan) {
|
if (hwBan) {
|
||||||
|
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||||
|
if (!alreadyFpBanned) {
|
||||||
await security.banAnonymousUser({
|
await security.banAnonymousUser({
|
||||||
fingerprint: parsed.fingerprint,
|
fingerprint: parsed.fingerprint,
|
||||||
hwFingerprint,
|
hwFingerprint,
|
||||||
bannedBy: hwBan.banned_by,
|
bannedBy: hwBan.banned_by,
|
||||||
reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`,
|
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
|
||||||
expires: hwBan.expires,
|
expires: hwBan.expires,
|
||||||
banIps: true,
|
banIps: true,
|
||||||
banHardware: true
|
banHardware: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
banned: true,
|
banned: true,
|
||||||
@@ -116,16 +143,20 @@ export default router => {
|
|||||||
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
|
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
|
||||||
if (fpBan) {
|
if (fpBan) {
|
||||||
if (hwFingerprint) {
|
if (hwFingerprint) {
|
||||||
|
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
|
||||||
|
if (!alreadyHwBanned) {
|
||||||
await security.banAnonymousUser({
|
await security.banAnonymousUser({
|
||||||
fingerprint: parsed.fingerprint,
|
fingerprint: parsed.fingerprint,
|
||||||
hwFingerprint,
|
hwFingerprint,
|
||||||
bannedBy: fpBan.banned_by,
|
bannedBy: fpBan.banned_by,
|
||||||
reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`,
|
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
|
||||||
expires: fpBan.expires,
|
expires: fpBan.expires,
|
||||||
banIps: true,
|
banIps: true,
|
||||||
banHardware: true
|
banHardware: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
banned: true,
|
banned: true,
|
||||||
@@ -144,6 +175,8 @@ export default router => {
|
|||||||
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
|
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
|
||||||
if (userRows.length > 0 && userRows[0].banned) {
|
if (userRows.length > 0 && userRows[0].banned) {
|
||||||
const u = userRows[0];
|
const u = userRows[0];
|
||||||
|
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||||
|
if (!alreadyFpBanned) {
|
||||||
await security.banAnonymousUser({
|
await security.banAnonymousUser({
|
||||||
userId,
|
userId,
|
||||||
fingerprint: parsed.fingerprint,
|
fingerprint: parsed.fingerprint,
|
||||||
@@ -153,6 +186,8 @@ export default router => {
|
|||||||
banIps: true,
|
banIps: true,
|
||||||
banHardware: true
|
banHardware: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
banned: true,
|
banned: true,
|
||||||
|
|||||||
+100
-13
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.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 queue from '../../queue.mjs';
|
||||||
import search from '../../routeinc/search.mjs';
|
import search from '../../routeinc/search.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -583,7 +583,8 @@ export default router => {
|
|||||||
const userHall = req.url.qs.userHall || null;
|
const userHall = req.url.qs.userHall || null;
|
||||||
const userHallOwner= req.url.qs.userHallOwner|| null;
|
const userHallOwner= req.url.qs.userHallOwner|| null;
|
||||||
const user = req.url.qs.user || 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 isFav = req.url.qs.fav === 'true';
|
||||||
const isStrict = req.url.qs.strict === '1';
|
const isStrict = req.url.qs.strict === '1';
|
||||||
const mode = req.mode ?? 0;
|
const mode = req.mode ?? 0;
|
||||||
@@ -598,7 +599,7 @@ export default router => {
|
|||||||
mode,
|
mode,
|
||||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||||
strict: isStrict,
|
strict: isStrict,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
is_admin: req.session?.admin
|
is_admin: req.session?.admin
|
||||||
@@ -616,7 +617,10 @@ export default router => {
|
|||||||
const user = req.url.qs.user || pathUser || null;
|
const user = req.url.qs.user || pathUser || null;
|
||||||
|
|
||||||
const pathMime = allowedMimes.includes(pathParts[4]) ? pathParts[4] : "";
|
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 tag = req.url.qs.tag || null;
|
||||||
const hall = req.url.qs.hall || null;
|
const hall = req.url.qs.hall || null;
|
||||||
@@ -639,7 +643,7 @@ export default router => {
|
|||||||
mode,
|
mode,
|
||||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||||
strict: isStrict,
|
strict: isStrict,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
is_admin: req.session?.admin
|
is_admin: req.session?.admin
|
||||||
@@ -667,7 +671,7 @@ export default router => {
|
|||||||
mode,
|
mode,
|
||||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||||
strict: isStrict,
|
strict: isStrict,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
is_admin: req.session?.admin
|
is_admin: req.session?.admin
|
||||||
@@ -743,7 +747,7 @@ export default router => {
|
|||||||
limit,
|
limit,
|
||||||
mode,
|
mode,
|
||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
is_admin: req.session?.admin,
|
is_admin: req.session?.admin,
|
||||||
@@ -755,6 +759,7 @@ export default router => {
|
|||||||
prefer_personalized: preferPersonalized
|
prefer_personalized: preferPersonalized
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAnonUser = isAnonymizeSession(req.session);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
items: items.map(item => ({
|
items: items.map(item => ({
|
||||||
@@ -764,9 +769,9 @@ export default router => {
|
|||||||
dest: item.dest,
|
dest: item.dest,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
stamp: item.stamp,
|
stamp: item.stamp,
|
||||||
username: item.username,
|
username: isAnonUser ? 'anonymous' : item.username,
|
||||||
display_name: item.display_name,
|
display_name: isAnonUser ? null : item.display_name,
|
||||||
username_color: item.username_color,
|
username_color: isAnonUser ? null : item.username_color,
|
||||||
has_coverart: item.has_coverart,
|
has_coverart: item.has_coverart,
|
||||||
rating_class: item.rating_class,
|
rating_class: item.rating_class,
|
||||||
xd_score: item.xd_score,
|
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) => {
|
group.get(/\/tag-feed$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null;
|
const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null;
|
||||||
@@ -866,7 +902,7 @@ export default router => {
|
|||||||
focus_id: focusId,
|
focus_id: focusId,
|
||||||
mode,
|
mode,
|
||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
is_admin: req.session?.admin,
|
is_admin: req.session?.admin,
|
||||||
@@ -875,6 +911,17 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const effectiveOffset = result.offset ?? offset;
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -883,7 +930,7 @@ export default router => {
|
|||||||
total: result.total,
|
total: result.total,
|
||||||
offset: effectiveOffset,
|
offset: effectiveOffset,
|
||||||
limit: result.limit ?? limit,
|
limit: result.limit ?? limit,
|
||||||
items: result.items,
|
items: outItems,
|
||||||
hasMore: (effectiveOffset + result.items.length) < result.total
|
hasMore: (effectiveOffset + result.items.length) < result.total
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -899,6 +946,14 @@ export default router => {
|
|||||||
|
|
||||||
group.get(/\/orakel\/user$/, async (req, res) => {
|
group.get(/\/orakel\/user$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (isAnonymizeSession(req.session)) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
username: 'anonymous',
|
||||||
|
display_name: 'Anonymous',
|
||||||
|
id: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
const now = ~~(Date.now() / 1000);
|
const now = ~~(Date.now() / 1000);
|
||||||
const sevenDaysAgo = now - 604800; // 7 days in seconds
|
const sevenDaysAgo = now - 604800; // 7 days in seconds
|
||||||
|
|
||||||
@@ -1088,6 +1143,13 @@ export default router => {
|
|||||||
prev: prev[0]?.id ?? null
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
rows
|
rows
|
||||||
@@ -1095,6 +1157,9 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.get(/\/user\/(?<user>[^\/]+)(\/(?<eps>\d+))?$/, async (req, res) => {
|
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 user = req.params.user;
|
||||||
const eps = +req.params.eps || 50;
|
const eps = +req.params.eps || 50;
|
||||||
|
|
||||||
@@ -1147,6 +1212,9 @@ export default router => {
|
|||||||
|
|
||||||
|
|
||||||
group.get(/\/users\/suggest$/, async (req, res) => {
|
group.get(/\/users\/suggest$/, async (req, res) => {
|
||||||
|
if (isAnonymizeSession(req.session)) {
|
||||||
|
return res.json({ success: true, suggestions: [] });
|
||||||
|
}
|
||||||
const searchString = req.url.qs.q;
|
const searchString = req.url.qs.q;
|
||||||
if (!searchString || searchString.length < 1) {
|
if (!searchString || searchString.length < 1) {
|
||||||
return res.json({ success: false, suggestions: [] });
|
return res.json({ success: false, suggestions: [] });
|
||||||
@@ -1357,6 +1425,9 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
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;
|
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
|
||||||
if (rawPostid === undefined || rawPostid === null) {
|
if (rawPostid === undefined || rawPostid === null) {
|
||||||
return res.json({ success: false, msg: 'Missing postid' }, 400);
|
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) => {
|
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 {
|
try {
|
||||||
const rawIds = req.post?.ids ?? req.body?.ids;
|
const rawIds = req.post?.ids ?? req.body?.ids;
|
||||||
let 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;
|
const itemid = +req.params.id;
|
||||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
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 fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
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
|
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||||
// These routes remain for other settings API endpoints
|
// These routes remain for other settings API endpoints
|
||||||
@@ -74,6 +75,9 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
|
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`
|
const tags = await db`
|
||||||
select t.id, t.tag, t.normalized
|
select t.id, t.tag, t.normalized
|
||||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
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) => {
|
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;
|
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);
|
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
|
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) => {
|
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
|
||||||
const tagname = decodeURIComponent(req.params.tag);
|
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
|
||||||
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
|
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);
|
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
|
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)
|
// 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 fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { logAnonActivity } from "../../anon_auth.mjs";
|
import { logAnonActivity } from "../../anon_auth.mjs";
|
||||||
|
import { canAnonDo, isAnonSession } from "../../settings.mjs";
|
||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
|
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
|
||||||
@@ -26,6 +27,9 @@ export default router => {
|
|||||||
|
|
||||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||||
// assign and/or create tag
|
// 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;
|
const rawTagname = req.post?.tagname || req.body?.tagname;
|
||||||
if (!req.params.postid || !rawTagname) {
|
if (!req.params.postid || !rawTagname) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -106,6 +110,9 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
|
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' });
|
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
|
||||||
|
|
||||||
const postid = +req.params.postid;
|
const postid = +req.params.postid;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { spawn as _spawnRaw } from 'child_process';
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.mjs';
|
||||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -232,9 +232,21 @@ const collectBody = (req) => {
|
|||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2/, group => {
|
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 ──────────────────────────────
|
// ── 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 jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
|
||||||
const state = progressMap.get(jobId);
|
const state = progressMap.get(jobId);
|
||||||
res.setHeader?.('Cache-Control', 'no-store');
|
res.setHeader?.('Cache-Control', 'no-store');
|
||||||
@@ -307,7 +319,7 @@ export default router => {
|
|||||||
return [...new Set(tags)];
|
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;
|
const url = req.url.qs?.url;
|
||||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
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 {
|
try {
|
||||||
if (!cfg.websrv.web_url_upload) {
|
if (!cfg.websrv.web_url_upload) {
|
||||||
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isBanned) {
|
if (req.cookies && req.cookies.f0ck_banned) {
|
||||||
return res.writeHead(302, {
|
try {
|
||||||
"Location": "/"
|
const bData = JSON.parse(decodeURIComponent(req.cookies.f0ck_banned));
|
||||||
}).end();
|
if (bData && (bData.reason || bData.banned)) {
|
||||||
|
isBanned = true;
|
||||||
|
reason = bData.reason || reason;
|
||||||
|
expires = bData.expires || expires;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.reply({
|
res.reply({
|
||||||
@@ -42,8 +47,11 @@ export default (router, tpl) => {
|
|||||||
session: req.session,
|
session: req.session,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
|
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
|
||||||
ban_video: cfg.websrv.ban_video,
|
isBanned: isBanned,
|
||||||
hideNavbar: true
|
clientIp: clientIp,
|
||||||
|
page_meta: {
|
||||||
|
title: isBanned ? 'Banned' : 'Ban Status'
|
||||||
|
}
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { promises as fs } from "fs";
|
|||||||
import { applyWordFilter } from "../wordfilter.mjs";
|
import { applyWordFilter } from "../wordfilter.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
|
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) => {
|
export default (router, tpl) => {
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transform for frontend if needed, or send as is
|
// 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
|
const outComments = anonymize
|
||||||
? comments.map(c => ({
|
? comments.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
@@ -135,8 +135,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Browse User Comments
|
// Browse User Comments
|
||||||
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
return req.session ? res.redirect('/') : res.redirect('/login');
|
||||||
}
|
}
|
||||||
const user = decodeURIComponent(req.params.user);
|
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 (!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)
|
// Rate limit regular users (admins and mods are exempt)
|
||||||
if (!req.session.admin && !req.session.is_moderator) {
|
if (!req.session.admin && !req.session.is_moderator) {
|
||||||
if (isCommentRateLimited(req.session.id)) {
|
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 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
|
||||||
: db`AND COALESCE(i.visibility, 0) = 0`);
|
: 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`
|
const comments = await db`
|
||||||
SELECT
|
SELECT
|
||||||
c.*,
|
c.*,
|
||||||
@@ -1117,6 +1123,7 @@ export default (router, tpl) => {
|
|||||||
WHERE c.is_deleted = false
|
WHERE c.is_deleted = false
|
||||||
AND i.active = true
|
AND i.active = true
|
||||||
AND i.is_deleted = false
|
AND i.is_deleted = false
|
||||||
|
${activityMimeSQL}
|
||||||
${visibilityFilter}
|
${visibilityFilter}
|
||||||
AND ${db.unsafe(modequery)}
|
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``}
|
${!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 => {
|
const processedComments = comments.map(c => {
|
||||||
let ratingLabel = '?';
|
let ratingLabel = '?';
|
||||||
let ratingClass = 'untagged';
|
let ratingClass = 'untagged';
|
||||||
@@ -1431,6 +1438,9 @@ export default (router, tpl) => {
|
|||||||
// POST /api/polls/:pollId/vote — cast or change vote
|
// POST /api/polls/:pollId/vote — cast or change vote
|
||||||
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
|
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
|
||||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
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 }) });
|
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
const pollId = req.params.pollId;
|
const pollId = req.params.pollId;
|
||||||
|
|||||||
+36
-10
@@ -4,6 +4,7 @@ import lib from "../lib.mjs";
|
|||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import { createI18n } from "../i18n.mjs";
|
import { createI18n } from "../i18n.mjs";
|
||||||
import { render502 } from "../private_items.mjs";
|
import { render502 } from "../private_items.mjs";
|
||||||
|
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession } from "../settings.mjs";
|
||||||
|
|
||||||
const auth = async (req, res, next) => {
|
const auth = async (req, res, next) => {
|
||||||
if (!req.session)
|
if (!req.session)
|
||||||
@@ -13,9 +14,9 @@ const auth = async (req, res, next) => {
|
|||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
|
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
|
||||||
// When guest anonymization is active, user profiles must not be accessible without a session
|
// When anonymization is active, user profiles must not be accessible without an authenticated regular session
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
return req.session ? res.redirect('/') : res.redirect('/login');
|
||||||
}
|
}
|
||||||
const user = decodeURIComponent(req.params.user);
|
const user = decodeURIComponent(req.params.user);
|
||||||
const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null);
|
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) });
|
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
|
// 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) {
|
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` });
|
res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` });
|
||||||
return res.end();
|
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
|
// Auto-persist strict mode from URL to session if it's there
|
||||||
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
|
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');
|
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
|
// 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;
|
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)({
|
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
|
||||||
user: req.params.user,
|
user: req.params.user,
|
||||||
tag: reqTag,
|
tag: reqTag,
|
||||||
@@ -405,8 +418,8 @@ export default (router, tpl) => {
|
|||||||
// Hall columns for display
|
// Hall columns for display
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
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(',') : '';
|
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
|
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
if (item.src) item.src = null;
|
if (item.src) item.src = null;
|
||||||
item.username = 'anonymous';
|
item.username = 'anonymous';
|
||||||
item.author_banner_file = null;
|
item.author_banner_file = null;
|
||||||
@@ -632,6 +645,19 @@ export default (router, tpl) => {
|
|||||||
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
|
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
|
||||||
const mode = modeMatch ? +modeMatch[1] : 0;
|
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 (cfg.allowedModes[mode]) {
|
||||||
if (req.session) {
|
if (req.session) {
|
||||||
req.session.mode = mode;
|
req.session.mode = mode;
|
||||||
|
|||||||
@@ -3,10 +3,65 @@ import f0cklib from "../routeinc/f0cklib.mjs";
|
|||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { getEnableItemSlugs } from "../settings.mjs";
|
import { getEnableItemSlugs } from "../settings.mjs";
|
||||||
import { setMotd } from "../motd.mjs";
|
import { setMotd } from "../motd.mjs";
|
||||||
|
import security from "../security.mjs";
|
||||||
|
|
||||||
export const clients = new Set();
|
export const clients = new Set();
|
||||||
const activeTabs = new Map(); // sessionId -> tabId
|
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
|
// Broadcast the deduplicated online-user list to all connected clients
|
||||||
function broadcastChatPresence() {
|
function broadcastChatPresence() {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -83,6 +138,17 @@ db.listen('warnings', (payload) => {
|
|||||||
}
|
}
|
||||||
}).catch(err => console.error('DB Listen Warning error:', err));
|
}).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.)
|
// Global listener for profile updates (display name changes etc.)
|
||||||
db.listen('profile_update', (payload) => {
|
db.listen('profile_update', (payload) => {
|
||||||
try {
|
try {
|
||||||
@@ -603,8 +669,16 @@ export default (router, tpl) => {
|
|||||||
res.writeHead(200, headers);
|
res.writeHead(200, headers);
|
||||||
res.write(': ok\n\n'); // Warmup
|
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 = {
|
const client = {
|
||||||
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
|
userId: clientUserId,
|
||||||
|
fingerprint: clientFp,
|
||||||
|
hwFingerprint: clientHw,
|
||||||
username: req.session?.user || null,
|
username: req.session?.user || null,
|
||||||
display_name: req.session?.display_name || null,
|
display_name: req.session?.display_name || null,
|
||||||
avatar_file: req.session?.avatar_file || 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,
|
do_not_disturb: req.session?.do_not_disturb === true,
|
||||||
sessionId,
|
sessionId,
|
||||||
tabId,
|
tabId,
|
||||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
ip: clientIp,
|
||||||
|
ipHash: clientIpHash,
|
||||||
send: (data) => {
|
send: (data) => {
|
||||||
try {
|
try {
|
||||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
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
|
// Send any unacknowledged warnings on connection
|
||||||
if (!isGuest && req.session?.id) {
|
if (!isGuest && req.session?.id) {
|
||||||
db`
|
db`
|
||||||
|
|||||||
@@ -43,17 +43,21 @@ export default (router, tpl) => {
|
|||||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
|
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({
|
const data = await f0cklib.getRandom({
|
||||||
user: opts.user,
|
user: opts.user,
|
||||||
tag: opts.tag,
|
tag: opts.tag,
|
||||||
hall: opts.hall,
|
hall: opts.hall,
|
||||||
mime: opts.mime || (req.cookies.mime || null),
|
mime: effectiveMime,
|
||||||
page: opts.page,
|
page: opts.page,
|
||||||
fav: opts.mode === 'favs',
|
fav: opts.mode === 'favs',
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
strict: opts.strict,
|
strict: opts.strict,
|
||||||
session: !!req.session
|
session: req.session
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import cfg from "../config.mjs";
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
|
import { isAnonymizeSession } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
// Serve the scroller page
|
// Serve the scroller page
|
||||||
@@ -377,7 +378,7 @@ export default (router, tpl) => {
|
|||||||
const lastItem = items[items.length - 1];
|
const lastItem = items[items.length - 1];
|
||||||
const nextCursor = lastItem ? lastItem.id : null;
|
const nextCursor = lastItem ? lastItem.id : null;
|
||||||
|
|
||||||
const isAnonymized = !req.session && cfg.main.guest_anonymize;
|
const isAnonymized = isAnonymizeSession(req.session);
|
||||||
const outItems = isAnonymized
|
const outItems = isAnonymized
|
||||||
? items.map(item => ({
|
? items.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import search from "../routeinc/search.mjs";
|
import search from "../routeinc/search.mjs";
|
||||||
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
|
|
||||||
const _eps = 20;
|
const _eps = 20;
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ export default (router, tpl) => {
|
|||||||
let pagination, link;
|
let pagination, link;
|
||||||
|
|
||||||
if (tag.length > 0) {
|
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:')) {
|
if (tag.startsWith('src:')) {
|
||||||
total = (await db`
|
total = (await db`
|
||||||
select count(*) as total
|
select count(*) as total
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { getMinTags } from "../settings.mjs";
|
import { getMinTags, canAnonDo, isAnonSession } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
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;
|
let maxfilesize = cfg.main.maxfilesize;
|
||||||
if (req.session.admin || req.session.is_moderator) {
|
if (req.session.admin || req.session.is_moderator) {
|
||||||
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
|
import { isAnonymizeSession } from "../settings.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -43,8 +44,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// List halls for a user
|
// List halls for a user
|
||||||
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
|
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
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) });
|
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);
|
const ownerName = decodeURIComponent(req.params.owner);
|
||||||
@@ -83,8 +84,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Item grid for a user hall
|
// Item grid for a user hall
|
||||||
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
|
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
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) });
|
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);
|
const ownerName = decodeURIComponent(req.params.owner);
|
||||||
@@ -132,8 +133,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Single item within a user hall
|
// Single item within a user hall
|
||||||
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
|
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
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) });
|
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);
|
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_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.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||||
data.item_has_dimensions = !!(item.width && item.height);
|
data.item_has_dimensions = !!(item.width && item.height);
|
||||||
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
if (item.src) item.src = null;
|
if (item.src) item.src = null;
|
||||||
item.username = 'anonymous';
|
item.username = 'anonymous';
|
||||||
item.author_banner_file = null;
|
item.author_banner_file = null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import db from "../../inc/sql.mjs";
|
import db from "../../inc/sql.mjs";
|
||||||
import lib from "../../inc/lib.mjs";
|
import lib from "../../inc/lib.mjs";
|
||||||
import cfg from "../../inc/config.mjs";
|
import cfg from "../../inc/config.mjs";
|
||||||
|
import { isAnonymizeSession } from "../settings.mjs";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
|
|
||||||
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
|
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
|
// API endpoint for lazy loading tags for a user
|
||||||
router.get(/^\/api\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
|
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 userParam = decodeURIComponent(req.params.user);
|
||||||
|
|
||||||
const u = await db`
|
const u = await db`
|
||||||
@@ -158,8 +162,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Main tags page
|
// Main tags page
|
||||||
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
|
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
|
||||||
if (cfg.main.guest_anonymize && !req.session) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
return res.redirect('/login');
|
return req.session ? res.redirect('/') : res.redirect('/login');
|
||||||
}
|
}
|
||||||
const userParam = decodeURIComponent(req.params.user);
|
const userParam = decodeURIComponent(req.params.user);
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -317,8 +317,8 @@ export default new class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Ban the hardware fingerprint
|
// 3. Ban the hardware fingerprint
|
||||||
if (banHardware) {
|
|
||||||
const associatedHws = new Set();
|
const associatedHws = new Set();
|
||||||
|
if (banHardware) {
|
||||||
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
|
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
@@ -348,9 +348,8 @@ export default new class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cascade to associated IPs
|
// 4. Cascade to associated IPs
|
||||||
if (banIps) {
|
|
||||||
const associatedIps = new Set();
|
const associatedIps = new Set();
|
||||||
|
if (banIps) {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const actIps = await db`select distinct ip from anon_activity_log where user_id = ${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);
|
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) {
|
for (const ip of associatedIps) {
|
||||||
if (!ip || ip === 'unknown') continue;
|
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`
|
await db`
|
||||||
insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at)
|
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
|
on conflict (ip) do update
|
||||||
set reason = excluded.reason,
|
set reason = excluded.reason,
|
||||||
expires_at = excluded.expires_at,
|
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 };
|
return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,100 @@ export const getEnableAnonymousAccess = () => {
|
|||||||
return true;
|
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 () => {
|
export const ensureAllItemsHaveSlugs = async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
|
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 { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_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 { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||||
@@ -1636,6 +1636,8 @@ process.on('uncaughtException', err => {
|
|||||||
domain: cfg.main.url.domain,
|
domain: cfg.main.url.domain,
|
||||||
hide_comments_from_public: cfg.main.hide_comments_from_public,
|
hide_comments_from_public: cfg.main.hide_comments_from_public,
|
||||||
guest_anonymize: !!cfg.main.guest_anonymize,
|
guest_anonymize: !!cfg.main.guest_anonymize,
|
||||||
|
anon_anonymize: false,
|
||||||
|
is_anonymized: false,
|
||||||
git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown',
|
git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown',
|
||||||
get motd() { return getMotd(); },
|
get motd() { return getMotd(); },
|
||||||
get manual_approval() { return getManualApproval(); },
|
get manual_approval() { return getManualApproval(); },
|
||||||
@@ -1688,6 +1690,8 @@ process.on('uncaughtException', err => {
|
|||||||
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||||
get enable_item_slugs() { return getEnableItemSlugs(); },
|
get enable_item_slugs() { return getEnableItemSlugs(); },
|
||||||
get enable_anonymous_access() { return getEnableAnonymousAccess(); },
|
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)),
|
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,
|
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,
|
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
||||||
@@ -1828,10 +1832,16 @@ process.on('uncaughtException', err => {
|
|||||||
? data.user_alternative_steuerung
|
? data.user_alternative_steuerung
|
||||||
: (cfg.websrv.user_alternative_steuerung !== false));
|
: (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 || {}, {
|
data = Object.assign({}, globals, data || {}, {
|
||||||
t: perRequestT,
|
t: perRequestT,
|
||||||
lang: perRequestLang,
|
lang: perRequestLang,
|
||||||
recaptcha_enabled: perRequestRecaptcha,
|
recaptcha_enabled: perRequestRecaptcha,
|
||||||
|
is_anonymized: isAnonymized,
|
||||||
|
anon_anonymize: anonAnonymize,
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
user_alternative_steuerung: useAltSteuerung,
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
@@ -1843,6 +1853,8 @@ process.on('uncaughtException', err => {
|
|||||||
? data.comment_display_mode
|
? data.comment_display_mode
|
||||||
: (cfg.websrv.default_comment_display_mode || 0))
|
: (cfg.websrv.default_comment_display_mode || 0))
|
||||||
});
|
});
|
||||||
|
globals.is_anonymized = isAnonymized;
|
||||||
|
globals.anon_anonymize = anonAnonymize;
|
||||||
|
|
||||||
// Random brand image per-render
|
// Random brand image per-render
|
||||||
const brand = cfg.websrv.custom_brand_image;
|
const brand = cfg.websrv.custom_brand_image;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
|
|||||||
import queue from "./inc/queue.mjs";
|
import queue from "./inc/queue.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import https from "https";
|
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 { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
||||||
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.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);
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
|
if (isAnonSession(req.session)) {
|
||||||
return sendJson(res, { success: false, msg: 'Uploading requires a registered account' }, 403);
|
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.
|
// CSRF validation — required for browser sessions, skipped for API key auth.
|
||||||
|
|||||||
+260
-12
@@ -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 { 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; }
|
.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 */
|
||||||
#create-user-modal {
|
#create-user-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -236,27 +329,59 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 0 15px;">
|
<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>
|
<div>
|
||||||
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
|
<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>
|
||||||
<div style="display: flex; gap: 10px; align-items: center; flex-grow: 1; max-width: 500px;">
|
<div>
|
||||||
<div style="flex-grow: 1; position: relative;">
|
<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'
|
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
|
||||||
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
|
style="width: 100%; height: 42px; padding: 0 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s; box-sizing: border-box;"
|
||||||
value="{{ q }}">
|
value="{{ q }}">
|
||||||
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
|
<div id="search-spinner" style="position: absolute; right: 15px; top: 11px; display: none;">
|
||||||
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-modern btn-create" onclick="openCreateUserModal()" style="padding: 10px 16px; font-size: 0.85rem; white-space: nowrap;" title="Create new user">
|
|
||||||
<i class="fa fa-user-plus"></i> Create User
|
<!-- Status Filter -->
|
||||||
|
<select id="status-filter" class="admin-select-filter {{ status ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by account status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="banned" {{ status === 'banned' ? 'selected' : '' }}>⛔ Banned</option>
|
||||||
|
<option value="active" {{ status === 'active' ? 'selected' : '' }}>🟢 Active</option>
|
||||||
|
<option value="pending" {{ status === 'pending' ? 'selected' : '' }}>⏳ Pending</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Role Filter -->
|
||||||
|
<select id="role-filter" class="admin-select-filter {{ role ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by user role">
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="staff" {{ role === 'staff' ? 'selected' : '' }}>🛡️ Staff (All)</option>
|
||||||
|
<option value="admin" {{ role === 'admin' ? 'selected' : '' }}>⚡ Admins</option>
|
||||||
|
<option value="mod" {{ role === 'mod' ? 'selected' : '' }}>🗡️ Moderators</option>
|
||||||
|
<option value="user" {{ role === 'user' ? 'selected' : '' }}>👤 Regular Users</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Legacy Only Toggle -->
|
||||||
|
<label id="legacy-filter-label" class="legacy-filter-btn {{ onlyLegacy ? 'active' : '' }}" title="Toggle to only show legacy ghost users">
|
||||||
|
<input type="checkbox" id="legacy-filter" {{ onlyLegacy ? 'checked' : '' }}>
|
||||||
|
<span><i class="fa fa-ghost" style="opacity: 0.8; margin-right: 4px;"></i>Only legacy</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Reset Filters Button -->
|
||||||
|
<button id="reset-filters-btn" class="btn-filter-reset" style="display: {{ (q || status || role || onlyLegacy) ? 'inline-flex' : 'none' }};" title="Reset all filters and search">
|
||||||
|
<i class="fa fa-rotate-left"></i> Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-form">
|
<div class="upload-form">
|
||||||
<table class="admin-users-table responsive-table">
|
<table class="admin-users-table responsive-table">
|
||||||
@@ -276,7 +401,7 @@
|
|||||||
</table>
|
</table>
|
||||||
<div id="loading-trigger" style="height: 20px; margin-top: 10px;"></div>
|
<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;">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,28 +627,81 @@
|
|||||||
var hasMore = {!! hasMore ? 'true' : 'false' !!};
|
var hasMore = {!! hasMore ? 'true' : 'false' !!};
|
||||||
var isLoading = false;
|
var isLoading = false;
|
||||||
var searchQuery = '{{ q }}';
|
var searchQuery = '{{ q }}';
|
||||||
|
var onlyLegacy = {!! onlyLegacy ? 'true' : 'false' !!};
|
||||||
|
var currentStatus = '{{ status }}';
|
||||||
|
var currentRole = '{{ role }}';
|
||||||
|
|
||||||
var searchInput = document.getElementById('user-search');
|
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 tableBody = document.getElementById('user-table-body');
|
||||||
var loadingTrigger = document.getElementById('loading-trigger');
|
var loadingTrigger = document.getElementById('loading-trigger');
|
||||||
var noUsersMsg = document.getElementById('no-users-msg');
|
var noUsersMsg = document.getElementById('no-users-msg');
|
||||||
var spinner = document.getElementById('search-spinner');
|
var spinner = document.getElementById('search-spinner');
|
||||||
var countSpan = document.getElementById('total-count');
|
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) {
|
async function fetchUsers(page, q, append) {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
if (!append) spinner.style.display = 'block';
|
if (!append) spinner.style.display = 'block';
|
||||||
|
|
||||||
try {
|
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' } });
|
var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||||
|
|
||||||
// Update state from headers
|
// Update state from headers
|
||||||
var total = res.headers.get('X-Total-Count');
|
var total = res.headers.get('X-Total-Count');
|
||||||
var hasMoreHeader = res.headers.get('X-Has-More');
|
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 (total !== null) countSpan.textContent = total;
|
||||||
|
if (totalLabelHeader && totalLabel) totalLabel.textContent = totalLabelHeader;
|
||||||
|
if (emptyMsgHeader && noUsersMsg) noUsersMsg.textContent = emptyMsgHeader;
|
||||||
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
|
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
|
||||||
|
|
||||||
var html = await res.text();
|
var html = await res.text();
|
||||||
@@ -548,12 +726,74 @@
|
|||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(function() {
|
searchTimeout = setTimeout(function() {
|
||||||
searchQuery = searchInput.value;
|
searchQuery = searchInput.value.trim();
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
updateUrlAndControls();
|
||||||
fetchUsers(1, searchQuery, false);
|
fetchUsers(1, searchQuery, false);
|
||||||
}, 300);
|
}, 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
|
// Infinite Scroll
|
||||||
var observer = new IntersectionObserver(function(entries) {
|
var observer = new IntersectionObserver(function(entries) {
|
||||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
||||||
@@ -624,6 +864,14 @@
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
closeCreateUserModal();
|
closeCreateUserModal();
|
||||||
showFlash(data.msg, 'success');
|
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
|
// Refresh the table from page 1
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchUsers(1, searchQuery, false);
|
fetchUsers(1, searchQuery, false);
|
||||||
|
|||||||
+65
-8
@@ -1,13 +1,70 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div id="main" style="display: flex; justify-content: center; align-items: center; min-height: 80vh;">
|
<div class="pagewrapper">
|
||||||
<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;">
|
<div id="main" style="padding: 40px 15px; box-sizing: border-box;">
|
||||||
<h1 style="color: var(--accent); margin-bottom: 20px;">YOU ARE BANNED!</h1>
|
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
|
||||||
<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>
|
<!-- BANNED VIEW -->
|
||||||
<p style="font-size: 1.1em; color: rgba(255,255,255,0.7);">Ban expires: <strong>{{ expires }}</strong></p>
|
<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;">
|
||||||
<div style="margin-top: 30px;">
|
<h1 style="color: #ff4757; margin-bottom: 20px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase;">YOU ARE BANNED!</h1>
|
||||||
<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>
|
<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>
|
</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)
|
@include(snippets/footer)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@include(snippets/page-title)
|
@include(snippets/page-title)
|
||||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||||
@each(items as item)
|
@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">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
|
|||||||
@@ -93,10 +93,10 @@
|
|||||||
|
|
||||||
<div class="blahlol">
|
<div class="blahlol">
|
||||||
@if(user_alternative_infobox)
|
@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">
|
<div class="user-infobox-avatar">
|
||||||
@if(session || !guest_anonymize)
|
@if(!is_anonymized)
|
||||||
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
||||||
@if(item.author_avatar_file)
|
@if(item.author_avatar_file)
|
||||||
<img src="/a/{{ item.author_avatar_file }}" />
|
<img src="/a/{{ item.author_avatar_file }}" />
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
<div class="user-infobox-info">
|
<div class="user-infobox-info">
|
||||||
<div class="user-infobox-header">
|
<div class="user-infobox-header">
|
||||||
<div class="user-infobox-username-container">
|
<div class="user-infobox-username-container">
|
||||||
@if(session || !guest_anonymize)
|
@if(!is_anonymized)
|
||||||
<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>
|
<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
|
@else
|
||||||
<span class="user-infobox-username">anonymous</span>
|
<span class="user-infobox-username">anonymous</span>
|
||||||
@endif
|
@endif
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-infobox-body">
|
<div class="user-infobox-body">
|
||||||
<div class="user-infobox-description">
|
<div class="user-infobox-description">
|
||||||
@if(session || !guest_anonymize){!! item.author_description || '' !!}@endif
|
@if(!is_anonymized){!! item.author_description || '' !!}@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="user-infobox-actions">
|
<div class="user-infobox-actions">
|
||||||
@if(session)
|
@if(session)
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
<span class="badge badge-dark">
|
<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>
|
<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)
|
@if(!user_alternative_infobox)
|
||||||
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||||
@endif
|
@endif
|
||||||
@@ -205,8 +205,8 @@
|
|||||||
<span class="rating-label">{!! tag.tag !!}</span>
|
<span class="rating-label">{!! tag.tag !!}</span>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
<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 }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
<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>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
@each(item.favorites as fav)
|
@each(item.favorites as fav)
|
||||||
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
@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>
|
<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>
|
<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
|
@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>
|
<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) data-is-locked="true" @endif>
|
||||||
@if(item.is_comments_locked && !is_mod_or_admin)
|
@if(item.is_comments_locked && !is_mod_or_admin)
|
||||||
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
<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
|
@else
|
||||||
<div class="comment-input main-input">
|
<div class="comment-input main-input">
|
||||||
<textarea disabled></textarea>
|
<textarea disabled></textarea>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
@if(item.is_comments_locked) data-is-locked="true" @endif>
|
@if(item.is_comments_locked) data-is-locked="true" @endif>
|
||||||
@if(item.is_comments_locked && !is_mod_or_admin)
|
@if(item.is_comments_locked && !is_mod_or_admin)
|
||||||
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
<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
|
@else
|
||||||
<div class="comment-input main-input">
|
<div class="comment-input main-input">
|
||||||
<textarea disabled></textarea>
|
<textarea disabled></textarea>
|
||||||
@@ -51,8 +53,8 @@
|
|||||||
<span class="rating-label">{!! tag.tag !!}</span>
|
<span class="rating-label">{!! tag.tag !!}</span>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
<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 }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
<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>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
@@ -138,7 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="blahlol">
|
<div class="blahlol">
|
||||||
<span class="badge badge-dark">
|
<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>
|
||||||
<span id="hall-badge-container">@if(halls_enabled && item.primaryHall)<span class="badge hall-badge-wrap">
|
<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
|
<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)
|
@each(item.favorites as fav)
|
||||||
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
@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>
|
<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>
|
<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
|
@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>
|
<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.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.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.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.scrollerEnableNsfl = {{ enable_nsfl ? 'true' : 'false' }};
|
||||||
window.scrollerEnableSwf = {{ enable_swf ? '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;
|
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>
|
<button class="filter-reset-btn" id="filter-reset-btn">{{ t('scroller.reset_all') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-scroll-area">
|
<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">
|
||||||
<div class="filter-section-label">{{ t('scroller.rating') }}</div>
|
<div class="filter-section-label">{{ t('scroller.rating') }}</div>
|
||||||
<div class="pill-group" id="mode-pills">
|
<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>
|
<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>
|
<button class="filter-pill" data-mode="1"><i class="fa-solid fa-fire"></i>NSFW</button>
|
||||||
@endif
|
@endif
|
||||||
@if(session && enable_nsfl)
|
@if(_scrollerShowNsfl)
|
||||||
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
|
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
|
||||||
@endif
|
@endif
|
||||||
@if(session)
|
@if(_scrollerShowAll)
|
||||||
<button class="filter-pill" data-mode="3">{{ t('scroller.all') }}</button>
|
<button class="filter-pill" data-mode="3">{{ t('scroller.all') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@if(session && (session.admin || session.is_moderator))
|
@if(_scrollerShowUntagged)
|
||||||
<button class="filter-pill" data-mode="2">{{ t('scroller.untagged') }}</button>
|
<button class="filter-pill" data-mode="2">{{ t('scroller.untagged') }}</button>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">{{ t('scroller.media_type') }}</div>
|
<div class="filter-section-label">{{ t('scroller.media_type') }}</div>
|
||||||
<div class="pill-group" id="mime-pills">
|
<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>
|
<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'))
|
@if(scroller_mime_cats.includes('video'))
|
||||||
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>{{ t('scroller.video') }}</button>
|
<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'))
|
@if(scroller_mime_cats.includes('audio'))
|
||||||
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>{{ t('scroller.audio') }}</button>
|
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>{{ t('scroller.audio') }}</button>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
|
|||||||
@@ -1,40 +1,75 @@
|
|||||||
<div id="excluded-tags-overlay" style="display: none;">
|
<div id="excluded-tags-overlay" style="display: none;">
|
||||||
<div id="excluded-tags-close">×</div>
|
<div id="excluded-tags-close">×</div>
|
||||||
<div class="search-container">
|
<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 id="nav_excluded_tags_list"></div>
|
||||||
<div class="nav-exclude">
|
<div class="nav-exclude">
|
||||||
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
|
<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 id="nav_exclude_suggestions" class="tag-suggestions"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
<!-- modes -->
|
<!-- modes -->
|
||||||
<div class="mode-filter">
|
<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>
|
<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" id="rating-selector">
|
<div class="rating-selector @if(_isSingleRating) locked @endif" id="rating-selector" @if(_isSingleRating) data-locked-rating="{{ _singleRating }}" @endif>
|
||||||
<button class="rating-toggle-btn" data-rating="sfw" id="rating-btn-sfw" title="SFW">SFW</button>
|
@if(_showSfw)
|
||||||
<button class="rating-toggle-btn" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW">NSFW</button>
|
<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>
|
||||||
@if(enable_nsfl)
|
|
||||||
<button class="rating-toggle-btn" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL">NSFL</button>
|
|
||||||
@endif
|
@endif
|
||||||
@if(session && session.admin || session && session.is_moderator)
|
@if(_showNsfw)
|
||||||
<button class="rating-toggle-btn" data-rating="untagged" id="rating-btn-untagged" title="Untagged">UNT</button>
|
<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
|
@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>
|
<button class="rating-toggle-btn rating-toggle-all" id="rating-btn-all" title="Show All Ratings">ALL</button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- mimes -->
|
<!-- mimes -->
|
||||||
@if(show_mime_picker)
|
@if(show_mime_picker)
|
||||||
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
|
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu" @if(_isSingleMime) data-locked-mime="{{ _singleMime }}" @endif>
|
||||||
@if(scroller_mime_cats && scroller_mime_cats.includes('video'))
|
@if(_showVideo)
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
|
<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
|
@endif
|
||||||
@if(scroller_mime_cats && scroller_mime_cats.includes('audio'))
|
@if(_showAudio)
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
|
<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
|
@endif
|
||||||
@if(scroller_mime_cats && scroller_mime_cats.includes('image'))
|
@if(_showImage)
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="image" id="mime-image">{{ t('filter.image') }}</label>
|
<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
|
@endif
|
||||||
@if(enable_swf)
|
@if(_showFlash)
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="flash" id="mime-flash">{{ t('filter.flash') }}</label>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@if(private_society && !session)
|
@if(private_society && !session)
|
||||||
<script>
|
<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.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||||
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
||||||
(() => {
|
(() => {
|
||||||
@@ -422,10 +422,14 @@
|
|||||||
public_nsfw: @if(public_nsfw) true @else false @endif,
|
public_nsfw: @if(public_nsfw) true @else false @endif,
|
||||||
enable_item_slugs: @if(enable_item_slugs) 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,
|
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,
|
strict_mode: @if(session && session.strict_mode) true @else false @endif,
|
||||||
logged_in: @if(session) 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,
|
hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif,
|
||||||
guest_anonymize: @if(guest_anonymize) 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,
|
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 }}",
|
csrf_token: "{{ csrf_token }}",
|
||||||
default_theme: "{{ default_theme }}",
|
default_theme: "{{ default_theme }}",
|
||||||
@@ -477,7 +481,7 @@
|
|||||||
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
|
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
|
||||||
enable_comment_polls: @if(enable_comment_polls) 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,
|
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.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||||
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
||||||
@@ -557,6 +561,11 @@
|
|||||||
zomg_on: "{{ t('toast.zomg_on') }}",
|
zomg_on: "{{ t('toast.zomg_on') }}",
|
||||||
zomg_off: "{{ t('toast.zomg_off') }}",
|
zomg_off: "{{ t('toast.zomg_off') }}",
|
||||||
copied: "{{ t('toast.copied') }}",
|
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
|
// actions
|
||||||
fav_added: "{{ t('toast.fav_added') }}",
|
fav_added: "{{ t('toast.fav_added') }}",
|
||||||
fav_removed: "{{ t('toast.fav_removed') }}",
|
fav_removed: "{{ t('toast.fav_removed') }}",
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<canvas class="hidden-xs" id="bg"></canvas>
|
<canvas class="hidden-xs" id="bg"></canvas>
|
||||||
@endif
|
@endif
|
||||||
@include(snippets/navbar)
|
@include(snippets/navbar)
|
||||||
@if(session)
|
@if(session || (enable_anonymous_access && anon_permissions.filter))
|
||||||
@include(snippets/excluded-tags-modal)
|
@include(snippets/excluded-tags-modal)
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@each(items as item)
|
@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">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
|
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
|
||||||
<div class="nav-collapse" id="navbarContent">
|
<div class="nav-collapse" id="navbarContent">
|
||||||
<div class="nav-links">
|
<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>
|
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-angle-up"></i> {{ t('nav.upload') }}</a>
|
||||||
@endif
|
@endif
|
||||||
@if(meme_creator)
|
@if(meme_creator)
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a>
|
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a>
|
||||||
|
|
||||||
<!-- Filter -->
|
<!-- 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;">
|
<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>
|
<i class="fa-solid fa-filter"></i>
|
||||||
@if(session.mode == 0)
|
@if(session.mode == 0)
|
||||||
@@ -134,6 +135,7 @@
|
|||||||
<span id="nav-filter-badge" class="filter-badge filter-badge-sfw">SFW</span>
|
<span id="nav-filter-badge" class="filter-badge filter-badge-sfw">SFW</span>
|
||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Logout -->
|
<!-- Logout -->
|
||||||
<a href="/logout" title="Logout" class="desktop-only"><i class="fa-solid fa-right-from-bracket"></i></a>
|
<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>
|
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
|
||||||
@endif
|
@endif
|
||||||
<a href="#" id="nav-search-btn" title="Search"><i class="fa-solid fa-magnifying-glass"></i></a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user