diff --git a/migrations/add_reporter_ip.sql b/migrations/add_reporter_ip.sql
new file mode 100644
index 0000000..31412ee
--- /dev/null
+++ b/migrations/add_reporter_ip.sql
@@ -0,0 +1 @@
+ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS reporter_ip text;
diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql
index 13693ea..f017aa8 100644
--- a/migrations/f0ckm_schema.sql
+++ b/migrations/f0ckm_schema.sql
@@ -1321,6 +1321,7 @@ ALTER SEQUENCE public.private_messages_id_seq OWNED BY public.private_messages.i
CREATE TABLE public.reports (
id integer NOT NULL,
reporter_id integer,
+ reporter_ip text,
item_id integer,
comment_id integer,
user_id integer,
diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 092d930..52d0b79 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -794,7 +794,9 @@ html[theme='paper'] a#prev {
}
html[theme='light'] .item_title,
-html[theme='paper'] .item_title {
+html[theme='light'] .item-title,
+html[theme='paper'] .item_title,
+html[theme='paper'] .item-title {
color: #0f172a;
}
@@ -2866,7 +2868,8 @@ body.sidebar-right-hidden #sidebar-drag-zone {
max-width: 100%;
}
- body.layout-legacy .item-layout-container .item-main-content .item_title {
+ body.layout-legacy .item-layout-container .item-main-content .item_title,
+ body.layout-legacy .item-layout-container .item-main-content .item-title {
align-self: center;
width: 800px;
max-width: 100%;
@@ -3217,6 +3220,7 @@ body.sidebar-right-hidden #sidebar-drag-zone {
.item-layout-container .item-main-content>._204863,
.item-layout-container .item-main-content>.item_title,
+.item-layout-container .item-main-content>.item-title,
.item-layout-container .item-main-content>.content,
.item-layout-container .item-main-content>.metadata,
.item-layout-container .item-main-content #comments-container {
@@ -3231,11 +3235,13 @@ body.sidebar-right-hidden #sidebar-drag-zone {
/* Reset z-index in fullscreen to prevent sibling stacking contexts from showing metadata/comments over the player */
#main:fullscreen .item-layout-container .item-main-content>._204863,
#main:fullscreen .item-layout-container .item-main-content>.item_title,
+#main:fullscreen .item-layout-container .item-main-content>.item-title,
#main:fullscreen .item-layout-container .item-main-content>.content,
#main:fullscreen .item-layout-container .item-main-content>.metadata,
#main:fullscreen .item-layout-container .item-main-content #comments-container,
#onara-modal:fullscreen .item-layout-container .item-main-content>._204863,
#onara-modal:fullscreen .item-layout-container .item-main-content>.item_title,
+#onara-modal:fullscreen .item-layout-container .item-main-content>.item-title,
#onara-modal:fullscreen .item-layout-container .item-main-content>.content,
#onara-modal:fullscreen .item-layout-container .item-main-content>.metadata,
#onara-modal:fullscreen .item-layout-container .item-main-content #comments-container {
@@ -4850,7 +4856,8 @@ html[theme='f0ck95d'] {
--comment-bg: #0b0a0a;
}
-html[theme="f0ck95d"] .item_title {
+html[theme="f0ck95d"] .item_title,
+html[theme="f0ck95d"] .item-title {
background: black;
border-left: 2px solid gray;
border-right: 2px solid gray;
@@ -5753,7 +5760,8 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content,
._204863,
-.item_title {
+.item_title,
+.item-title {
animation: contentSlideIn 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
@@ -5769,9 +5777,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.slide-next,
._204863.slide-next,
.item_title.slide-next,
+.item-title.slide-next,
#onara-modal .content.slide-next,
#onara-modal ._204863.slide-next,
-#onara-modal .item_title.slide-next {
+#onara-modal .item_title.slide-next,
+#onara-modal .item-title.slide-next {
animation: contentSlideInRight 0.45s cubic-bezier(0.16, 1, 0.3, 1) both !important;
will-change: transform, opacity;
}
@@ -5779,9 +5789,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.slide-prev,
._204863.slide-prev,
.item_title.slide-prev,
+.item-title.slide-prev,
#onara-modal .content.slide-prev,
#onara-modal ._204863.slide-prev,
-#onara-modal .item_title.slide-prev {
+#onara-modal .item_title.slide-prev,
+#onara-modal .item-title.slide-prev {
animation: contentSlideInLeft 0.45s cubic-bezier(0.16, 1, 0.3, 1) both !important;
will-change: transform, opacity;
}
@@ -5789,9 +5801,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.slide-out-left,
._204863.slide-out-left,
.item_title.slide-out-left,
+.item-title.slide-out-left,
#onara-modal .content.slide-out-left,
#onara-modal ._204863.slide-out-left,
-#onara-modal .item_title.slide-out-left {
+#onara-modal .item_title.slide-out-left,
+#onara-modal .item-title.slide-out-left {
animation: contentSlideOutLeft 0.22s cubic-bezier(0.3, 0, 0.8, 1) forwards !important;
pointer-events: none;
will-change: transform, opacity;
@@ -5800,9 +5814,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.slide-out-right,
._204863.slide-out-right,
.item_title.slide-out-right,
+.item-title.slide-out-right,
#onara-modal .content.slide-out-right,
#onara-modal ._204863.slide-out-right,
-#onara-modal .item_title.slide-out-right {
+#onara-modal .item_title.slide-out-right,
+#onara-modal .item-title.slide-out-right {
animation: contentSlideOutRight 0.22s cubic-bezier(0.3, 0, 0.8, 1) forwards !important;
pointer-events: none;
will-change: transform, opacity;
@@ -5811,9 +5827,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.fade-in-zoom,
._204863.fade-in-zoom,
.item_title.fade-in-zoom,
+.item-title.fade-in-zoom,
#onara-modal .content.fade-in-zoom,
#onara-modal ._204863.fade-in-zoom,
-#onara-modal .item_title.fade-in-zoom {
+#onara-modal .item_title.fade-in-zoom,
+#onara-modal .item-title.fade-in-zoom {
animation: contentFadeInZoom 0.35s cubic-bezier(0.16, 1, 0.3, 1) both !important;
will-change: transform, opacity;
}
@@ -5821,9 +5839,11 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.content.fade-out-zoom,
._204863.fade-out-zoom,
.item_title.fade-out-zoom,
+.item-title.fade-out-zoom,
#onara-modal .content.fade-out-zoom,
#onara-modal ._204863.fade-out-zoom,
-#onara-modal .item_title.fade-out-zoom {
+#onara-modal .item_title.fade-out-zoom,
+#onara-modal .item-title.fade-out-zoom {
animation: contentFadeOutZoom 0.18s cubic-bezier(0.3, 0, 0.8, 1) forwards !important;
pointer-events: none;
will-change: transform, opacity;
@@ -5990,11 +6010,13 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
position: relative;
}
-.item_title {
+.item_title,
+.item-title,
+.item-itle {
width: 100%;
max-width: 1100px;
- padding: 4px 10px;
border-top: none;
+ padding: 0px 0px 5px 0px;
color: rgba(255, 255, 255, 0.72);
font-size: 0.82em;
font-family: inherit;
@@ -6004,12 +6026,26 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
text-overflow: ellipsis;
line-height: 1.6;
box-sizing: border-box;
+ min-height: calc(1.6em + 5px) !important;
z-index: 1;
position: relative;
}
-.item_title:empty::before {
+.item_title_text:empty::before,
+.item_title:empty::before,
+.item-title:empty::before,
+.item-itle:empty::before {
content: '\00a0';
+ visibility: hidden;
+ user-select: none;
+ pointer-events: none;
+}
+
+.item_title:has(.info-title-edit-wrap),
+.item-title:has(.info-title-edit-wrap),
+.item-itle:has(.info-title-edit-wrap) {
+ white-space: normal;
+ overflow: visible;
}
/* .location {
@@ -12034,7 +12070,15 @@ textarea.mod-reason {
color: #fff;
border: 1px solid #444;
resize: vertical;
- padding: 5px;
+ padding: 8px;
+ border-radius: 4px;
+ box-sizing: border-box;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+textarea.mod-reason:focus {
+ outline: none;
+ border-color: #666;
}
.error-msg {
@@ -12044,6 +12088,47 @@ textarea.mod-reason {
display: none;
}
+.error-msg:not(:empty) {
+ display: block;
+}
+
+#report-error {
+ color: #ff6b6b;
+ font-size: 0.88em;
+ margin: 10px 0 5px 0;
+ padding: 8px 12px;
+ background: rgba(255, 107, 107, 0.12);
+ border: 1px solid rgba(255, 107, 107, 0.35);
+ border-radius: 4px;
+ text-align: left;
+ line-height: 1.4;
+ word-break: break-word;
+ animation: fadeIn 0.2s ease-in-out;
+}
+
+#report-error:empty {
+ display: none !important;
+}
+
+.field-error {
+ border-color: #ff6b6b !important;
+ box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.4) !important;
+ animation: fieldShake 0.4s ease-in-out;
+}
+
+#modal-report-recaptcha.field-error {
+ border: 1px solid #ff6b6b !important;
+ border-radius: 4px;
+ box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.4) !important;
+ padding: 4px;
+}
+
+@keyframes fieldShake {
+ 0%, 100% { transform: translateX(0); }
+ 20%, 60% { transform: translateX(-6px); }
+ 40%, 80% { transform: translateX(6px); }
+}
+
/* ---------- notifications.html styles ---------- */
.notif-history-container {
max-width: 760px !important;
@@ -17748,7 +17833,6 @@ body.scroller-active #gchat-reopen-bubble {
.info-title-edit-wrap {
display: flex;
align-items: center;
- gap: 6px;
margin-top: 2px;
}
@@ -17756,9 +17840,8 @@ body.scroller-active #gchat-reopen-bubble {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.14);
- border-radius: 6px;
color: #fff;
- padding: 6px 10px;
+ padding: 7px 10px;
font-size: 0.88rem;
outline: none;
transition: all 0.18s ease;
@@ -17774,7 +17857,6 @@ body.scroller-active #gchat-reopen-bubble {
background: var(--accent, #9f0);
color: #111;
border: none;
- border-radius: 6px;
width: 32px;
height: 32px;
display: flex;
@@ -19019,6 +19101,16 @@ body.onara-modal-open #sidebar-drag-zone {
z-index: 10061 !important;
}
+body.onara-modal-open .admin-bar {
+ z-index: 100080 !important;
+ filter: none !important;
+}
+body.onara-modal-open .admin-bar.open {
+ opacity: 1 !important;
+ pointer-events: auto !important;
+ visibility: visible !important;
+}
+
/* Modal wrapper covers main content area with subtle dark glassmorphism, respecting sidebar */
.onara-modal-wrapper {
position: fixed !important;
@@ -19204,7 +19296,8 @@ body.layout-modern #onara-modal .item-layout-container .item-main-content {
#onara-modal .content:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom),
#onara-modal ._204863:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom),
-#onara-modal .item_title:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom) {
+#onara-modal .item_title:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom),
+#onara-modal .item-title:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom) {
animation: contentSlideIn 0.55s cubic-bezier(0.16, 1, 0.3, 1) both !important;
will-change: transform, opacity;
}
diff --git a/public/s/js/admin.js b/public/s/js/admin.js
index 8ecac35..8e99b3e 100644
--- a/public/s/js/admin.js
+++ b/public/s/js/admin.js
@@ -391,11 +391,70 @@
deleteEvent(e);
} else if (target.closest("#a_pin")) {
pinButtonEvent(e);
+ } else if (target.closest("#a_unavailable")) {
+ unavailableButtonEvent(e);
} else if (target.closest("#a_favo")) {
toggleFavEvent(e);
}
});
+ const unavailableButtonEvent = async e => {
+ if (e) e.preventDefault();
+ const ctx = getContext();
+ if (!ctx) return;
+ const { postid } = ctx;
+
+ const unavBtn = document.querySelector('#a_unavailable');
+ if (!unavBtn) return;
+
+ const currentVis = parseInt(unavBtn.getAttribute('data-visibility') || '0', 10);
+ const willBeUnavailable = currentVis !== 3;
+ const targetVis = willBeUnavailable ? 3 : 0;
+ const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
+
+ const proceed = async () => {
+ try {
+ const res = await (await fetch('/api/v2/item/visibility', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': window.f0ckSession?.csrf_token
+ },
+ body: new URLSearchParams({ postid, id: postid, visibility: targetVis })
+ })).json();
+
+ if (res.success) {
+ const isNowUnav = res.visibility === 3;
+ unavBtn.setAttribute('data-visibility', res.visibility);
+ unavBtn.classList.toggle('active', isNowUnav);
+ unavBtn.style.color = isNowUnav ? 'var(--danger, #ff4444)' : '';
+ unavBtn.setAttribute('title', isNowUnav ? 'Make Available (Public)' : 'Make Unavailable (451)');
+
+ const infoVisLabel = document.getElementById('info-visibility-label');
+ if (infoVisLabel) {
+ infoVisLabel.innerHTML = isNowUnav
+ ? ' Unavailable (451)'
+ : ' Public';
+ }
+ const infoVisBtn = document.getElementById('info-visibility-edit-btn');
+ if (infoVisBtn) infoVisBtn.dataset.visibility = res.visibility;
+
+ window.flashMessage(isNowUnav ? 'ITEM MARKED UNAVAILABLE (451)' : 'ITEM RESTORED TO PUBLIC');
+ } else {
+ alert('Error: ' + (res.msg || 'Failed to update visibility'));
+ }
+ } catch (err) {
+ console.error('Unavailable error:', err);
+ }
+ };
+
+ if (typeof ModAction !== 'undefined' && ModAction.confirm) {
+ ModAction.confirm('Item Visibility', `${actionText} for post #${postid}?`, proceed);
+ } else if (confirm(`${actionText} for post #${postid}?`)) {
+ proceed();
+ }
+ };
+
const pinButtonEvent = async e => {
if (e) e.preventDefault();
const ctx = getContext();
diff --git a/public/s/js/comments.js b/public/s/js/comments.js
index b7b3882..4ef2511 100644
--- a/public/s/js/comments.js
+++ b/public/s/js/comments.js
@@ -2245,7 +2245,7 @@ class CommentSystem {
const anonDataAttrs = isAnonGuest ? '' : `data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"`;
- return `
${repliesHtml}`;
+ return `${repliesHtml}`;
}
timeAgo(date) {
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index b2cb489..43fa3ba 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -972,12 +972,53 @@ window.cancelAnimFrame = (function () {
}
});
- // Prevent left-click navigation on timestamp links
+ const fallbackCopy = (text, msg) => {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ textarea.style.top = "0";
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ try {
+ document.execCommand("copy");
+ if (typeof window.flashMessage === 'function') {
+ window.flashMessage(msg);
+ }
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ }
+ document.body.removeChild(textarea);
+ };
+
+ const copyCurrentUrl = () => {
+ const url = window.location.href;
+ const msg = (window.f0ckI18n && window.f0ckI18n.copied) || 'URL copied to clipboard';
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url).then(() => {
+ if (typeof window.flashMessage === 'function') {
+ window.flashMessage(msg);
+ }
+ }).catch(() => {
+ fallbackCopy(url, msg);
+ });
+ } else {
+ fallbackCopy(url, msg);
+ }
+ };
+ window.copyCurrentUrl = copyCurrentUrl;
+
+ // Left-click on timestamp copies the current URL to clipboard and shows toast (same as pressing "y")
document.addEventListener('click', (e) => {
- if (e.target.closest('.timestamp-link')) {
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
+ const tsTarget = e.target.closest('.timestamp-link, time.timeago');
+ if (tsTarget) {
+ if (e.button === 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ copyCurrentUrl();
+ }
}
}, true);
@@ -5492,26 +5533,7 @@ window.cancelAnimFrame = (function () {
"i": clickOnElementBinding("a#a_addtag"),
"l": () => window.toggleBackground(),
"y": () => {
- const url = window.location.href;
- if (navigator.clipboard) {
- navigator.clipboard.writeText(url).then(() => window.flashMessage('URL copied to clipboard'));
- } else {
- const textarea = document.createElement("textarea");
- textarea.value = url;
- textarea.style.position = "fixed";
- textarea.style.left = "-9999px";
- textarea.style.top = "0";
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
- try {
- document.execCommand("copy");
- window.flashMessage((window.f0ckI18n && window.f0ckI18n.copied) || 'URL copied to clipboard');
- } catch (err) {
- console.error("Fallback copy failed:", err);
- }
- document.body.removeChild(textarea);
- }
+ copyCurrentUrl();
},
" ": () => {
if (video && typeof video.play === 'function') { // Check if video wrapper exists/is valid
@@ -7683,8 +7705,8 @@ window.cancelAnimFrame = (function () {
document.querySelector('.messages-convo-page')
);
const sel = hasDedicatedWrapper
- ? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid'
- : '.pagination-container-fluid, .pagewrapper';
+ ? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid, .admin-bar'
+ : '.pagination-container-fluid, .pagewrapper, .admin-bar';
return document.querySelectorAll(sel);
};
@@ -7705,10 +7727,10 @@ window.cancelAnimFrame = (function () {
const wrappers = getSidebarWrappers();
if (animateWrappers) {
wrappers.forEach(w => {
- const isPagination = w.classList.contains('pagination-container-fluid');
+ const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar');
const isMessages = w.classList.contains('messages-convo-page');
- const prop = (isPagination || isMessages) ? 'right' : 'padding-right';
+ const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right';
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
w.style.setProperty('transition', `${prop} 0.3s ease-in-out`, 'important');
@@ -11166,6 +11188,82 @@ document.addEventListener('DOMContentLoaded', () => {
const reportUserInput = document.getElementById('report-user-id');
const reportReason = document.getElementById('report-reason');
const reportError = document.getElementById('report-error');
+ let _reportRcWidgetId = null;
+
+ const showReportError = (msg, targetEl) => {
+ if (!reportError) return;
+ reportError.innerHTML = '';
+ const icon = document.createElement('i');
+ icon.className = 'fa fa-circle-exclamation';
+ icon.style.marginRight = '6px';
+ reportError.appendChild(icon);
+ reportError.appendChild(document.createTextNode(' ' + msg));
+ reportError.style.display = 'block';
+
+ if (targetEl) {
+ targetEl.classList.remove('field-error');
+ void targetEl.offsetWidth; // Force reflow to re-trigger CSS shake animation
+ targetEl.classList.add('field-error');
+ if (typeof targetEl.focus === 'function') {
+ try { targetEl.focus(); } catch (e) {}
+ }
+ }
+ };
+
+ const clearReportError = () => {
+ if (reportError) {
+ reportError.innerHTML = '';
+ reportError.style.display = 'none';
+ }
+ if (reportReason) reportReason.classList.remove('field-error');
+ const rcEl = document.getElementById('modal-report-recaptcha');
+ if (rcEl) rcEl.classList.remove('field-error');
+ };
+
+ if (reportReason) {
+ reportReason.addEventListener('input', () => {
+ if (reportReason.value.trim()) {
+ reportReason.classList.remove('field-error');
+ const rcEl = document.getElementById('modal-report-recaptcha');
+ if (!rcEl || !rcEl.classList.contains('field-error')) {
+ clearReportError();
+ }
+ }
+ });
+ reportReason.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ e.preventDefault();
+ const submitBtn = document.getElementById('report-submit');
+ if (submitBtn && !submitBtn.disabled) submitBtn.click();
+ }
+ });
+ }
+
+ window.renderReportRecaptcha = function() {
+ const rcEl = document.getElementById('modal-report-recaptcha');
+ if (!rcEl || !window.grecaptcha || typeof grecaptcha.render !== 'function') return;
+ if (_reportRcWidgetId !== null) {
+ try { grecaptcha.reset(_reportRcWidgetId); } catch(e) {}
+ } else {
+ const sitekey = rcEl.dataset.sitekey || (window.f0ckConfig && window.f0ckConfig.recaptcha_site_key);
+ if (sitekey) {
+ try {
+ _reportRcWidgetId = grecaptcha.render(rcEl, {
+ sitekey: sitekey,
+ theme: 'dark',
+ callback: function() {
+ rcEl.classList.remove('field-error');
+ if (!reportReason || !reportReason.classList.contains('field-error')) {
+ clearReportError();
+ }
+ }
+ });
+ } catch(e) {
+ console.warn('[REPORT] Failed to render recaptcha:', e);
+ }
+ }
+ }
+ };
// Open item report
document.addEventListener('click', (e) => {
@@ -11176,9 +11274,11 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = '';
reportUserInput.value = '';
reportReason.value = '';
- reportError.textContent = '';
+ clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
+ setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
+ if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
const commentBtn = e.target.closest('.report-comment-btn');
@@ -11188,9 +11288,11 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = commentBtn.dataset.id;
reportUserInput.value = '';
reportReason.value = '';
- reportError.textContent = '';
+ clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
+ setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
+ if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
const userBtn = e.target.closest('.report-user-btn'); // for future
@@ -11200,32 +11302,58 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = '';
reportUserInput.value = userBtn.dataset.userId;
reportReason.value = '';
- reportError.textContent = '';
+ clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
+ setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
+ if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
// Close logic
if (e.target.matches('#report-cancel') || e.target.id === 'report-modal') {
reportModal.style.display = 'none';
document.body.classList.remove('modal-open');
+ clearReportError();
+ if (_reportRcWidgetId !== null && window.grecaptcha) {
+ try { grecaptcha.reset(_reportRcWidgetId); } catch(e) {}
+ }
}
// Submit logic
if (e.target.matches('#report-submit')) {
+ clearReportError();
const reason = reportReason.value.trim();
if (!reason) {
- reportError.textContent = (window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please provide a reason.';
+ showReportError((window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please provide a reason.', reportReason);
return;
}
+ const rcEl = document.getElementById('modal-report-recaptcha');
+ let rcToken = '';
+ if (rcEl) {
+ if (_reportRcWidgetId === null || !window.grecaptcha) {
+ showReportError((window.f0ckI18n && window.f0ckI18n.captcha_loading) || 'CAPTCHA is still loading. Please wait a moment.', rcEl);
+ return;
+ }
+ rcToken = grecaptcha.getResponse(_reportRcWidgetId);
+ if (!rcToken) {
+ showReportError((window.f0ckI18n && window.f0ckI18n.captcha_required) || 'Please complete the CAPTCHA.', rcEl);
+ return;
+ }
+ }
+
const payload = new URLSearchParams();
if (reportItemInput.value) payload.append('item_id', reportItemInput.value);
if (reportCommentInput.value) payload.append('comment_id', reportCommentInput.value);
if (reportUserInput.value) payload.append('reported_user_id', reportUserInput.value);
payload.append('reason', reason);
+ if (rcToken) payload.append('g-recaptcha-response', rcToken);
+
+ const submitBtn = e.target;
+ const originalBtnText = submitBtn.innerHTML;
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = '' + ((window.f0ckI18n && window.f0ckI18n.processing) || 'Processing...');
- e.target.disabled = true;
fetch('/api/v2/report', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -11235,20 +11363,28 @@ document.addEventListener('DOMContentLoaded', () => {
.then(data => {
if (data.success) {
reportModal.style.display = 'none';
+ document.body.classList.remove('modal-open');
+ clearReportError();
if (window.showFlash) window.showFlash((window.f0ckI18n && window.f0ckI18n.report_success) || 'Report submitted successfully.', 'success');
// Reset fields for future reports
reportReason.value = '';
} else {
- reportError.textContent = data.msg || (window.f0ckI18n && window.f0ckI18n.report_error) || 'An error occurred.';
- if (window.showFlash) window.showFlash(data.msg || (window.f0ckI18n && window.f0ckI18n.report_error) || 'An error occurred.', 'error');
+ const errMsg = data.msg || (window.f0ckI18n && window.f0ckI18n.report_error) || 'An error occurred.';
+ showReportError(errMsg);
+ if (window.showFlash) window.showFlash(errMsg, 'error');
}
})
.catch(err => {
- reportError.textContent = (window.f0ckI18n && window.f0ckI18n.network_error) || 'Network error.';
- if (window.showFlash) window.showFlash((window.f0ckI18n && window.f0ckI18n.network_error) || 'Network error.', 'error');
+ const errMsg = (window.f0ckI18n && window.f0ckI18n.network_error) || 'Network error.';
+ showReportError(errMsg);
+ if (window.showFlash) window.showFlash(errMsg, 'error');
})
.finally(() => {
- e.target.disabled = false;
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = originalBtnText;
+ if (_reportRcWidgetId !== null && window.grecaptcha) {
+ try { grecaptcha.reset(_reportRcWidgetId); } catch(e) {}
+ }
});
}
});
@@ -12585,9 +12721,9 @@ document.addEventListener('click', (e) => {
visSaveBtn.textContent = origText;
if (data.success) {
const visVal = parseInt(data.visibility, 10);
- const titles = ['Public', 'Unlisted', 'Private'];
- const icons = ['fa-globe', 'fa-link', 'fa-lock'];
- const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)'];
+ const titles = ['Public', 'Unlisted', 'Private', 'Unavailable'];
+ const icons = ['fa-globe', 'fa-link', 'fa-lock', 'fa-ban'];
+ const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)', 'var(--color-danger, #ff4444)'];
const visBtn = document.getElementById('a_visibility');
if (visBtn) {
@@ -12595,6 +12731,14 @@ document.addEventListener('click', (e) => {
visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`);
}
+ const unavBtn = document.getElementById('a_unavailable');
+ if (unavBtn) {
+ unavBtn.dataset.visibility = visVal;
+ unavBtn.classList.toggle('active', visVal === 3);
+ unavBtn.style.color = visVal === 3 ? 'var(--danger, #ff4444)' : '';
+ unavBtn.setAttribute('title', visVal === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)');
+ }
+
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
if (infoVisBtn) {
infoVisBtn.dataset.visibility = visVal;
@@ -12627,8 +12771,9 @@ document.addEventListener('click', (e) => {
const saveBtn = e.target.closest('#info-title-save');
if (saveBtn) {
e.preventDefault();
- const input = document.getElementById('info-title-input');
- const status = document.getElementById('info-title-status');
+ const container = saveBtn.closest('.item_title') || document;
+ const input = container.querySelector('#info-title-input') || document.getElementById('info-title-input');
+ const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status');
if (!input) return;
const itemId = input.dataset.itemId;
@@ -12652,25 +12797,27 @@ document.addEventListener('click', (e) => {
saveBtn.disabled = false;
saveBtn.innerHTML = origIcon;
if (data.success) {
- // Update the live .item_title bar below the ID bar
- const titleBar = document.querySelector('.item_title');
- if (titleBar) {
- titleBar.textContent = data.title || '';
- } else if (data.title) {
- // Fallback: create it if somehow missing (e.g. enable_item_title was toggled)
- const idBar = document.querySelector('.item-main-content > ._204863');
- if (idBar) {
- const newBar = document.createElement('div');
- newBar.className = 'item_title';
- newBar.textContent = data.title;
- idBar.insertAdjacentElement('afterend', newBar);
- }
+ // Keep input form intact and update its value
+ input.value = data.title || '';
+
+ // Update any plain-text title display element
+ const textEl = container.querySelector('.item_title_text') || document.querySelector('.item_title_text');
+ if (textEl) {
+ textEl.textContent = data.title || '';
}
+
+ // Invalidate cache so navigating next/prev fetches fresh HTML from server
+ if (window.invalidateItemCache) {
+ window.invalidateItemCache(itemId);
+ }
+
if (status) {
- status.textContent = '✓ Saved';
- status.style.color = 'var(--accent, #5cb85c)';
- status.style.display = 'inline';
- setTimeout(() => { status.style.display = 'none'; }, 2000);
+ status.style.display = 'none';
+ status.textContent = '';
+ }
+
+ if (window.flashMessage) {
+ window.flashMessage('Title saved', 2000, 'success');
}
} else {
if (status) {
@@ -12678,6 +12825,9 @@ document.addEventListener('click', (e) => {
status.style.color = '#e84040';
status.style.display = 'inline';
}
+ if (window.flashMessage) {
+ window.flashMessage('Error while saving Title', 3000, 'error');
+ }
}
})
.catch(() => {
@@ -12688,6 +12838,9 @@ document.addEventListener('click', (e) => {
status.style.color = '#e84040';
status.style.display = 'inline';
}
+ if (window.flashMessage) {
+ window.flashMessage('Error while saving Title', 3000, 'error');
+ }
});
return;
}
@@ -12823,6 +12976,16 @@ document.addEventListener('click', (e) => {
}
});
+ // Handle Enter key on title input
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && e.target && e.target.id === 'info-title-input') {
+ e.preventDefault();
+ const container = e.target.closest('.item_title') || document;
+ const saveBtn = container.querySelector('#info-title-save') || document.getElementById('info-title-save');
+ if (saveBtn) saveBtn.click();
+ }
+ });
+
// Ensure any navigation event restores the scroll state
window.addEventListener('pjax:start', () => {
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
diff --git a/public/s/js/user_comments.js b/public/s/js/user_comments.js
index b724819..c18d278 100644
--- a/public/s/js/user_comments.js
+++ b/public/s/js/user_comments.js
@@ -419,7 +419,7 @@ if (!window.UserCommentSystem) {
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
- return ``;
+ return ``;
}
startLiveTimestamps() {
diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs
index 6d41ec8..406b86c 100644
--- a/src/inc/lib.mjs
+++ b/src/inc/lib.mjs
@@ -518,5 +518,15 @@ export default new class {
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
+
+ isLocalhostRequest(req) {
+ if (!req) return false;
+ const rawHost = req.headers?.['x-forwarded-host'] || req.headers?.['host'] || req.headers?.['x-forwarded-server'] || '';
+ if (!rawHost) return false;
+ const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
+ const firstHost = hostStr.split(',')[0].trim();
+ const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
+ return hostNoPort === 'localhost' || hostNoPort === '127.0.0.1' || hostNoPort === '::1';
+ }
};
diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json
index b568cfe..ffa2573 100644
--- a/src/inc/locales/de.json
+++ b/src/inc/locales/de.json
@@ -643,6 +643,8 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
+ "captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
+ "captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Wird verarbeitet...",
"yes": "Ja",
"no": "Nein",
diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json
index b3144d0..9853d3e 100644
--- a/src/inc/locales/en.json
+++ b/src/inc/locales/en.json
@@ -647,6 +647,8 @@
"reason_required": "Reason is required.",
"reason_optional": "Reason (optional)",
"reason_required_label": "Reason (required)",
+ "captcha_required": "Please complete the CAPTCHA.",
+ "captcha_loading": "CAPTCHA is still loading. Please wait a moment.",
"processing": "Processing...",
"yes": "Yes",
"no": "No",
diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json
index 41c0468..2878fdf 100644
--- a/src/inc/locales/nl.json
+++ b/src/inc/locales/nl.json
@@ -641,6 +641,8 @@
"reason_required": "Reden is vereist.",
"reason_optional": "Reden (optioneel)",
"reason_required_label": "Reden (vereist)",
+ "captcha_required": "Vul alstublieft de CAPTCHA in.",
+ "captcha_loading": "CAPTCHA is nog aan het laden. Even geduld a.u.b.",
"processing": "Verwerken...",
"yes": "Ja",
"no": "Nee",
diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json
index ae946db..1e7fd2c 100644
--- a/src/inc/locales/zange.json
+++ b/src/inc/locales/zange.json
@@ -642,6 +642,8 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
+ "captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
+ "captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Verarbeitung wird durchgeführt...",
"yes": "Ja",
"no": "Nein",
diff --git a/src/inc/private_items.mjs b/src/inc/private_items.mjs
index e41a457..a673874 100644
--- a/src/inc/private_items.mjs
+++ b/src/inc/private_items.mjs
@@ -3,32 +3,44 @@ import db from "./sql.mjs";
// Maps for fast O(1) in-memory lookups:
// dest (string) -> owner username (lowercase string)
const _privateDests = new Map();
-// id (number) -> owner username (lowercase string)
const _privateIds = new Map();
+// Unavailable items (visibility === 3, serves HTTP 451 for non-logged in users):
+const _unavailableDests = new Map();
+const _unavailableIds = new Map();
+
let _initialized = false;
let _initPromise = null;
/**
- * Load all active private items into memory cache.
+ * Load all active private and unavailable items into memory cache.
*/
export async function initPrivateItems() {
try {
const rows = await db`
- SELECT id, dest, LOWER(username) as username
+ SELECT id, dest, LOWER(username) as username, visibility
FROM items
- WHERE visibility = 2 AND is_deleted = false
+ WHERE visibility IN (2, 3) AND is_deleted = false
`;
_privateDests.clear();
_privateIds.clear();
+ _unavailableDests.clear();
+ _unavailableIds.clear();
+
for (const r of rows) {
- if (r.dest) _privateDests.set(r.dest, r.username || '');
- if (r.id) _privateIds.set(Number(r.id), r.username || '');
+ const u = r.username || '';
+ if (r.visibility === 2) {
+ if (r.dest) _privateDests.set(r.dest, u);
+ if (r.id) _privateIds.set(Number(r.id), u);
+ } else if (r.visibility === 3) {
+ if (r.dest) _unavailableDests.set(r.dest, u);
+ if (r.id) _unavailableIds.set(Number(r.id), u);
+ }
}
_initialized = true;
- console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) into memory cache`);
+ console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) and ${_unavailableDests.size} unavailable item(s) into memory cache`);
} catch (err) {
- console.error('[BOOT] Failed to load private items into cache:', err.message);
+ console.error('[BOOT] Failed to load private/unavailable items into cache:', err.message);
}
}
@@ -48,6 +60,8 @@ export function addPrivateItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _privateDests.set(dest, u);
if (id) _privateIds.set(Number(id), u);
+ // Ensure not in unavailable
+ removeUnavailableItem(id, dest);
}
export function removePrivateItem(id, dest) {
@@ -55,9 +69,22 @@ export function removePrivateItem(id, dest) {
if (id) _privateIds.delete(Number(id));
}
+export function addUnavailableItem(id, dest, username) {
+ const u = (username || '').toLowerCase();
+ if (dest) _unavailableDests.set(dest, u);
+ if (id) _unavailableIds.set(Number(id), u);
+ // Ensure not in private
+ removePrivateItem(id, dest);
+}
+
+export function removeUnavailableItem(id, dest) {
+ if (dest) _unavailableDests.delete(dest);
+ if (id) _unavailableIds.delete(Number(id));
+}
+
/**
- * Checks if a given pathname (/b/, /t/..., /ca/...) is a private item.
- * Returns { isPrivate: boolean, owner: string } or null if not private.
+ * Checks if a given pathname (/b/, /t/..., /ca/...) is a private or unavailable item.
+ * Returns { isPrivate: boolean, isUnavailable: boolean, owner: string } or null if normal public.
*/
export function getPrivateItemFromPath(pathname) {
if (!pathname || typeof pathname !== 'string') return null;
@@ -70,9 +97,13 @@ export function getPrivateItemFromPath(pathname) {
dest = pathname.slice(3);
}
dest = dest.split('?')[0].split('#')[0];
- const owner = _privateDests.get(dest);
- if (owner !== undefined) {
- return { isPrivate: true, owner };
+ const privOwner = _privateDests.get(dest);
+ if (privOwner !== undefined) {
+ return { isPrivate: true, isUnavailable: false, owner: privOwner };
+ }
+ const unavOwner = _unavailableDests.get(dest);
+ if (unavOwner !== undefined) {
+ return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
return null;
}
@@ -88,9 +119,13 @@ export function getPrivateItemFromPath(pathname) {
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
- const owner = _privateIds.get(id);
- if (owner !== undefined) {
- return { isPrivate: true, owner };
+ const privOwner = _privateIds.get(id);
+ if (privOwner !== undefined) {
+ return { isPrivate: true, isUnavailable: false, owner: privOwner };
+ }
+ const unavOwner = _unavailableIds.get(id);
+ if (unavOwner !== undefined) {
+ return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
@@ -107,9 +142,13 @@ export function getPrivateItemFromPath(pathname) {
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
- const owner = _privateIds.get(id);
- if (owner !== undefined) {
- return { isPrivate: true, owner };
+ const privOwner = _privateIds.get(id);
+ if (privOwner !== undefined) {
+ return { isPrivate: true, isUnavailable: false, owner: privOwner };
+ }
+ const unavOwner = _unavailableIds.get(id);
+ if (unavOwner !== undefined) {
+ return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
@@ -141,3 +180,29 @@ export function render502(req, res) {
}).end(body);
}
}
+
+/**
+ * Render HTTP 451 Unavailable For Legal Reasons response.
+ */
+export function render451(req, res) {
+ if (req.headers && (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.headers.accept?.includes('application/json'))) {
+ res.writeHead(451, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
+ }).end(JSON.stringify({ success: false, is_unavailable: true, msg: '451 - Unavailable For Legal Reasons' }));
+ } else {
+ const body = `\r
+451 Unavailable For Legal Reasons\r
+\r
+451 Unavailable For Legal Reasons
\r
+
nginx\r
+\r
+\r
+`;
+ res.writeHead(451, {
+ 'Content-Type': 'text/html',
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
+ }).end(body);
+ }
+}
+
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index 5005265..3f58e1a 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -371,11 +371,12 @@ const buildFeedFilters = async ({
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
}
+ const isAdmin = !!session?.admin;
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
- const visibilityFilter = isOwnerOrAdmin
+ const visibilityFilter = isAdmin
? db``
: (session && session.user
- ? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
+ ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return {
@@ -881,10 +882,11 @@ export default {
// Helper to construct shared filter conditions
const buildConditions = () => {
- const visibilityFilter = isOwnerOrAdmin
+ const isAdmin = !!session?.admin;
+ const visibilityFilter = isAdmin
? db``
: (session && session.user
- ? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
+ ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return db`
@@ -964,9 +966,18 @@ export default {
};
}
- // If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted):
+ // If item is Unavailable (visibility === 3):
+ // Only viewable by admins, not regular users or mods (renders normal post not found)
+ if (actitem.visibility === 3 && !session?.admin) {
+ return {
+ success: false,
+ message: "404 - upload not found"
+ };
+ }
+
+ // If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
// Block numeric enumeration unless viewer is owner/admin
- if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
+ if (isNumeric && (actitem.visibility === 1 || actitem.visibility === 2) && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs
index 1ccc2c8..1f30c80 100644
--- a/src/inc/routes/ajax.mjs
+++ b/src/inc/routes/ajax.mjs
@@ -137,6 +137,25 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
+ // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
+ if (cfg.main.guest_anonymize && !req.session) {
+ if (item.src) item.src = null;
+ item.username = 'anonymous';
+ item.author_banner_file = null;
+ item.author_banner_position = null;
+ item.author_banner_size = null;
+ item.author_avatar = null;
+ item.author_avatar_file = null;
+ item.author_color = null;
+ item.author_description = null;
+ item.author_display_name = null;
+ item.author_id = null;
+ if (data.uploader) {
+ data.uploader.name = 'anonymous';
+ data.uploader.id = null;
+ data.uploader.color = null;
+ }
+ }
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs
index c46d434..5777e54 100644
--- a/src/inc/routes/apiv2/index.mjs
+++ b/src/inc/routes/apiv2/index.mjs
@@ -11,7 +11,7 @@ import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
-import { addPrivateItem, removePrivateItem } from '../../private_items.mjs';
+import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => {
@@ -1067,7 +1067,11 @@ export default router => {
if (!isOwner && !isMod) return res.json({ success: false, msg: 'Forbidden' }, 403);
// Accept title from JSON or URL-encoded body
- let rawTitle = req.post?.title ?? req.body?.title ?? null;
+ let body = req.post || req.body || {};
+ if (typeof body === 'string') {
+ try { body = JSON.parse(body); } catch (_) {}
+ }
+ let rawTitle = body?.title ?? null;
if (rawTitle !== null) rawTitle = String(rawTitle).trim();
// Empty string → null (clears the title)
const title = (rawTitle === '' || rawTitle === null) ? null : rawTitle.substring(0, 500);
@@ -1317,7 +1321,7 @@ export default router => {
}
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
- if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
+ if (!postid || isNaN(visibility) || ![0, 1, 2, 3].includes(visibility)) {
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
}
@@ -1340,12 +1344,21 @@ export default router => {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
+ if (visibility === 3 && !isAdmin) {
+ return res.json({ success: false, msg: 'Only moderators or administrators can make an item unavailable' }, 403);
+ }
+
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
if (visibility === 2) {
addPrivateItem(item[0].id, item[0].dest, item[0].username);
+ removeUnavailableItem(item[0].id, item[0].dest);
+ } else if (visibility === 3) {
+ addUnavailableItem(item[0].id, item[0].dest, item[0].username);
+ removePrivateItem(item[0].id, item[0].dest);
} else {
removePrivateItem(item[0].id, item[0].dest);
+ removeUnavailableItem(item[0].id, item[0].dest);
}
f0cklib.clearCountCache();
diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs
index 53189e5..a7d91a4 100644
--- a/src/inc/routes/index.mjs
+++ b/src/inc/routes/index.mjs
@@ -398,9 +398,24 @@ export default (router, tpl) => {
// Hall columns for display
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
- // When guest anonymization is active, suppress source URL from the info modal
- if (cfg.main.guest_anonymize && !req.session && item.src) {
- item.src = null;
+ // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
+ if (cfg.main.guest_anonymize && !req.session) {
+ if (item.src) item.src = null;
+ item.username = 'anonymous';
+ item.author_banner_file = null;
+ item.author_banner_position = null;
+ item.author_banner_size = null;
+ item.author_avatar = null;
+ item.author_avatar_file = null;
+ item.author_color = null;
+ item.author_description = null;
+ item.author_display_name = null;
+ item.author_id = null;
+ if (data.uploader) {
+ data.uploader.name = 'anonymous';
+ data.uploader.id = null;
+ data.uploader.color = null;
+ }
}
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
diff --git a/src/inc/routes/reports.mjs b/src/inc/routes/reports.mjs
index 7bc66a4..c55ea40 100644
--- a/src/inc/routes/reports.mjs
+++ b/src/inc/routes/reports.mjs
@@ -1,11 +1,13 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
+import cfg from "../config.mjs";
+import security from "../security.mjs";
export default (router, tpl) => {
- // User: Submit a new report
- router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
+ // Submit a new report (Users & Guests)
+ router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
@@ -18,10 +20,40 @@ export default (router, tpl) => {
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
}
+ const ip = security.getRealIP(req);
+ const isGuest = !req.session;
+
+ if (isGuest) {
+ // CAPTCHA verification for guest reports (bypassed for .onion and localhost)
+ const isOnion = lib.isOnionRequest(req);
+ const isLocalhost = lib.isLocalhostRequest(req);
+ if (!isOnion && !isLocalhost && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
+ const rcToken = req.post['g-recaptcha-response'];
+ if (!rcToken) {
+ return res.json({ success: false, msg: "Please complete the CAPTCHA." }, 400);
+ }
+ try {
+ const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
+ });
+ const { success } = await verifyRes.json();
+ if (!success) {
+ return res.json({ success: false, msg: "CAPTCHA verification failed. Please try again." }, 400);
+ }
+ } catch (e) {
+ console.error('[REPORT] reCAPTCHA error:', e.message);
+ return res.json({ success: false, msg: "CAPTCHA verification error. Please try again." }, 500);
+ }
+ }
+ }
+
const reportRes = await db`
- INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
+ INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason)
VALUES (
- ${req.session.id},
+ ${req.session ? req.session.id : null},
+ ${ip},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
@@ -86,7 +118,8 @@ export default (router, tpl) => {
c.content AS comment_body,
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
- COALESCE(i.mime, ci.mime) AS resolved_item_mime
+ COALESCE(i.mime, ci.mime) AS resolved_item_mime,
+ COALESCE(i.visibility, ci.visibility, 0) AS resolved_item_visibility
FROM reports r
LEFT JOIN "user" rep ON r.reporter_id = rep.id
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs
index e8b0833..de6b934 100644
--- a/src/inc/routes/user_halls.mjs
+++ b/src/inc/routes/user_halls.mjs
@@ -185,9 +185,24 @@ export default (router, tpl) => {
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
- // When guest anonymization is active, suppress source URL from the info modal
- if (cfg.main.guest_anonymize && !req.session && item.src) {
- item.src = null;
+ // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
+ if (cfg.main.guest_anonymize && !req.session) {
+ if (item.src) item.src = null;
+ item.username = 'anonymous';
+ item.author_banner_file = null;
+ item.author_banner_position = null;
+ item.author_banner_size = null;
+ item.author_avatar = null;
+ item.author_avatar_file = null;
+ item.author_color = null;
+ item.author_description = null;
+ item.author_display_name = null;
+ item.author_id = null;
+ if (data.uploader) {
+ data.uploader.name = 'anonymous';
+ data.uploader.id = null;
+ data.uploader.color = null;
+ }
}
}
diff --git a/src/index.mjs b/src/index.mjs
index 3960115..e7ec0a5 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -26,7 +26,7 @@ import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
-import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502 } from "./inc/private_items.mjs";
+import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -87,7 +87,7 @@ const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.si
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
function getGateLoginInjection(req) {
- const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
+ const rcEnabled = _rcEnabled && !lib.isOnionRequest(req) && !lib.isLocalhostRequest(req);
return `
@@ -726,6 +726,41 @@ process.on('uncaughtException', err => {
if (req.url.pathname.match(/^\/(b|c|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
const privItem = getPrivateItemFromPath(req.url.pathname);
if (privItem) {
+ if (privItem.isUnavailable) {
+ // Unavailable item (visibility === 3):
+ // Direct URLs MUST serve 451 when requested by non-admins (or without a session)
+ let isAdmin = false;
+ if (req.cookies?.session) {
+ const _sessionHash = lib.sha256(req.cookies.session);
+ let user = _scGet(_sessionHash);
+ if (!user) {
+ const urows = await db`
+ select "user".id, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires
+ from "user_sessions"
+ left join "user" on "user".id = "user_sessions".user_id
+ where "user_sessions".session = ${_sessionHash}
+ limit 1
+ `;
+ if (urows.length > 0) {
+ user = urows[0];
+ _scSet(_sessionHash, user);
+ }
+ }
+ if (user && !user.banned && user.admin) {
+ isAdmin = true;
+ }
+ }
+
+ if (!isAdmin) {
+ render451(req, res);
+ req.url.pathname = '/unavailable_item_bypass';
+ return;
+ }
+
+ res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
+ return;
+ }
+
// Private item (visibility === 2):
// Direct URLs MUST serve 502 when requested without a session (or by unauthorized users),
// regardless of the protect_files setting.
@@ -1693,7 +1728,7 @@ process.on('uncaughtException', err => {
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
let perRequestRecaptcha = defaultRecaptcha;
- if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
+ if (effectiveReq && (lib.isOnionRequest(effectiveReq) || lib.isLocalhostRequest(effectiveReq))) {
perRequestRecaptcha = false;
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
perRequestRecaptcha = data.recaptcha_enabled;
diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html
index da1f09f..4e7ec3f 100644
--- a/views/item-partial-legacy.html
+++ b/views/item-partial-legacy.html
@@ -10,8 +10,8 @@
@if(enable_item_title)
-
- {!! item.title || '' !!}
+
+
{!! item.title || '' !!}
@if(can_manage_item)
@@ -93,9 +93,10 @@
@if(user_alternative_infobox)
-
+
- {!! item.author_description || '' !!}
+ @if(session || !guest_anonymize){!! item.author_description || '' !!}@endif
@if(session)
@@ -177,10 +183,12 @@
@endif
@if(is_mod_or_admin)
+
@endif
@else
+
@endif
@@ -295,6 +303,15 @@
Only visible to you and site moderators.
+ @if(is_mod_or_admin)
+
+ @endif
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html
index 7b59258..f780d0e 100644
--- a/views/item-partial-modern.html
+++ b/views/item-partial-modern.html
@@ -74,8 +74,8 @@
@if(enable_item_title)
-
- {!! item.title || '' !!}
+
+
{!! item.title || '' !!}
@if(can_manage_item)
@@ -173,10 +173,12 @@
@endif
@if(is_mod_or_admin)
+
@endif
@else
+
@endif
@@ -235,6 +237,15 @@
Only visible to you and site moderators.
+ @if(is_mod_or_admin)
+
+ @endif
diff --git a/views/mod_reports.html b/views/mod_reports.html
index 048135d..e47b557 100644
--- a/views/mod_reports.html
+++ b/views/mod_reports.html
@@ -136,10 +136,17 @@ window.loadReports = async function(page = 1) {
'
';
}
+ let reporterHtml = '';
+ if (r.reporter_name) {
+ reporterHtml = '
' + r.reporter_name + '' + (r.reporter_ip ? '
(' + r.reporter_ip + ')' : '');
+ } else {
+ reporterHtml = '
Guest' + (r.reporter_ip ? ' (' + r.reporter_ip + ')' : '') + '';
+ }
+
const tr = document.createElement('tr');
tr.innerHTML =
'
' + r.id + ' | ' +
- '
' + r.reporter_name + ' | ' +
+ '
' + reporterHtml + ' | ' +
'
' + targetHtml + ' | ' +
'
' + r.reason + ' | ' +
'
' + new Date(r.created_at).toLocaleString() + ' | ' +
@@ -255,9 +262,11 @@ window.expandItem = function(e, id) {
}
let buttonsHtml = '';
- // Delete Video button only for direct Video reports
+ // Delete Item and Make Unavailable buttons for direct Item reports
if (isItem && !isComment) {
buttonsHtml += '
';
+ const isUnav = r.resolved_item_visibility === 3;
+ buttonsHtml += '
';
}
if (isComment) {
buttonsHtml += '
';
@@ -289,7 +298,7 @@ window.expandItem = function(e, id) {
'
Moderation Action:
' +
'
' +
buttonsHtml +
- '' +
+ (r.reporter_id && r.reporter_name ? '' : (r.reporter_ip ? 'Reporter: Guest (' + r.reporter_ip + ')' : '')) +
'
' +
'
' +
'
' +
@@ -335,6 +344,36 @@ window.adminDeleteItem = function(id) {
});
};
+window.modToggleUnavailable = function(id, currentVis) {
+ const willBeUnavailable = currentVis !== 3;
+ const targetVis = willBeUnavailable ? 3 : 0;
+ const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
+
+ window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => {
+ const params = new URLSearchParams();
+ params.append('postid', id);
+ params.append('id', id);
+ params.append('visibility', targetVis);
+ const res = await fetch('/api/v2/item/visibility', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': window.f0ckSession?.csrf_token
+ },
+ body: params
+ });
+ const data = await res.json();
+ if (data.success) {
+ if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success');
+ const item = window.currentReports.find(x => x.resolved_item_id === id);
+ if (item) item.resolved_item_visibility = targetVis;
+ window.loadReports(window.currentPage);
+ } else {
+ throw new Error(data.msg || 'Failed to update visibility');
+ }
+ });
+};
+
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, '', async (reason) => {
const params = new URLSearchParams();
diff --git a/views/snippets/footer.html b/views/snippets/footer.html
index 1b47560..893942e 100644
--- a/views/snippets/footer.html
+++ b/views/snippets/footer.html
@@ -74,6 +74,9 @@
+ @if(recaptcha_enabled && !session)
+
+ @endif
@@ -496,6 +499,8 @@
reason_required: "{{ t('toast.reason_required') }}",
reason_optional: "{{ t('toast.reason_optional') }}",
reason_required_label: "{{ t('toast.reason_required_label') }}",
+ captcha_required: "{{ t('toast.captcha_required') }}",
+ captcha_loading: "{{ t('toast.captcha_loading') }}",
confirm_yes: "{{ t('toast.yes') }}",
confirm_no: "{{ t('toast.no') }}",
confirm_btn: "{{ t('toast.confirm') }}",
diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html
index 6cc03d5..da5fdea 100644
--- a/views/snippets/navbar.html
+++ b/views/snippets/navbar.html
@@ -58,20 +58,6 @@
{{ t('nav.my_halls') }}
@endif
{{ t('nav.favs') }}
- @if(session.admin)
-
{{ t('nav.admin') }}
- @if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
-
- @endif
-
- @endif
- @if(session.admin || session.is_moderator)
-
{{ t('nav.mod') }}
- @if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
-
- @endif
-
- @endif
{{ t('nav.settings') }}
{{ t('nav.logout') }}
@@ -156,63 +142,70 @@
@if(session.show_motd !== false)true@else false @endif
- @if(session.admin || session._is_impersonating)
-
-
- @if(session._is_impersonating)
-
-
-
-
Viewing as {!! session.user !!}
-
— all actions are performed as this user
-
+
+
+@if(session.admin || session._is_impersonating)
+
+
+ @if(session._is_impersonating)
+
+
+
+ Viewing as {!! session.user !!}
+ — all actions are performed as this user
+
+
+
+ @else
+
+
+
+
+ Admin
- @else
-
-
-
-
- Admin
-
-
+
-
-
-
Preview:
-
-
-
-
-
+
+
+
Preview:
+
+
+
+
- @endif
+
+
@endif
-
-
+
+@endif
@if(session.admin)
@@ -380,6 +373,7 @@
@if(session && session.admin)
x{{ t('shortcuts.delete') }}
p{{ t('shortcuts.toggle_rating') }}
+
^ / ~Toggle Admin Bar
@endif
Space{{ t('shortcuts.play_pause') }}
← →{{ t('shortcuts.next_prev') }}
@@ -483,6 +477,7 @@
window.onRecaptchaModalReady = function() {
_rcLoaded = true;
renderModalRecaptcha();
+ if (typeof window.renderReportRecaptcha === 'function') window.renderReportRecaptcha();
};
document.addEventListener('DOMContentLoaded', function() {
@@ -506,23 +501,111 @@