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 `
${avatarHtml}
${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}
${timeAgo}
${content}
${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnonGuest ? null : comment.username)}
#${comment.id}
${repliesHtml}`; + return `
${avatarHtml}
${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}
${timeAgo}
${content}
${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnonGuest ? null : comment.username)}
#${comment.id}
${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 `
${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; + return `
${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; } 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 || '' !!} +
+ @if(can_manage_item)
@@ -93,9 +93,10 @@
@if(user_alternative_infobox) -
+
+ @if(session || !guest_anonymize) @if(item.author_avatar_file) @@ -105,6 +106,11 @@ @endif + @else + + + + @endif
@@ -119,7 +125,7 @@
- {!! 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 || '' !!} +
+ @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
- @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 - - +@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 @@