diff --git a/config_example.json b/config_example.json index c936a08..b701699 100644 --- a/config_example.json +++ b/config_example.json @@ -50,6 +50,7 @@ "rate_item": false, "filter": true, "exclude_tags": true, + "chan": false, "anonymize_users": false, "allowed_modes": [ "sfw", diff --git a/config_example.yaml b/config_example.yaml index 2fbfb97..98e9771 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -45,6 +45,7 @@ anonymous_permissions: rate_item: false filter: true exclude_tags: true + chan: false anonymize_users: false allowed_modes: - sfw diff --git a/docker-compose.yml b/docker-compose.yml index fc69eac..ba19ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: - ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z - ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z - ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z + - ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z environment: - GIT_HASH=${f0ckm_TAG:-unknown} diff --git a/package.json b/package.json index 22df826..a52bddc 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,14 @@ "main": "index.mjs", "type": "module", "scripts": { + "prestart": "node scripts/generate-config.mjs", "start": "node --trace-uncaught src/index.mjs", - "dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs", + "predev": "node scripts/generate-config.mjs", + "dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs", + "devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs", + "config:gen": "node scripts/generate-config.mjs", + "config:watch": "node scripts/generate-config.mjs --watch", + "config:to-yaml": "node scripts/generate-config.mjs --to-yaml", "trigger": "node debug/trigger.mjs", "autotagger": "node debug/autotagger.mjs", "thumbnailer": "node debug/thumbnailer.mjs", @@ -30,6 +36,7 @@ "jszip": "3.10.1", "marked": "18.0.2", "matrix-js-sdk": "^40.3.0-rc.0", - "postgres": "^3.3.4" + "postgres": "^3.3.4", + "yaml": "^2.9.1" } } diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 06237ce..9527ce7 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -3829,6 +3829,25 @@ body.sidebar-right-hidden #sidebar-drag-zone { margin-right: 8px; } +/* Sub-tab nav + background mode bar: scroll horizontally inside themselves instead of overflowing the sidebar */ +.f0ck-tuner-subtabs-nav, +.f0ck-tuner-mode-bar { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} +.f0ck-tuner-subtabs-nav::-webkit-scrollbar, +.f0ck-tuner-mode-bar::-webkit-scrollbar { height: 4px; } +.f0ck-tuner-subtabs-nav::-webkit-scrollbar-thumb, +.f0ck-tuner-mode-bar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); } +/* Buttons keep their natural width (grow to fill when there is room); mode-bar buttons set flex:1 inline */ +.f0ck-tuner-subtabs-nav > .f0ck-tuner-subtab-btn { flex: 1 0 auto; } +.f0ck-tuner-mode-bar > .f0ck-tuner-mode-btn { flex: 1 0 auto !important; } +.f0ck-tuner-header > .f0ck-tuner-close { flex-shrink: 0; } + .f0ck-tuner-subtab-btn, .f0ck-tuner-mode-btn { flex: 1; @@ -3958,6 +3977,112 @@ body.sidebar-right-hidden #sidebar-drag-zone { color: #fff; } +/* ── Tuner: style guide (square, flat, theme colours, no glow) + touch-friendly sliders ── */ +.f0ck-audio-tuner-panel, +.f0ck-audio-tuner-panel *, +.f0ck-audio-tuner-panel *::before, +.f0ck-audio-tuner-panel *::after { border-radius: 0 !important; } + +.f0ck-audio-tuner-panel:not(.in-sidebar) { + background: var(--bg, #111); + border: 1px solid var(--accent, #99ff00); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.f0ck-audio-tuner-panel { font-family: inherit; color: var(--text-color, var(--white, #e6e6e6)); } + +.f0ck-audio-tuner-panel .f0ck-tuner-header { border-bottom-color: rgba(255, 255, 255, 0.1); } +.f0ck-audio-tuner-panel .f0ck-tuner-section-title { + color: var(--text-muted, #8a8f98); + letter-spacing: 0.12em; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; align-items: center; gap: 6px; +} +.f0ck-audio-tuner-panel .f0ck-tuner-section-title::before { + content: ''; width: 3px; height: 10px; background: var(--accent, #99ff00); flex-shrink: 0; +} + +/* Sub-tabs + mode switch: segmented control, active = solid accent, no glow */ +.f0ck-audio-tuner-panel .f0ck-tuner-subtabs-nav, +.f0ck-audio-tuner-panel .f0ck-tuner-mode-bar { + background: rgba(0, 0, 0, 0.35) !important; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn, +.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn { color: var(--text-muted, #8e8e93); } +.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn:hover, +.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn:hover { color: var(--text-color, #fff); background: rgba(255, 255, 255, 0.06); } +.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn.active, +.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn.active { background: var(--accent, #99ff00); color: #000; box-shadow: none; } + +/* Panels / cards */ +.f0ck-audio-tuner-panel :is(.f0ck-tuner-presets-bar, .f0ck-danmaku-debug-card) { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Inputs & selects */ +.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select) { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--text-color, #fff); + min-height: 30px; +} +.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select):focus { + border-color: var(--accent, #99ff00); outline: none; +} + +/* Buttons: primary = solid accent (outline on hover), the rest outlined; no glows */ +.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary) { + background: var(--accent, #99ff00); border: 1px solid var(--accent, #99ff00); color: #000; filter: none; +} +.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary):hover { + background: transparent; color: var(--accent, #99ff00); filter: none; +} +.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default { background: transparent; border-color: var(--accent, #99ff00); } +.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default:hover { box-shadow: none; } +.f0ck-audio-tuner-panel :is(button, select, input):focus-visible { outline: 2px solid var(--accent, #99ff00); outline-offset: 1px; } + +/* Value readout next to each label */ +.f0ck-audio-tuner-panel .f0ck-tuner-row-label { color: var(--text-muted, #bbb); } +.f0ck-audio-tuner-panel .f0ck-tuner-row-label span:last-child { + color: var(--accent, #99ff00); font-variant-numeric: tabular-nums; +} + +/* Sliders: flat track, square accent thumb, generous hit area. + touch-action pan-y: vertical drags still scroll the panel, horizontal drags move the slider. */ +.f0ck-audio-tuner-panel input[type="range"] { + -webkit-appearance: none; appearance: none; + width: 100%; height: 22px; margin: 0; padding: 0; + background: transparent; cursor: pointer; touch-action: pan-y; display: block; +} +.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-runnable-track { + height: 4px; background: rgba(255, 255, 255, 0.15); border: 0; +} +.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 14px; height: 14px; margin-top: -5px; + background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45); + transition: transform 0.1s; +} +.f0ck-audio-tuner-panel input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); } +.f0ck-audio-tuner-panel input[type="range"]::-moz-range-track { height: 4px; background: rgba(255, 255, 255, 0.15); border: 0; } +.f0ck-audio-tuner-panel input[type="range"]::-moz-range-progress { height: 4px; background: var(--accent, #99ff00); } +.f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb { + width: 14px; height: 14px; background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45); +} +.f0ck-audio-tuner-panel input[type="range"]:focus { outline: none; } +.f0ck-audio-tuner-panel input[type="range"]:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--text-color, #fff); outline-offset: 1px; } + +/* Touch screens: bigger thumb and row so the slider is easy to grab */ +@media (pointer: coarse) { + .f0ck-audio-tuner-panel input[type="range"] { height: 34px; } + .f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; margin-top: -9px; } + .f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; } + .f0ck-audio-tuner-panel .f0ck-tuner-row { margin-bottom: 6px; } +} + .sidebar-video-details { display: flex; margin-top: 8px; @@ -5281,6 +5406,7 @@ body.layout-legacy .blahlol { background: var(--bg); border-left: 1px solid var(--nav-border-color); border-right: 1px solid var(--nav-border-color); + border-top: 1px solid var(--nav-border-color); } body.layout-legacy .comments-list:not(:has(.comment)) { diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js index e613618..39cd368 100644 --- a/public/s/js/anon_ssh.js +++ b/public/s/js/anon_ssh.js @@ -369,6 +369,55 @@ return finishData; } + // ── Add a passkey to the current anonymous identity ─────────────────── + + async addPasskey() { + if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.'); + + const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) + || document.querySelector('meta[name="csrf-token"]')?.content || ''; + const beginRes = await fetch('/api/v2/anon/passkey/add/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({}) + }); + const beginData = await beginRes.json(); + if (!beginData.success) throw new Error(beginData.msg || 'Server error'); + + const rawChallenge = beginData.options.challenge; + const opts = beginData.options; + + const cred = await navigator.credentials.create({ publicKey: { + rp: opts.rp, + user: { + id: b64urlToArr(opts.user.id), + name: opts.user.name, + displayName: opts.user.displayName + }, + challenge: b64urlToArr(rawChallenge), + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout || 60000, + excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })), + authenticatorSelection: opts.authenticatorSelection, + attestation: opts.attestation || 'none' + }}); + + const finishRes = await fetch('/api/v2/anon/passkey/add/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({ + challenge: rawChallenge, + credentialId: arrToB64url(cred.rawId), + clientDataJSON: arrToB64url(cred.response.clientDataJSON), + attestationObject: arrToB64url(cred.response.attestationObject), + hw_fingerprint: await getHardwareFingerprint() + }) + }); + const finishData = await finishRes.json(); + if (!finishData.success) throw new Error(finishData.msg || 'Registration failed'); + return finishData; + } + // ── Helpers ─────────────────────────────────────────────────────────────── _hasLocalPasskey() { @@ -490,12 +539,26 @@ const res = await fetch('/api/v2/anon/identity'); const data = await res.json(); const fpEl = document.getElementById('anon-pk-fp-display'); - const credEl = document.getElementById('anon-pk-cred-display'); if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none'; - if (credEl) credEl.textContent = data.credential_id || '—'; + this._renderPasskeyCount(data.passkey_count, data.passkey_max); } catch (e) {} } + _renderPasskeyCount(count, max) { + const countEl = document.getElementById('anon-pk-count'); + const maxEl = document.getElementById('anon-pk-max'); + const addBtn = document.getElementById('anon-pk-add-btn'); + if (countEl && typeof count === 'number') countEl.textContent = count; + if (maxEl && typeof max === 'number') maxEl.textContent = max; + if (addBtn && typeof count === 'number' && typeof max === 'number') { + const full = count >= max; + addBtn.disabled = full; + addBtn.innerHTML = full + ? ' Limit reached' + : ' Add passkey'; + } + } + // ── Init ────────────────────────────────────────────────────────────────── async init() { @@ -568,24 +631,27 @@ const modalOverlay = document.getElementById('anon-passkey-modal'); if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); }); - // Add-passkey button inside modal (for anonymous users to add a second passkey) + // Add-passkey button inside modal — attaches a new passkey to this identity (max enforced server-side) const addBtn = document.getElementById('anon-pk-add-btn'); if (addBtn) { addBtn.addEventListener('click', async () => { + const errEl = document.getElementById('anon-pk-error'); + if (errEl) errEl.style.display = 'none'; addBtn.disabled = true; try { - await this.register(); - if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!'); - this._refreshModalContent(); + const data = await this.addPasskey(); + if (typeof window.showToastNotification === 'function') window.showToastNotification('Passkey added'); + this._renderPasskeyCount(data.passkey_count, data.passkey_max); } catch (err) { console.warn('[ANON_PASSKEY] Modal add passkey error:', err); - if (err && err.name === 'NotAllowedError') { - alert('Passkey creation was cancelled or blocked. If Bitwarden or another password manager is locked, please unlock it.'); - } else { - alert('Failed to add passkey: ' + ((err && err.message) || 'Unknown error')); + if (errEl) { + errEl.style.display = ''; + errEl.textContent = (err && err.name === 'NotAllowedError') + ? 'Cancelled or blocked. Unlock your password manager and try again.' + : ((err && err.message) || 'Failed to add passkey.'); } - } finally { addBtn.disabled = false; + this._refreshModalContent(); } }); } diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 965bd73..be3c07d 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1,3 +1,12 @@ +// Pages whose content depends on the rating/mime filter and must reload when it changes. +// Profile pages count even when both previews are empty (then no .posts grid is rendered). +// Elements whose horizontal drags belong to them (sliders, sideways-scrolling rows): swipe gestures ignore touches starting here. +window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]'; + +window.isFilterReloadableView = function () { + return !!document.querySelector('.posts, .tags-grid') || /^\/user\/[^/]+\/?$/.test(window.location.pathname); +}; + // Normalize percent-encoded characters in the URL bar that are safe to show decoded. // Runs immediately so the address bar is clean before any other JS runs. @@ -1437,7 +1446,7 @@ window.cancelAnimFrame = (function () { window.flashMessage('ALL MODE ACTIVATED'); gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); - const isGridView = document.querySelector('.posts, .tags-grid'); + const isGridView = window.isFilterReloadableView(); const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname))); let reloadPromise = null; if (isOnaraOpen) { @@ -1501,7 +1510,7 @@ window.cancelAnimFrame = (function () { window.flashMessage(label); gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); - const isGridView = document.querySelector('.posts, .tags-grid'); + const isGridView = window.isFilterReloadableView(); const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname))); let reloadPromise = null; if (isOnaraOpen) { @@ -13173,7 +13182,7 @@ window.cancelAnimFrame = (function () { // Refresh content const isOnaraOpen = document.body.classList.contains('onara-modal-open'); - const isGridView = document.querySelector('.posts, .tags-grid'); + const isGridView = window.isFilterReloadableView(); const isItemView = document.getElementById('prev') || document.getElementById('next'); let reloadPromise = null; @@ -13789,6 +13798,7 @@ window.cancelAnimFrame = (function () { }, 50); const updateDomAfterSave = (resData) => { + const itemPath = (resData && resData.item_path) || itemId; try { const finalRating = resData.rating || selectedRating || 'untagged'; @@ -13838,11 +13848,11 @@ window.cancelAnimFrame = (function () { sep.textContent = '→'; const a = document.createElement('a'); - a.href = `/${itemId}`; + a.href = `/${itemPath}`; a.className = 'location-link is-rehosted'; - a.title = `View rehosted post #${itemId} on f0ckm`; + a.title = `View rehosted post /${itemPath} on f0ckm`; a.style.cssText = 'color: #4ade80; font-weight: 700;'; - a.innerHTML = ` #${itemId}`; + a.innerHTML = ` /${itemPath}`; locEl.appendChild(sep); locEl.appendChild(a); @@ -13860,8 +13870,8 @@ window.cancelAnimFrame = (function () { if (rehostBtn) { rehostBtn.outerHTML = ` - - #${itemId} + + /${itemPath} ${item.is_external ? ( item.local_id - ? ` + ? `
${_i.view_label || 'View'}
` @@ -1761,6 +1761,7 @@ const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`; const local_id = allRehosts[external_media_url] || null; + const local_path = (local_id && data.rehost_paths && data.rehost_paths[local_id]) || local_id; return { id: `${data.board}/${p.no}`, @@ -1770,6 +1771,7 @@ external_source: '4chan', is_external: true, local_id, + local_path, external_media_url, mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'), dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`, @@ -1972,7 +1974,7 @@ // Update button to link to the new site-internal post setTimeout(() => { btn.outerHTML = ` - +
View
@@ -1983,8 +1985,8 @@ if (slide) { const idLink = slide.querySelector('.scroll-id-link'); if (idLink) { - idLink.href = `/${data.item_id}`; - idLink.textContent = `#${data.item_id}`; + idLink.href = `/${data.item_path || data.item_id}`; + idLink.textContent = `/${data.item_path || data.item_id}`; } // Reflect rehoster's username and local timestamp if (window.scrollerUsername) { diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 6af553b..5ac084b 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -3401,7 +3401,7 @@ window.initUploadForm = (selector) => { } } - if (shouldRedirectToItem) { + if (shouldRedirectToItem && !lastData?.manual_approval) { const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1); let targetUrl; if (isMultiNonAlbumShitpost) { @@ -3416,7 +3416,7 @@ window.initUploadForm = (selector) => { window.location.href = targetUrl; } } - // else: stay on current page + // else: stay on current page (including manual_approval pending) } } else { restoreBtn(); diff --git a/public/s/js/user.js b/public/s/js/user.js index c70bdf4..3e48965 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -60,6 +60,9 @@ (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) ); + // Anonymous uploaders may change the rating of their own item without full manage rights + const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]'); + // Only remove existing dynamically generated tags [...inner.querySelectorAll(".badge")].forEach(tag => { // Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself @@ -100,7 +103,7 @@ span.classList.add('rating-tag', `is-${tag.normalized}`); span.dataset.rating = tag.normalized; if (activePostId) span.dataset.itemId = activePostId; - if (canManage) { + if (canRate) { span.classList.add('can-cycle'); } } else { @@ -144,7 +147,7 @@ const hasRating = !!lastRatingTag; if (!hasRating) { const untaggedSpan = document.createElement("span"); - untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`; + untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`; untaggedSpan.dataset.rating = 'untagged'; if (activePostId) untaggedSpan.dataset.itemId = activePostId; const lbl = document.createElement("span"); diff --git a/src/inc/chan_http.mjs b/src/inc/chan_http.mjs new file mode 100644 index 0000000..a037c5e --- /dev/null +++ b/src/inc/chan_http.mjs @@ -0,0 +1,137 @@ +/** + * chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads). + * + * Every call gets a randomized browser identity: + * - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random + * profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers, + * so we must NOT override the User-Agent (a mismatch would defeat the point). + * - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language. + * + * config: main.curl_impersonate + * (unset) / true → auto-detect wrappers on PATH + * false → never use curl-impersonate + * "" → look for wrappers in that directory (in addition to PATH) + */ +import fs from 'fs'; +import path from 'path'; +import cfg from './config.mjs'; + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + +// Browser majors derived from the date so the pool never goes stale. +// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks. +const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000)); + +const randomUserAgent = () => { + const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind + const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3); + const templates = [ + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`, + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`, + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`, + `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`, + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`, + `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`, + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`, + `Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0` + ]; + return pick(templates); +}; + +const ACCEPT_LANGUAGES = [ + 'en-US,en;q=0.9', + 'en-US,en;q=0.8', + 'en-GB,en;q=0.9,en-US;q=0.8', + 'en-US,en;q=0.9,de;q=0.8', + 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7' +]; + +// ── curl-impersonate detection (cached after first lookup) ────────────────── +// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android. +const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i; +// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl. +const PROFILES_PER_BROWSER = 3; +let _impersonateBins = null; + +const findImpersonateBins = () => { + if (_impersonateBins) return _impersonateBins; + const setting = cfg.main?.curl_impersonate; + if (setting === false) return (_impersonateBins = []); + + const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim()); + + const byBrowser = new Map(); // family → [{ version, full }] + const seen = new Set(); + for (const dir of dirs) { + let entries; + try { entries = fs.readdirSync(dir); } catch { continue; } + for (const name of entries) { + const m = name.match(IMPERSONATE_RE); + if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants + const full = path.join(dir, name); + try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; } + seen.add(name); + const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase(); + if (!byBrowser.has(family)) byBrowser.set(family, []); + byBrowser.get(family).push({ version: parseInt(m[2], 10), full }); + } + } + + // Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome + // (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient). + const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version)); + if (newestChrome && byBrowser.has('edge')) { + byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10)); + } + + _impersonateBins = []; + for (const list of byBrowser.values()) { + list.sort((a, b) => b.version - a.version); + _impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full)); + } + console.log(_impersonateBins.length + ? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})` + : '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent'); + return _impersonateBins; +}; + +// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros +// (openSUSE, Fedora, macOS) point it at the local bundle explicitly. +const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt'; +const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem']; +let _caArgs = null; +const caArgs = () => { + if (_caArgs) return _caArgs; + if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []); + const found = CA_FALLBACKS.find(f => fs.existsSync(f)); + return (_caArgs = found ? ['--cacert', found] : []); +}; + +const socksArgs = () => { + const socks = cfg.main?.socks; + if (!socks || socks === 'undefined') return []; + const host = socks.includes('://') ? socks.split('://')[1] : socks; + return ['--socks5-hostname', host]; +}; + +/** + * Build a curl command for a 4chan URL with a randomized browser identity. + * @param {string} url + * @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300']) + * @returns {{ bin: string, args: string[] }} + */ +export const chanCurl = (url, extraArgs = []) => { + const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()]; + const impersonate = findImpersonateBins(); + if (impersonate.length) { + // Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint + return { bin: pick(impersonate), args: [...base, ...caArgs(), url] }; + } + return { + bin: 'curl', + args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url] + }; +}; + +export default { chanCurl }; diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index 71a16e9..b2f9ba6 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -4,7 +4,7 @@ import db from "./sql.mjs"; import cfg from "./config.mjs"; import { createI18n } from "./i18n.mjs"; -import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs"; +import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs"; @@ -552,7 +552,7 @@ export default new class { } return res.redirect('/login'); } - const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')); + const hasGroup = canUseChan(req.session); if (!hasGroup) { if (isApi) { return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' }); diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index e39d40a..9826c1d 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -1,7 +1,7 @@ import db from "../sql.mjs"; import lib from "../lib.mjs"; import cfg from "../config.mjs"; -import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs"; +import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession, getSessionOwnerName } from "../settings.mjs"; import { updateHallsCache } from "../halls_cache.mjs"; import queue from "../queue.mjs"; import fs from "fs"; @@ -129,6 +129,13 @@ const computeBaseMode = (mode, ratings, session) => { if (!allowedModes.includes('sfw')) { baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`; } + // Uploaders always see their own untagged items, even when 'untagged' isn't an allowed anon mode. + // The name is interpolated into raw SQL, so only accept the plain shadow-login charset. + // Only `items.id` may reference the outer row: some callers alias items (e.g. comments.mjs rewrites items.id → i.id). + const ownerName = getSessionOwnerName(session); + if (!allowedModes.includes('untagged') && ownerName && /^[a-z0-9_]+$/i.test(ownerName)) { + baseMode = `((${baseMode}) or items.id in (select own.id from items own where lower(own.username) = '${ownerName.toLowerCase()}' and not exists (select 1 from tags_assign ota where ota.item_id = own.id and ota.tag_id in (1, 2, ${nsflId}))))`; + } } return baseMode; }; @@ -477,11 +484,11 @@ const buildFeedFilters = async ({ } 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 ownerName = getSessionOwnerName(session); const visibilityFilter = isAdmin ? db`` - : (session && session.user - ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))` + : (ownerName + ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))` : db`and coalesce(items.visibility, 0) = 0`); return { @@ -1217,10 +1224,11 @@ const f0cklib = { // Helper to construct shared filter conditions const buildConditions = () => { const isAdmin = !!session?.admin; + const ownerName = getSessionOwnerName(session); const visibilityFilter = isAdmin ? db`` - : (session && session.user - ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))` + : (ownerName + ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))` : db`and coalesce(items.visibility, 0) = 0`); return db` @@ -1284,8 +1292,9 @@ const f0cklib = { const itemid = actitem.id; // Check visibility permissions: + const ownerName = getSessionOwnerName(session); const isOwnerOrAdmin = session && ( - (session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) || + (ownerName && ownerName.toLowerCase() === (actitem.username || '').toLowerCase()) || session.admin || session.is_moderator ); diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 471afc9..8b1f5db 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -286,8 +286,27 @@ export default (router, tpl) => { router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage + // Dashboard counters (cheap aggregate queries; failures just show 0) + const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 }; + try { + const [[r], [u], [t]] = await Promise.all([ + db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`, + db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon + FROM "user"`, + db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false` + ]); + dash.trash = t?.n || 0; + dash.open_reports = r?.n || 0; + dash.users = u?.users || 0; + dash.anon_users = u?.anon || 0; + } catch (e) { + console.error('[ADMIN] dashboard counters failed:', e.message); + } + res.reply({ body: tpl.render("admin", { + dash, totals: await lib.countf0cks(), session: req.session, manual_approval: getManualApproval(), diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 62cb3f6..b6c79b8 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -3,7 +3,7 @@ import lib from "../lib.mjs"; import url from "url"; import cfg from "../config.mjs"; import { createI18n } from "../i18n.mjs"; -import { isAnonymizeSession } from "../settings.mjs"; +import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs"; export default (router, tpl) => { // ── Merged random + item load: single request instead of two ──────────── @@ -177,6 +177,8 @@ export default (router, tpl) => { if (data.item) { const session = data.session; const item = data.item; + // Keep the real uploader for permission checks — anonymization below overwrites item.username + const _realUsername = item.username; if (isAnonymizeSession(req.session)) { if (item.src) item.src = null; item.username = 'anonymous'; @@ -205,6 +207,9 @@ export default (router, tpl) => { const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); + // Rating may also be changed by an anonymous uploader on their own item + const _ownerName = getSessionOwnerName(session); + data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.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]))); data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; @@ -229,13 +234,7 @@ export default (router, tpl) => { console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`); let itemPage = null; - const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - const cookieOnara = req.cookies?.f0ck_onara !== undefined - ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') - : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); - const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) - ? !!cfgOnara - : (query.onara === '1' || cookieOnara === true); + const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1'); if (effectiveOnara) { try { itemPage = await f0cklib.getItemPage({ @@ -444,6 +443,8 @@ export default (router, tpl) => { if (data.item) { const session = data.session; const item = data.item; + // Keep the real uploader for permission checks — anonymization below overwrites item.username + const _realUsername = item.username; // When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL if (isAnonymizeSession(req.session)) { if (item.src) item.src = null; @@ -483,6 +484,9 @@ export default (router, tpl) => { const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); + // Rating may also be changed by an anonymous uploader on their own item + const _ownerName = getSessionOwnerName(session); + data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.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]))); data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; @@ -510,13 +514,7 @@ export default (router, tpl) => { - Render: ${tAjaxRender - tAjaxAux}ms`); let itemPage = null; - const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - const cookieOnara = req.cookies?.f0ck_onara !== undefined - ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') - : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); - const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) - ? !!cfgOnara - : (query.onara === '1' || cookieOnara === true); + const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1'); if (effectiveOnara || query.get_page === '1') { try { itemPage = await f0cklib.getItemPage({ diff --git a/src/inc/routes/apiv2/anon.mjs b/src/inc/routes/apiv2/anon.mjs index 7013cff..727a386 100644 --- a/src/inc/routes/apiv2/anon.mjs +++ b/src/inc/routes/apiv2/anon.mjs @@ -16,6 +16,14 @@ import { } from '../../webauthn.mjs'; import { getEnableAnonymousAccess } from '../../settings.mjs'; +// Maximum number of passkeys a single anonymous identity may hold +const MAX_ANON_PASSKEYS = 4; + +const countPasskeys = async userId => { + const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`; + return rows[0]?.n || 0; +}; + export default router => { router.group(/^\/api\/v2\/anon/, group => { @@ -426,6 +434,128 @@ export default router => { } }); + // ─── Add Passkey to current anonymous identity ──────────────────────────── + + // Resolves the logged-in anonymous user, or null if the session isn't an anon identity + const getAnonSessionUserId = async req => { + if (!req.session?.id) return null; + const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`; + return rows.length > 0 ? req.session.id : null; + }; + + /** + * POST /api/v2/anon/passkey/add/begin + * Registration options for an additional passkey on the current anonymous identity. + */ + group.post(/\/passkey\/add\/begin$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + const userId = await getAnonSessionUserId(req); + if (!userId) { + return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401); + } + + const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`; + if (existing.length >= MAX_ANON_PASSKEYS) { + return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400); + } + + const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16)))); + const challenge = generateChallenge({ type: 'anon-add', userId }); + + const options = buildRegistrationOptions({ + challenge, + userId: userHandle, + userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`, + displayName: 'Anonymous', + rpId: getRpIdFromHost(req.headers.host) + }); + // Stop the authenticator from registering a second copy of a passkey it already holds + options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id })); + + return res.json({ success: true, options }); + } catch (err) { + console.error('[ANON_PASSKEY] add/begin error:', err); + return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); + } + }); + + /** + * POST /api/v2/anon/passkey/add/finish + * Verify attestation and attach the new passkey to the current anonymous identity. + */ + group.post(/\/passkey\/add\/finish$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + const userId = await getAnonSessionUserId(req); + if (!userId) { + return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401); + } + if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) { + return res.json({ success: false, msg: 'Invalid CSRF token' }, 403); + } + + const body = req.post || req.body || {}; + const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body; + if (!challenge || !clientDataJSON || !attestationObject || !credentialId) { + return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400); + } + + let challengeMeta; + try { + challengeMeta = consumeChallenge(challenge); + } catch (e) { + return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400); + } + if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) { + return res.json({ success: false, msg: 'Wrong challenge type' }, 400); + } + + // Re-check here too: two add flows could have been started in parallel + if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) { + return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400); + } + + let regResult; + try { + regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) }); + } catch (e) { + console.warn('[ANON_PASSKEY] Add verification failed:', e.message); + return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400); + } + + const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`; + if (taken.length > 0) { + return res.json({ success: false, msg: 'This passkey is already registered.' }, 409); + } + + const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, ''); + const auditIp = resolveAuditIP(req); + + await db` + INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name) + VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'}) + `; + await db` + INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint) + VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null}) + ON CONFLICT (credential_id) DO NOTHING + `; + + const passkeyCount = await countPasskeys(userId); + return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS }); + } catch (err) { + console.error('[ANON_PASSKEY] add/finish error:', err); + return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); + } + }); + // ─── Identity ───────────────────────────────────────────────────────────── /** @@ -448,6 +578,7 @@ export default router => { FROM anon_identities ai LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id WHERE ai.user_id = ${req.session.id} + ORDER BY ai.created_at ASC LIMIT 1 `; @@ -462,6 +593,8 @@ export default router => { hw_fingerprint: rows[0].hw_fingerprint, credential_id: rows[0].credential_id, passkey_name: rows[0].passkey_name, + passkey_count: await countPasskeys(req.session.id), + passkey_max: MAX_ANON_PASSKEYS, csrf_token: req.session.csrf_token }); } diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 6867f27..e5bfc3d 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -2,7 +2,7 @@ import { promises as fs } from "fs"; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; -import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from '../../settings.mjs'; +import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession, getSessionOwnerName } from '../../settings.mjs'; import queue from '../../queue.mjs'; import search from '../../routeinc/search.mjs'; import path from "path"; @@ -2315,7 +2315,8 @@ export default router => { return res.json({ success: false, msg: 'Item not found' }, 404); } - const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); + const ownerName = getSessionOwnerName(req.session); + const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase()); const isAdmin = !!(req.session.admin || req.session.is_moderator); if (!isOwner && !isAdmin) { diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index e2f147b..d484600 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -6,7 +6,7 @@ import cfg from "../../config.mjs"; import fs from "fs"; import path from "path"; import { logAnonActivity } from "../../anon_auth.mjs"; -import { canAnonDo, isAnonSession } from "../../settings.mjs"; +import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs"; export default router => { router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => { @@ -156,7 +156,7 @@ export default router => { WHERE item_id IN ${db(itemIds)} AND tag_id IN ${db(tagIds)} AND item_id IN ( - SELECT id FROM items WHERE username = ${req.session.user} + SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)} ) `; } @@ -206,7 +206,7 @@ export default router => { const ownedItems = await db` SELECT id FROM items WHERE id IN ${db(itemIds)} - AND username = ${req.session.user} + AND username = ${getSessionOwnerName(req.session)} AND active = true AND is_deleted = false `; eligibleIds = ownedItems.map(r => r.id); @@ -440,7 +440,8 @@ export default router => { return res.json({ success: false, msg: 'Item not found' }, 404); } - const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); + const ownerName = getSessionOwnerName(req.session); + const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase()); const isAdmin = !!(req.session.admin || req.session.is_moderator); if (!isOwner && !isAdmin) { return res.json({ success: false, msg: 'Unauthorized' }, 403); diff --git a/src/inc/routes/chan.mjs b/src/inc/routes/chan.mjs index 6df6c5a..3f55831 100644 --- a/src/inc/routes/chan.mjs +++ b/src/inc/routes/chan.mjs @@ -2,6 +2,11 @@ import cfg from "../config.mjs"; import db from "../sql.mjs"; import lib from "../lib.mjs"; import queue from "../queue.mjs"; +import { chanCurl } from "../chan_http.mjs"; +import { getSessionOwnerName, getEnableItemSlugs, isOnaraEnabledFor } from "../settings.mjs"; + +// Link path for a local item: its slug when slugs are enabled, else the numeric id +const itemPath = (id, slug) => (getEnableItemSlugs() && slug) ? slug : id; /** * chan.mjs — 4chan thread viewer & catalogue routes @@ -28,17 +33,8 @@ export default (router, tpl) => { * Helper to fetch data via curl respecting SOCKS5 proxy if configured. */ async function fetchWithProxy(url) { - const curlArgs = [ - '-s', '-f', '-L', - '-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - '--max-time', '30', - url - ]; - if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { - const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; - curlArgs.push('--socks5-hostname', proxyHost); - } - const { stdout } = await queue.spawn('curl', curlArgs, { encoding: 'utf8' }); + const { bin, args } = chanCurl(url, ['--max-time', '30']); + const { stdout } = await queue.spawn(bin, args, { encoding: 'utf8' }); const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim(); if (!text.startsWith('{') && !text.startsWith('[')) { throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`); @@ -123,10 +119,12 @@ export default (router, tpl) => { } }); const rehosts = {}; + const rehostPaths = {}; if (opUrls.length > 0) { try { - const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`; + const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`; rows.forEach(r => { + rehostPaths[r.id] = itemPath(r.id, r.slug); rehosts[r.src] = r.id; const m = r.src.match(/(\d{13,20})/); if (m) rehosts[m[1]] = r.id; @@ -135,6 +133,7 @@ export default (router, tpl) => { } threads.forEach(t => { t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null; + t.local_path = t.local_id ? rehostPaths[t.local_id] : null; }); const data = { @@ -219,6 +218,7 @@ export default (router, tpl) => { is_video: isVideo, is_image: isImage, local_id: localId, + local_path: rehostInfo ? rehostInfo.path : null, rehosted: !!localId, user_has_favorited: localId ? userFavSet.has(Number(localId)) : false, is_onara_active: targetPostNo ? p.no === targetPostNo : false, @@ -255,11 +255,13 @@ export default (router, tpl) => { cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); }); const rehostMap = {}; + const rehostPaths = {}; if (cdnUrls.length > 0) { try { - const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`; + const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`; rows.forEach(r => { - const info = { id: r.id, stamp: r.stamp }; + const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) }; + rehostPaths[r.id] = info.path; rehosts[r.src] = r.id; rehostMap[r.src] = info; const m = r.src.match(/(\d{13,20})/); @@ -363,11 +365,13 @@ export default (router, tpl) => { }); const rehosts = {}; const rehostMap = {}; + const rehostPaths = {}; if (cdnUrls.length > 0) { try { - const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`; + const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`; rows.forEach(r => { - const info = { id: r.id, stamp: r.stamp }; + const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) }; + rehostPaths[r.id] = info.path; rehosts[r.src] = r.id; rehostMap[r.src] = info; const m = r.src.match(/(\d{13,20})/); @@ -412,7 +416,7 @@ export default (router, tpl) => { localThumb = `/t/${li.id}.webp`; } if (req.session) { - canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) || + canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) || req.session.admin || req.session.is_moderator); } } @@ -456,9 +460,11 @@ export default (router, tpl) => { external_board: board, external_tid: tid, external_id: targetPost.no, + external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`, external_media_url: externalMediaUrl, original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null, local_id: localId, + local_path: localId ? (rehostPaths[localId] || localId) : null, rehosted: !!localId, is_sfw: itemRating === 'sfw', is_nsfw: itemRating === 'nsfw', @@ -510,6 +516,7 @@ export default (router, tpl) => { is_active: p.no === targetPost.no, index: idx + 1, local_id: pLocalId, + local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null, rehosted: !!pLocalId, user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false, width: p.w || null, @@ -542,6 +549,7 @@ export default (router, tpl) => { user_alternative_steuerung: req.session?.user_alternative_steuerung, user_alternative_infobox: req.session?.user_alternative_infobox, can_manage_item: canManage, + can_rate_item: canManage, can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))), user_has_favorited: userHasFavorited, isSubscribed: false, @@ -583,13 +591,7 @@ export default (router, tpl) => { } // Full page render: if Onara is active, render thread page with onara modal open - const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - const cookieOnara = req.cookies?.f0ck_onara !== undefined - ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') - : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); - const isOnara = (cfgOnara !== undefined && cfgOnara !== null) - ? !!cfgOnara - : (cookieOnara !== null ? cookieOnara : !!req.session?.onara); + const isOnara = isOnaraEnabledFor(req); if (isOnara) { const isModern = req.session?.use_new_layout; @@ -677,10 +679,12 @@ export default (router, tpl) => { cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); }); const rehosts = {}; + const rehostPaths = {}; if (cdnUrls.length > 0) { try { - const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`; + const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`; rows.forEach(r => { + rehostPaths[r.id] = itemPath(r.id, r.slug); rehosts[r.src] = r.id; const m = r.src.match(/(\d{13,20})/); if (m) rehosts[m[1]] = r.id; @@ -705,6 +709,7 @@ export default (router, tpl) => { is_image: !isVideo, index: idx + 1, local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null, + local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null, rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])) }; }); diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs index ee02cee..8dd548a 100644 --- a/src/inc/routes/external.mjs +++ b/src/inc/routes/external.mjs @@ -2,9 +2,17 @@ import cfg from "../config.mjs"; import db from "../sql.mjs"; import lib from "../lib.mjs"; import queue from "../queue.mjs"; +import { chanCurl } from "../chan_http.mjs"; import { promises as fs } from "fs"; import path from "path"; -import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs"; +import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs"; + +// Link path for a local item: its slug when slugs are enabled, else the numeric id +const itemPath = async (id, slug) => { + if (!getEnableItemSlugs()) return id; + if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug; + return slug || id; +}; import { applyWordFilter } from "../wordfilter.mjs"; /** @@ -45,18 +53,8 @@ export default (router) => { * This ensures we respect the SOCKS5 proxy for all external 4chan requests. */ async function fetchWithProxy(url, asBuffer = false) { - const curlArgs = [ - '-s', '-f', '-L', - '-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - '--max-time', '30', - url - ]; - if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { - const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; - curlArgs.push('--socks5-hostname', proxyHost); - } - - const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' }); + const { bin, args } = chanCurl(url, ['--max-time', '30']); + const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' }); if (asBuffer) return stdout; const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim(); if (!text.startsWith('{') && !text.startsWith('[')) { @@ -86,6 +84,7 @@ export default (router) => { // Check which media URLs are already rehosted on this platform const rehosts = {}; + const rehostPaths = {}; const mediaPosts = posts.filter(p => p.tim && p.ext); const cdn4Urls = []; mediaPosts.forEach(p => { @@ -98,7 +97,8 @@ export default (router) => { }); if (cdn4Urls.length > 0) { try { - const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`; + const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`; + for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug); rows.forEach(r => { rehosts[r.src] = r.id; const m = r.src.match(/(\d{13,20})/); @@ -111,7 +111,7 @@ export default (router) => { return res.reply({ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, - body: JSON.stringify({ success: true, posts, board, tid, rehosts }) + body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths }) }); } catch (err) { @@ -295,19 +295,10 @@ export default (router) => { }; const contentType = mimes[ext] || 'application/octet-stream'; - const curlArgs = [ - '-s', '-f', '-L', - '-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - '--max-time', '60', - url - ]; - if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { - const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; - curlArgs.push('--socks5-hostname', proxyHost); - } + const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']); const { spawn } = await import('child_process'); - const curl = spawn('curl', curlArgs); + const curl = spawn(curlBin, curlArgs); res.writeHead(200, { 'Content-Type': contentType, @@ -355,25 +346,22 @@ export default (router) => { : null; const session = req.session; + // Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account + const ownerName = getSessionOwnerName(session); try { const uuid = await queue.genuuid(); const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`); // Download via curl (lightweight) - const curlArgs = [ - '-s', '-f', '-L', url, '-o', tmpPath, + const { bin: curlBin, args: curlArgs } = chanCurl(url, [ + '-o', tmpPath, '--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`, '--connect-timeout', '30', - '--max-time', '300', - '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ]; - if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { - const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; - curlArgs.push('--socks5-hostname', proxyHost); - } + '--max-time', '300' + ]); - await queue.spawn('curl', curlArgs); + await queue.spawn(curlBin, curlArgs); // Detect MIME const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim(); @@ -402,7 +390,7 @@ export default (router) => { return res.reply({ code: 200, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' }) + body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' }) }); } } @@ -424,7 +412,7 @@ export default (router) => { return res.reply({ code: 200, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' }) + body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' }) }); } } @@ -469,7 +457,7 @@ export default (router) => { size: (await fs.stat(path.join(destDir, filename))).size, checksum: insertChecksum, phash: phash, - username: session.user, + username: ownerName, userchannel: 'web', usernetwork: 'web', stamp: ~~(Date.now() / 1000), @@ -542,7 +530,7 @@ export default (router) => { id: itemid, dest: filename, mime: mime, - username: session.user, + username: ownerName, display_name: session.display_name || null, tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: false, @@ -575,7 +563,7 @@ export default (router) => { return res.reply({ headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: true, item_id: itemid }) + body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) }) }); } catch (err) { @@ -630,9 +618,10 @@ export default (router) => { return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) }); } - const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase()); + const ownerName = getSessionOwnerName(session); + const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase()); const isAdmin = !!(session.admin || session.is_moderator); - const isChanUser = !!(session.admin || (Array.isArray(session.groups) && session.groups.includes('4chan'))); + const isChanUser = canUseChan(session); const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) || (comment && typeof comment === 'string' && comment.trim().length > 0); @@ -722,6 +711,7 @@ export default (router) => { body: JSON.stringify({ success: true, item_id: itemId, + item_path: await itemPath(itemId), rating: ratingTag ? ratingTag.normalized : 'untagged', tags: cleanTagObjects }) diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index ee119a6..0f4c40f 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -1,802 +1,802 @@ -import cfg from "../config.mjs"; -import db from "../sql.mjs"; -import lib from "../lib.mjs"; -import f0cklib from "../routeinc/f0cklib.mjs"; -import { createI18n } from "../i18n.mjs"; -import { render502 } from "../private_items.mjs"; -import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession } from "../settings.mjs"; - -const auth = async (req, res, next) => { - if (!req.session) - return res.redirect("/login"); - return next(); -}; - -export default (router, tpl) => { - router.get(/^\/user\/(?[^/]+)\/?$/, async (req, res) => { - // When anonymization is active, user profiles must not be accessible without an authenticated regular session - // But allow anon users to view their own profile - if (isAnonymizeSession(req.session)) { - const requestedUser = decodeURIComponent(req.params.user).toLowerCase(); - const sessionUser = (req.session?.user || req.session?.login || '').toLowerCase(); - if (requestedUser !== sessionUser) { - return req.session ? res.redirect('/') : res.redirect('/login'); - } - } - const user = decodeURIComponent(req.params.user); - const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null); - - const query = await db` - select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires, "user".created_at, user_options.*, user_options.display_name - from "user" - left join user_options on "user".id = user_options.user_id - where "user".user ilike ${user} OR "user".login ilike ${user} - limit 1 - `; - - let userData = query[0]; - - if (!userData) { - // Fallback: Check if user exists as a "Ghost" (uploading in items table but no account) - const ghostQuery = await db` - SELECT username, MIN(stamp) as first_upload - FROM items - WHERE username ILIKE ${user} AND active = true - GROUP BY username - LIMIT 1 - `; - - if (ghostQuery.length) { - userData = { - id: null, - user: user, - created_at: new Date(ghostQuery[0].first_upload * 1000), - activated: true, - banned: false, - is_ghost: true - }; - } else { - return res.reply({ - code: 404, - body: tpl.render('error', { - message: 'this user does not exists', - tmp: null - }, req) - }); - } - } - - let f0cks, favs; - const count = { - f0cks: 0, - favs: 0, - tags: 0 - }; - try { - const isRandom = req.cookies.random_mode === '1'; - const ratingsRaw = req.cookies.ratings; - // In All mode (mode=3), ignore the ratings cookie — it would otherwise - // filter out e.g. NSFW uploads even though the user is in "All" mode. - const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null); - f0cks = await f0cklib.getf0cks({ - user: user, - mode: req.mode, - ratings: ratingsArr, - mime: mime, - fav: false, - session: req.session, - user_id: req.session?.id, - random: isRandom - }); - if ('items' in f0cks) { - count.f0cks = f0cks.total ?? f0cks.items.length; - f0cks.items = f0cks.items.slice(0, 12); - } - } catch (err) { - console.error('[PROFILE] getf0cks failed for user:', user, err); - f0cks = false; - count.f0cks = 0; - } - if (!userData.is_ghost) { - try { - const isRandom = req.cookies.random_mode === '1'; - const ratingsRaw = req.cookies.ratings; - // In All mode (mode=3), ignore the ratings cookie — a stale ratings cookie - // (e.g. only 'sfw') would cause NSFW favorites to show 0 on the profile. - const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null); - favs = await f0cklib.getf0cks({ - user: user, - mode: req.mode, - ratings: ratingsArr, - mime: mime, - fav: true, - session: req.session, - user_id: req.session?.id, - is_admin: req.session?.admin, - random: isRandom - }); - if (favs && 'items' in favs) { - count.favs = favs.total ?? favs.items.length; - favs.items = favs.items.slice(0, 12); - } - } catch (err) { - favs = false; - count.favs = 0; - } - } - - if (!userData.is_ghost) { - try { - const [comms, tags, halls] = await Promise.all([ - db` - select count(*) - from comments c - join items i on c.item_id = i.id - where c.user_id = ${userData.id} - and c.is_deleted = false - and i.active = true - and i.is_deleted = false - `, - db` - select count(*) - from tags_assign - where user_id = ${userData.id} - and tag_id > 2 - `, - db` - select count(*) - from user_halls - where user_id = ${userData.id} - ` - ]); - count.comments = +comms[0].count; - count.tags = +tags[0].count; - count.halls = +halls[0].count; - } catch (e) { - count.comments = count.comments || 0; - count.tags = 0; - } - } - - userData.timestamp = { - timeago: lib.timeAgo(userData.created_at, req.lang), - timefull: new Date(userData.created_at).toISOString() - }; - userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000); - - if (!req.session) { - userData.banned = false; - delete userData.ban_expires; - delete userData.ban_duration; - } else if (userData.banned) { - if (!userData.ban_expires) { - userData.ban_duration = "Permanent"; - } else { - const diff = ~~((new Date(userData.ban_expires) - new Date()) / 1e3); - if (diff <= 0) { - userData.ban_duration = "Expiration pending refresh"; - } else { - const epochs = [ - ["year", 31536000], - ["month", 2592000], - ["week", 604800], - ["day", 86400], - ["hour", 3600], - ["minute", 60], - ["second", 1] - ]; - let durationStr = "Expires in "; - for (let [name, seconds] of epochs) { - const interval = ~~(diff / seconds); - if (interval >= 1) { - durationStr += `${interval} ${name}${interval === 1 ? "" : "s"}`; - break; - } - } - userData.ban_duration = durationStr; - } - } - } - - const data = { - user: userData, - f0cks, - count, - favs, - tmp: null, - session: req.session ? { ...req.session } : false, - page_meta: { - title: userData.user, - description: userData.is_ghost ? `${count.f0cks} legacy uploads` : `${count.f0cks} uploads, ${count.favs} favorites`, - url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userData.user)}`, - image: userData.avatar_file - ? `https://${cfg.main.url.domain}/a/${userData.avatar_file}` - : userData.avatar - ? `https://${cfg.main.url.domain}/t/${userData.avatar}.webp` - : `https://${cfg.main.url.domain}/a/default.png` - } - }; - - if (req.headers['x-requested-with'] === 'XMLHttpRequest') { - return res.reply({ body: tpl.render('user-partial', data, req) }); - } - - return res.reply({ body: tpl.render('user', data, req) }); - }); - - /* */ - const handleGenericRoute = async (req, res) => { - const tRouteStart = Date.now(); - const mode = req.params.itemid ? 'item' : 'index'; - - // Feature flag guards for disabled features - if (cfg.websrv.halls_enabled === false && req.params.hall) { - return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); - } - - // Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs - if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) { - res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` }); - return res.end(); - } - - // When anonymization is active, user gallery pages must require authentication and hide other users - if (isAnonymizeSession(req.session) && req.params.user && req.params.mode) { - const targetUser = decodeURIComponent(req.params.user).toLowerCase(); - const isSelf = req.session?.user && ( - targetUser === req.session.user.toLowerCase() || - (req.session.login && targetUser === req.session.login.toLowerCase()) - ); - if (!isSelf) { - return req.session ? res.redirect('/') : res.redirect('/login'); - } - } - - // Auto-persist strict mode from URL to session if it's there - if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) { - req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1'); - } - - // Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX - const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag; - - // Track tag browsing interest in user affinity profile - if (reqTag && req.session?.id && !reqTag.startsWith('title:')) { - f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag: reqTag, scoreDelta: 2.0 }).catch(() => {}); - } - - const bypassFilter = !!(req.query?.force === '1' || req.query?.allow === '1' || req.query?.bypass === '1' || req.url.qs?.force === '1' || req.url.qs?.allow === '1' || req.url.qs?.bypass === '1' || req.query?.see_anyways === '1' || req.url.qs?.see_anyways === '1'); - - const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({ - user: req.params.user, - tag: reqTag, - mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), - page: req.params.page, - itemid: req.params.itemid, - hall: req.params.hall, - fav: req.params.mode == 'favs', - mode: req.mode, - bypass_filter: bypassFilter, - ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), - session: req.session, - user_id: req.session?.id, - is_admin: req.session?.admin, - exclude: req.session ? (req.session.excluded_tags || []) : [], - url: decodeURIComponent(req.url.pathname || req.url), - strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), - explicitStrict: !!(req.query?.strict || req.url.qs?.strict), - random: req.cookies.random_mode === '1', - minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), - lang: req.lang, - tagger: req.url.qs?.tagger || null, - subf0ck: req.query?.subf0ck || req.url.qs?.subf0ck || null - }); - console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); - console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); - - if (!data.success) { - if (data.is_private && !req.session && (mode === 'item' || data.message === '403 - private upload')) { - render502(req, res); - return; - } - if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) { - const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); - return res.reply({ - code: 403, - body: tpl.render('error', { - message: tErr('profile.private_favorites'), - domain: cfg.main.url.domain, - tmp: null, - session: req.session ? { ...req.session } : false, - error_filter_hint: null, - error_filter_hint_link: null - }, req) - }); - } - // For index/grid views with zero items (empty DB), render an empty grid instead of error - if (mode !== 'item') { - data.items = []; - data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null }; - data.total = 0; - data.success = true; - if (!data.link) { - if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' }; - else if (reqTag) data.link = { main: '/tag/' + encodeURIComponent(reqTag) + '/', path: 'p/', suffix: '' }; - else data.link = { main: '/', path: 'p/', suffix: '' }; - } - data.tmp = data.tmp || {}; - if (req.params.hall && !data.tmp.hall) { - const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`; - data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall; - } - if (reqTag && !data.tmp.tag) data.tmp.tag = reqTag; - } else { - // Return 200 for filtered NSFW items (has item data) so Discord parses og:image - // Return 404 only for truly missing items - const statusCode = data.item ? 200 : 404; - const reqMode = req.mode ?? 0; - const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' }; - const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null; - const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); - return res.reply({ - code: statusCode, - body: tpl.render('error', { - message: tErr('error.post_not_visible'), - item: data.item, // For OG meta tags on filtered NSFW items - item_id: data.item?.id || req.params.itemid, - item_slug: data.item?.slug || (typeof req.params.itemid === 'string' ? req.params.itemid : null), - domain: cfg.main.url.domain, - tmp: null, - session: req.session ? { ...req.session } : false, - error_mode_label: errorModeLabel, - error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `${errorModeLabel}` }) : null, - error_filter_hint_link: tErr('error.filter_hint_link'), - error_see_anyways: tErr('error.see_anyways') - }, req) - }); - } - } - - if (mode === 'item') { - data.hidePagination = true; - // Precompute hall display data for the template - if (data.item && data.item.halls && data.item.halls.length) { - const currentHallSlug = data.tmp && data.tmp.hall - ? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall) - : null; - data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0]; - data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug); - } else if (data.item) { - data.item.primaryHall = null; - data.item.otherHalls = []; - } - if (req.session || !cfg.main.hide_comments_from_public) { - const isPrefetch = !!( - req.headers?.['x-purpose'] === 'prefetch' || - req.headers?.['purpose'] === 'prefetch' || - req.headers?.['sec-purpose'] === 'prefetch' - ); - // Mark notifications as read (only when actually viewed, not on prefetch) - if (req.session?.id && !isPrefetch) { - f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {}); - } - // Subscription status — just a boolean, cheap to embed - const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false; - data.isSubscribed = sub; - - // xD Score — fetch comments only for the score; do NOT embed into the page - // Comments are always loaded async by the client via /api/comments/:id - // This avoids blocking the browser's main thread when a comment has a huge xD payload. - const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false); - const xdScore = f0cklib.computeXdScore(commentsForScore); - const xdMeta = f0cklib.xdScoreMeta(xdScore); - data.item.xd_score = xdScore; - data.item.xd_tier = xdMeta.tier; - data.item.xd_label = xdMeta.label; - - // Do NOT set commentsJSON — client will fetch async - data.commentsJSON = null; - data.comments = []; - } else { - data.isSubscribed = false; - data.commentsJSON = null; - data.comments = []; - data.item.xd_score = 0; - data.item.xd_tier = 0; - data.item.xd_label = ''; - } - } else { - // Ensure total is defined for list views (to prevent template error) - if (data.total === undefined) data.total = 0; - } - - // Explicitly inject session for template logic (Navbar) - // Only inject session for authenticated users to avoid showing member UI to guests - data.session = (req.session && req.session.user) ? { ...req.session } : false; - - // Precompute boolean helpers for template @if() — the flummpress template engine uses a - // non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters. - // This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if() - // will produce broken JS and a "Unexpected token '{'" parse error. - // Solution: precompute all such conditions as plain booleans here. - if (mode === 'item' && data.item) { - const session = data.session; - const item = data.item; - // Is the current user a moderator/admin? - data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); - // Can the current user manage this item (owner, admin, or mod)? - const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); - data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); - // Is the item's MIME type suitable for metadata extraction? - // YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible. - 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]))); - // Has the current user favorited this item? - data.user_has_favorited = lib.userHasFavorited(session, item.favorites); - // 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 or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL - if (isAnonymizeSession(req.session)) { - if (item.src) item.src = null; - item.username = 'anonymous'; - item.author_banner_file = null; - 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; - } - if (Array.isArray(item.favorites)) { - item.favorites = item.favorites.map(f => { - const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id); - if (isSelf) return f; - return { - user_id: null, - user: 'anonymous', - login: 'anonymous', - display_name: 'Anonymous', - avatar: null, - avatar_file: null, - username_color: null, - hide_fav_badge: f.hide_fav_badge, - is_anon: true - }; - }); - } - } - // 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')); - data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); - data.item_username_lower = (item.username || '').toLowerCase(); - data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); - data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); - data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); - 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); - data.show_repost_row = !!((data.session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (item.is_repost || (item.reposts && item.reposts.length > 0))); - data.item.show_repost_row = data.show_repost_row; - } - - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - res.setHeader('Surrogate-Control', 'no-store'); - - const tRenderStart = Date.now(); - - // Check if AJAX request for standard grid view - if (req.headers['x-requested-with'] === 'XMLHttpRequest' && mode === 'index') { - const body = tpl.render('index-partial', data, req); - console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (partial) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); - return res.reply({ body }); - } - - const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - const cookieOnara = req.cookies?.f0ck_onara !== undefined - ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') - : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); - // Global config onara overweighs always the user pref - const isOnara = (cfgOnara !== undefined && cfgOnara !== null) - ? !!cfgOnara - : (cookieOnara !== null ? cookieOnara : !!req.session?.onara); - if (mode === 'item' && isOnara) { - const itemHtml = tpl.render('ajax-item', data, req); - - const isRandom = req.cookies.random_mode === '1' || req.url.qs?.random === '1' || req.query?.random === '1'; - let itemPage = 1; - if (req.params.page) { - itemPage = parseInt(req.params.page, 10) || 1; - } else if (!isRandom && data.item) { - try { - itemPage = await f0cklib.getItemPage({ - targetItemId: data.item.id, - targetItemPinned: data.item.is_pinned, - user: req.params.user, - tag: reqTag, - mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), - hall: req.params.hall, - fav: req.params.mode == 'favs', - mode: req.mode, - ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), - session: req.session, - user_id: req.session?.id, - is_admin: req.session?.admin, - exclude: req.session ? (req.session.excluded_tags || []) : [], - strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), - minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), - tagger: req.url.qs?.tagger || null - }); - } catch (err) { - console.error('[ONARA] getItemPage error:', err); - itemPage = 1; - } - } - - let gridData = await f0cklib.getf0cks({ - user: req.params.user, - tag: reqTag, - mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), - page: itemPage, - hall: req.params.hall, - fav: req.params.mode == 'favs', - mode: req.mode, - ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), - session: req.session, - user_id: req.session?.id, - is_admin: req.session?.admin, - exclude: req.session ? (req.session.excluded_tags || []) : [], - url: decodeURIComponent(req.url.pathname || req.url), - strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), - explicitStrict: !!(req.query?.strict || req.url.qs?.strict), - random: isRandom, - minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), - lang: req.lang, - tagger: req.url.qs?.tagger || null - }); - - if (!gridData || !gridData.success) { - gridData = { - items: [], - pagination: { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null }, - total: 0, - success: true, - link: { main: '/', path: 'p/', suffix: '' }, - tmp: {} - }; - } - - const activeId = data.item ? data.item.id : req.params.itemid; - const activeSlug = data.item ? data.item.slug : null; - - // Mark the active item in the grid for instant highlight - let foundInGrid = false; - if (Array.isArray(gridData.items)) { - for (const it of gridData.items) { - if ((activeSlug && it.slug === activeSlug) || (it.id === activeId)) { - it.is_onara_active = true; - foundInGrid = true; - } else { - it.is_onara_active = false; - } - } - // If not in first page of feed, add it so thumbnail is present in background - if (!foundInGrid && data.item) { - const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; - const tagId = data.item.tag_id || (data.item.is_nsfl ? nsflId : (data.item.is_nsfw ? 2 : (data.item.is_sfw ? 1 : null))); - const thumbUrl = data.item.thumb || data.item.thumbnail || (data.item.id ? `/t/${data.item.id}.webp` : null); - const cleanDest = data.item.dest ? String(data.item.dest).replace(/^\/b\//, '') : ''; - const gridItem = { - ...data.item, - id: data.item.id, - slug: data.item.slug, - tag_id: tagId, - thumb: thumbUrl, - dest: cleanDest, - display_name: data.item.author_display_name || data.item.display_name || data.item.username, - username: data.item.username, - mime: data.item.matching_sub_mime || data.item.mime, - thumb_size: data.item.thumb_size || 1, - is_onara_active: true - }; - - if (isRandom && gridData.items.length > 0) { - const insertIdx = Math.floor(Math.random() * (gridData.items.length + 1)); - gridData.items.splice(insertIdx, 0, gridItem); - } else { - gridData.items.unshift(gridItem); - } - } - } - - gridData.session = data.session; - gridData.is_onara_item = true; - gridData.onara = true; - gridData.onara_item_html = itemHtml; - gridData.item = data.item; - gridData.page_meta = data.page_meta; - gridData.hidePagination = false; - - const body = tpl.render('index', gridData, req); - console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (onara direct item) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); - return res.reply({ body }); - } - - const body = tpl.render(mode, data, req); - console.log(`[${new Date().toISOString()}] [ROUTE] Render complete in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); - - return res.reply({ body }); - }; - - // Favorites route: redirect logged in users (or anon users) to /user/:user/favs, redirect clean guests to /login - router.get(/^\/favs(?:\/p\/(?\d+))?\/?(?:\?.*)?$/, async (req, res) => { - if (req.session && req.session.user) { - const targetUser = (req.session.is_anon && req.session.login) ? req.session.login : req.session.user; - res.writeHead(302, { Location: `/user/${encodeURIComponent(targetUser.toLowerCase())}/favs` }); - return res.end(); - } - res.writeHead(302, { Location: `/login` }); - return res.end(); - }); - - router.get(/^\/favs\/(?[a-zA-Z0-9_-]{11}|\d+)$/, (req, res) => { - req.params.mode = 'favs'; - return handleGenericRoute(req, res); - }); - - // Specific route for direct item links: /user/:user/:itemid - // This avoids ambiguity with the profile route - router.get(/^\/user\/(?[^/]+)\/(?(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute); - - // Generic router for everything else (Index, Tags, standard User Grids) - // We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them. - router.get(/^(?!\/(s|b|t|ca|a|4|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?.+?))?(?:\/h\/(?.+?))?(?:\/user\/(?.+?)\/(?f0cks|uploads|favs))?(?:\/(?(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?\d+))?(?:\/(?[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute); - /* */ - - router.get(/^\/(about)$/, (req, res) => { - res.reply({ - body: tpl.render('about', { - tmp: null, - mail: cfg.main.mail, - session: (req.session && req.session.user) ? { ...req.session } : false, - page_meta: { - title: 'about', - description: 'About', - url: `https://${cfg.main.url.domain}/about` - } - }, req) - }); - }); - - router.get(/^\/(terms)$/, (req, res) => { - res.reply({ - body: tpl.render('terms', { - tmp: null, - session: (req.session && req.session.user) ? { ...req.session } : false, - page_meta: { - title: 'terms', - description: 'Terms of service', - url: `https://${cfg.main.url.domain}/terms` - } - }, req) - }); - }); - - router.get(/^\/(rules)$/, (req, res) => { - res.reply({ - body: tpl.render('rules', { - tmp: null, - domain: cfg.main.url.domain, - session: req.session ? { ...req.session } : false, - page_meta: { - title: 'rules', - description: 'Rules and guidelines', - url: `https://${cfg.main.url.domain}/rules` - } - }, req) - }); - }); - - - - router.get(/^\/mode\/(\d)/, async (req, res) => { - const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/); - const mode = modeMatch ? +modeMatch[1] : 0; - - const isGuest = !req.session || !req.session.user; - if (isGuest) { - if (mode !== 0) { - if (req.headers['x-requested-with'] === 'XMLHttpRequest') { - return res.reply({ - code: 403, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: false, msg: 'Only SFW mode is allowed for guests' }) - }); - } - return res.redirect('/'); - } - } else if (isAnonSession(req.session)) { - if (!canAnonDo('filter') || !canAnonMode(mode)) { - if (req.headers['x-requested-with'] === 'XMLHttpRequest') { - return res.reply({ - code: 403, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: false, msg: 'Mode not permitted for anonymous users' }) - }); - } - return res.redirect('/'); - } - } - - if (cfg.allowedModes[mode]) { - if (req.session) { - req.session.mode = mode; - const blah = { - user_id: req.session.id, - mode: mode, - theme: req.theme ?? (cfg.websrv.theme || "f0ck") - }; - - await db` - insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme') - } - on conflict ("user_id") do update set - mode = excluded.mode, - theme = excluded.theme, - user_id = excluded.user_id - `; - } - } - if (req.headers['x-requested-with'] === 'XMLHttpRequest') { - return res.reply({ - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': `mode=${mode}; ${lib.getCookieOptions(31536000, false)}` - }, - body: JSON.stringify({ success: true, mode: mode }) - }); - } - - return res.writeHead(302, { - "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", - "Set-Cookie": `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`, - "Location": "/" - }).end(); - }); - - router.get(/^\/strict\/(0|1)$/, async (req, res) => { - const urlStr = req.url.pathname || req.url; - const strict = +urlStr.split("/")[2] === 1; - - if (req.session) { - console.log(`[DEBUG] Setting strict mode to ${strict} for user ${req.session.id}`); - req.session.strict_mode = strict; - - await db` - insert into "user_options" (user_id, strict_mode, mode, theme, fullscreen) - values (${req.session.id}, ${strict}, ${req.session.mode ?? 0}, ${req.session.theme ?? (cfg.websrv.theme || 'f0ck')}, ${req.session.fullscreen ?? 0}) - on conflict ("user_id") do update set - strict_mode = excluded.strict_mode - `; - } else { - console.log(`[DEBUG] No session found for strict toggle!`); - } - - return res.reply({ - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ success: true, strict: strict }) - }); - }); - - - return router; -}; +import cfg from "../config.mjs"; +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import f0cklib from "../routeinc/f0cklib.mjs"; +import { createI18n } from "../i18n.mjs"; +import { render502 } from "../private_items.mjs"; +import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs"; + +const auth = async (req, res, next) => { + if (!req.session) + return res.redirect("/login"); + return next(); +}; + +export default (router, tpl) => { + router.get(/^\/user\/(?[^/]+)\/?$/, async (req, res) => { + // When anonymization is active, user profiles must not be accessible without an authenticated regular session + // But allow anon users to view their own profile + if (isAnonymizeSession(req.session)) { + const requestedUser = decodeURIComponent(req.params.user).toLowerCase(); + // Anon sessions have user = 'anonymous'; their real profile name is the shadow login (anon_xxxx) + const isSelf = !!req.session && ( + requestedUser === (req.session.user || '').toLowerCase() || + requestedUser === (req.session.login || '').toLowerCase() + ); + if (!isSelf) { + return req.session ? res.redirect('/') : res.redirect('/login'); + } + } + const user = decodeURIComponent(req.params.user); + const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null); + + const query = await db` + select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires, "user".created_at, user_options.*, user_options.display_name + from "user" + left join user_options on "user".id = user_options.user_id + where "user".user ilike ${user} OR "user".login ilike ${user} + limit 1 + `; + + let userData = query[0]; + + if (!userData) { + // Fallback: Check if user exists as a "Ghost" (uploading in items table but no account) + const ghostQuery = await db` + SELECT username, MIN(stamp) as first_upload + FROM items + WHERE username ILIKE ${user} AND active = true + GROUP BY username + LIMIT 1 + `; + + if (ghostQuery.length) { + userData = { + id: null, + user: user, + created_at: new Date(ghostQuery[0].first_upload * 1000), + activated: true, + banned: false, + is_ghost: true + }; + } else { + return res.reply({ + code: 404, + body: tpl.render('error', { + message: 'this user does not exists', + tmp: null + }, req) + }); + } + } + + let f0cks, favs; + const count = { + f0cks: 0, + favs: 0, + tags: 0 + }; + try { + const isRandom = req.cookies.random_mode === '1'; + const ratingsRaw = req.cookies.ratings; + // In All mode (mode=3), ignore the ratings cookie — it would otherwise + // filter out e.g. NSFW uploads even though the user is in "All" mode. + const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null); + f0cks = await f0cklib.getf0cks({ + user: user, + mode: req.mode, + ratings: ratingsArr, + mime: mime, + fav: false, + session: req.session, + user_id: req.session?.id, + random: isRandom + }); + if ('items' in f0cks) { + count.f0cks = f0cks.total ?? f0cks.items.length; + f0cks.items = f0cks.items.slice(0, 12); + } + } catch (err) { + console.error('[PROFILE] getf0cks failed for user:', user, err); + f0cks = false; + count.f0cks = 0; + } + if (!userData.is_ghost) { + try { + const isRandom = req.cookies.random_mode === '1'; + const ratingsRaw = req.cookies.ratings; + // In All mode (mode=3), ignore the ratings cookie — a stale ratings cookie + // (e.g. only 'sfw') would cause NSFW favorites to show 0 on the profile. + const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null); + favs = await f0cklib.getf0cks({ + user: user, + mode: req.mode, + ratings: ratingsArr, + mime: mime, + fav: true, + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + random: isRandom + }); + if (favs && 'items' in favs) { + count.favs = favs.total ?? favs.items.length; + favs.items = favs.items.slice(0, 12); + } + } catch (err) { + favs = false; + count.favs = 0; + } + } + + if (!userData.is_ghost) { + try { + const [comms, tags, halls] = await Promise.all([ + db` + select count(*) + from comments c + join items i on c.item_id = i.id + where c.user_id = ${userData.id} + and c.is_deleted = false + and i.active = true + and i.is_deleted = false + `, + db` + select count(*) + from tags_assign + where user_id = ${userData.id} + and tag_id > 2 + `, + db` + select count(*) + from user_halls + where user_id = ${userData.id} + ` + ]); + count.comments = +comms[0].count; + count.tags = +tags[0].count; + count.halls = +halls[0].count; + } catch (e) { + count.comments = count.comments || 0; + count.tags = 0; + } + } + + userData.timestamp = { + timeago: lib.timeAgo(userData.created_at, req.lang), + timefull: new Date(userData.created_at).toISOString() + }; + userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000); + + if (!req.session) { + userData.banned = false; + delete userData.ban_expires; + delete userData.ban_duration; + } else if (userData.banned) { + if (!userData.ban_expires) { + userData.ban_duration = "Permanent"; + } else { + const diff = ~~((new Date(userData.ban_expires) - new Date()) / 1e3); + if (diff <= 0) { + userData.ban_duration = "Expiration pending refresh"; + } else { + const epochs = [ + ["year", 31536000], + ["month", 2592000], + ["week", 604800], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ["second", 1] + ]; + let durationStr = "Expires in "; + for (let [name, seconds] of epochs) { + const interval = ~~(diff / seconds); + if (interval >= 1) { + durationStr += `${interval} ${name}${interval === 1 ? "" : "s"}`; + break; + } + } + userData.ban_duration = durationStr; + } + } + } + + const data = { + user: userData, + f0cks, + count, + favs, + tmp: null, + session: req.session ? { ...req.session } : false, + page_meta: { + title: userData.user, + description: userData.is_ghost ? `${count.f0cks} legacy uploads` : `${count.f0cks} uploads, ${count.favs} favorites`, + url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userData.user)}`, + image: userData.avatar_file + ? `https://${cfg.main.url.domain}/a/${userData.avatar_file}` + : userData.avatar + ? `https://${cfg.main.url.domain}/t/${userData.avatar}.webp` + : `https://${cfg.main.url.domain}/a/default.png` + } + }; + + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ body: tpl.render('user-partial', data, req) }); + } + + return res.reply({ body: tpl.render('user', data, req) }); + }); + + /* */ + const handleGenericRoute = async (req, res) => { + const tRouteStart = Date.now(); + const mode = req.params.itemid ? 'item' : 'index'; + + // Feature flag guards for disabled features + if (cfg.websrv.halls_enabled === false && req.params.hall) { + return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); + } + + // Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs + if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) { + res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` }); + return res.end(); + } + + // When anonymization is active, user gallery pages must require authentication and hide other users + if (isAnonymizeSession(req.session) && req.params.user && req.params.mode) { + const targetUser = decodeURIComponent(req.params.user).toLowerCase(); + const isSelf = req.session?.user && ( + targetUser === req.session.user.toLowerCase() || + (req.session.login && targetUser === req.session.login.toLowerCase()) + ); + if (!isSelf) { + return req.session ? res.redirect('/') : res.redirect('/login'); + } + } + + // Auto-persist strict mode from URL to session if it's there + if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) { + req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1'); + } + + // Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX + const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag; + + // Track tag browsing interest in user affinity profile + if (reqTag && req.session?.id && !reqTag.startsWith('title:')) { + f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag: reqTag, scoreDelta: 2.0 }).catch(() => {}); + } + + const bypassFilter = !!(req.query?.force === '1' || req.query?.allow === '1' || req.query?.bypass === '1' || req.url.qs?.force === '1' || req.url.qs?.allow === '1' || req.url.qs?.bypass === '1' || req.query?.see_anyways === '1' || req.url.qs?.see_anyways === '1'); + + const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({ + user: req.params.user, + tag: reqTag, + mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), + page: req.params.page, + itemid: req.params.itemid, + hall: req.params.hall, + fav: req.params.mode == 'favs', + mode: req.mode, + bypass_filter: bypassFilter, + ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], + url: decodeURIComponent(req.url.pathname || req.url), + strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), + explicitStrict: !!(req.query?.strict || req.url.qs?.strict), + random: req.cookies.random_mode === '1', + minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), + lang: req.lang, + tagger: req.url.qs?.tagger || null, + subf0ck: req.query?.subf0ck || req.url.qs?.subf0ck || null + }); + console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); + console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); + + if (!data.success) { + if (data.is_private && !req.session && (mode === 'item' || data.message === '403 - private upload')) { + render502(req, res); + return; + } + if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) { + const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); + return res.reply({ + code: 403, + body: tpl.render('error', { + message: tErr('profile.private_favorites'), + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false, + error_filter_hint: null, + error_filter_hint_link: null + }, req) + }); + } + // For index/grid views with zero items (empty DB), render an empty grid instead of error + if (mode !== 'item') { + data.items = []; + data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null }; + data.total = 0; + data.success = true; + if (!data.link) { + if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' }; + else if (reqTag) data.link = { main: '/tag/' + encodeURIComponent(reqTag) + '/', path: 'p/', suffix: '' }; + else data.link = { main: '/', path: 'p/', suffix: '' }; + } + data.tmp = data.tmp || {}; + if (req.params.hall && !data.tmp.hall) { + const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`; + data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall; + } + if (reqTag && !data.tmp.tag) data.tmp.tag = reqTag; + } else { + // Return 200 for filtered NSFW items (has item data) so Discord parses og:image + // Return 404 only for truly missing items + const statusCode = data.item ? 200 : 404; + const reqMode = req.mode ?? 0; + const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' }; + const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null; + const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); + return res.reply({ + code: statusCode, + body: tpl.render('error', { + message: tErr('error.post_not_visible'), + item: data.item, // For OG meta tags on filtered NSFW items + item_id: data.item?.id || req.params.itemid, + item_slug: data.item?.slug || (typeof req.params.itemid === 'string' ? req.params.itemid : null), + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false, + error_mode_label: errorModeLabel, + error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `${errorModeLabel}` }) : null, + error_filter_hint_link: tErr('error.filter_hint_link'), + error_see_anyways: tErr('error.see_anyways') + }, req) + }); + } + } + + if (mode === 'item') { + data.hidePagination = true; + // Precompute hall display data for the template + if (data.item && data.item.halls && data.item.halls.length) { + const currentHallSlug = data.tmp && data.tmp.hall + ? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall) + : null; + data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0]; + data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug); + } else if (data.item) { + data.item.primaryHall = null; + data.item.otherHalls = []; + } + if (req.session || !cfg.main.hide_comments_from_public) { + const isPrefetch = !!( + req.headers?.['x-purpose'] === 'prefetch' || + req.headers?.['purpose'] === 'prefetch' || + req.headers?.['sec-purpose'] === 'prefetch' + ); + // Mark notifications as read (only when actually viewed, not on prefetch) + if (req.session?.id && !isPrefetch) { + f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {}); + } + // Subscription status — just a boolean, cheap to embed + const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false; + data.isSubscribed = sub; + + // xD Score — fetch comments only for the score; do NOT embed into the page + // Comments are always loaded async by the client via /api/comments/:id + // This avoids blocking the browser's main thread when a comment has a huge xD payload. + const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false); + const xdScore = f0cklib.computeXdScore(commentsForScore); + const xdMeta = f0cklib.xdScoreMeta(xdScore); + data.item.xd_score = xdScore; + data.item.xd_tier = xdMeta.tier; + data.item.xd_label = xdMeta.label; + + // Do NOT set commentsJSON — client will fetch async + data.commentsJSON = null; + data.comments = []; + } else { + data.isSubscribed = false; + data.commentsJSON = null; + data.comments = []; + data.item.xd_score = 0; + data.item.xd_tier = 0; + data.item.xd_label = ''; + } + } else { + // Ensure total is defined for list views (to prevent template error) + if (data.total === undefined) data.total = 0; + } + + // Explicitly inject session for template logic (Navbar) + // Only inject session for authenticated users to avoid showing member UI to guests + data.session = (req.session && req.session.user) ? { ...req.session } : false; + + // Precompute boolean helpers for template @if() — the flummpress template engine uses a + // non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters. + // This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if() + // will produce broken JS and a "Unexpected token '{'" parse error. + // Solution: precompute all such conditions as plain booleans here. + if (mode === 'item' && data.item) { + const session = data.session; + const item = data.item; + // Is the current user a moderator/admin? + data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); + // Can the current user manage this item (owner, admin, or mod)? + const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); + data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); + // Rating may also be changed by an anonymous uploader on their own item + const _ownerName = getSessionOwnerName(session); + data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.toLowerCase() === item.username.toLowerCase())); + // Is the item's MIME type suitable for metadata extraction? + // YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible. + 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]))); + // Has the current user favorited this item? + data.user_has_favorited = lib.userHasFavorited(session, item.favorites); + // 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 or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL + if (isAnonymizeSession(req.session)) { + if (item.src) item.src = null; + item.username = 'anonymous'; + item.author_banner_file = null; + 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; + } + if (Array.isArray(item.favorites)) { + item.favorites = item.favorites.map(f => { + const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id); + if (isSelf) return f; + return { + user_id: null, + user: 'anonymous', + login: 'anonymous', + display_name: 'Anonymous', + avatar: null, + avatar_file: null, + username_color: null, + hide_fav_badge: f.hide_fav_badge, + is_anon: true + }; + }); + } + } + // 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')); + data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); + data.item_username_lower = (item.username || '').toLowerCase(); + data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); + data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); + data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); + 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); + data.show_repost_row = !!((data.session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (item.is_repost || (item.reposts && item.reposts.length > 0))); + data.item.show_repost_row = data.show_repost_row; + } + + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); + + const tRenderStart = Date.now(); + + // Check if AJAX request for standard grid view + if (req.headers['x-requested-with'] === 'XMLHttpRequest' && mode === 'index') { + const body = tpl.render('index-partial', data, req); + console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (partial) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); + return res.reply({ body }); + } + + const isOnara = isOnaraEnabledFor(req); + if (mode === 'item' && isOnara) { + const itemHtml = tpl.render('ajax-item', data, req); + + const isRandom = req.cookies.random_mode === '1' || req.url.qs?.random === '1' || req.query?.random === '1'; + let itemPage = 1; + if (req.params.page) { + itemPage = parseInt(req.params.page, 10) || 1; + } else if (!isRandom && data.item) { + try { + itemPage = await f0cklib.getItemPage({ + targetItemId: data.item.id, + targetItemPinned: data.item.is_pinned, + user: req.params.user, + tag: reqTag, + mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), + hall: req.params.hall, + fav: req.params.mode == 'favs', + mode: req.mode, + ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], + strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), + minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), + tagger: req.url.qs?.tagger || null + }); + } catch (err) { + console.error('[ONARA] getItemPage error:', err); + itemPage = 1; + } + } + + let gridData = await f0cklib.getf0cks({ + user: req.params.user, + tag: reqTag, + mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), + page: itemPage, + hall: req.params.hall, + fav: req.params.mode == 'favs', + mode: req.mode, + ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], + url: decodeURIComponent(req.url.pathname || req.url), + strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), + explicitStrict: !!(req.query?.strict || req.url.qs?.strict), + random: isRandom, + minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), + lang: req.lang, + tagger: req.url.qs?.tagger || null + }); + + if (!gridData || !gridData.success) { + gridData = { + items: [], + pagination: { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null }, + total: 0, + success: true, + link: { main: '/', path: 'p/', suffix: '' }, + tmp: {} + }; + } + + const activeId = data.item ? data.item.id : req.params.itemid; + const activeSlug = data.item ? data.item.slug : null; + + // Mark the active item in the grid for instant highlight + let foundInGrid = false; + if (Array.isArray(gridData.items)) { + for (const it of gridData.items) { + if ((activeSlug && it.slug === activeSlug) || (it.id === activeId)) { + it.is_onara_active = true; + foundInGrid = true; + } else { + it.is_onara_active = false; + } + } + // If not in first page of feed, add it so thumbnail is present in background + if (!foundInGrid && data.item) { + const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; + const tagId = data.item.tag_id || (data.item.is_nsfl ? nsflId : (data.item.is_nsfw ? 2 : (data.item.is_sfw ? 1 : null))); + const thumbUrl = data.item.thumb || data.item.thumbnail || (data.item.id ? `/t/${data.item.id}.webp` : null); + const cleanDest = data.item.dest ? String(data.item.dest).replace(/^\/b\//, '') : ''; + const gridItem = { + ...data.item, + id: data.item.id, + slug: data.item.slug, + tag_id: tagId, + thumb: thumbUrl, + dest: cleanDest, + display_name: data.item.author_display_name || data.item.display_name || data.item.username, + username: data.item.username, + mime: data.item.matching_sub_mime || data.item.mime, + thumb_size: data.item.thumb_size || 1, + is_onara_active: true + }; + + if (isRandom && gridData.items.length > 0) { + const insertIdx = Math.floor(Math.random() * (gridData.items.length + 1)); + gridData.items.splice(insertIdx, 0, gridItem); + } else { + gridData.items.unshift(gridItem); + } + } + } + + gridData.session = data.session; + gridData.is_onara_item = true; + gridData.onara = true; + gridData.onara_item_html = itemHtml; + gridData.item = data.item; + gridData.page_meta = data.page_meta; + gridData.hidePagination = false; + + const body = tpl.render('index', gridData, req); + console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (onara direct item) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); + return res.reply({ body }); + } + + const body = tpl.render(mode, data, req); + console.log(`[${new Date().toISOString()}] [ROUTE] Render complete in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); + + return res.reply({ body }); + }; + + // Favorites route: redirect logged in users (or anon users) to /user/:user/favs, redirect clean guests to /login + router.get(/^\/favs(?:\/p\/(?\d+))?\/?(?:\?.*)?$/, async (req, res) => { + if (req.session && req.session.user) { + const targetUser = (req.session.is_anon && req.session.login) ? req.session.login : req.session.user; + res.writeHead(302, { Location: `/user/${encodeURIComponent(targetUser.toLowerCase())}/favs` }); + return res.end(); + } + res.writeHead(302, { Location: `/login` }); + return res.end(); + }); + + router.get(/^\/favs\/(?[a-zA-Z0-9_-]{11}|\d+)$/, (req, res) => { + req.params.mode = 'favs'; + return handleGenericRoute(req, res); + }); + + // Specific route for direct item links: /user/:user/:itemid + // This avoids ambiguity with the profile route + router.get(/^\/user\/(?[^/]+)\/(?(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute); + + // Generic router for everything else (Index, Tags, standard User Grids) + // We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them. + router.get(/^(?!\/(s|b|t|ca|a|4|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?.+?))?(?:\/h\/(?.+?))?(?:\/user\/(?.+?)\/(?f0cks|uploads|favs))?(?:\/(?(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?\d+))?(?:\/(?[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute); + /* */ + + router.get(/^\/(about)$/, (req, res) => { + res.reply({ + body: tpl.render('about', { + tmp: null, + mail: cfg.main.mail, + session: (req.session && req.session.user) ? { ...req.session } : false, + page_meta: { + title: 'about', + description: 'About', + url: `https://${cfg.main.url.domain}/about` + } + }, req) + }); + }); + + router.get(/^\/(terms)$/, (req, res) => { + res.reply({ + body: tpl.render('terms', { + tmp: null, + session: (req.session && req.session.user) ? { ...req.session } : false, + page_meta: { + title: 'terms', + description: 'Terms of service', + url: `https://${cfg.main.url.domain}/terms` + } + }, req) + }); + }); + + router.get(/^\/(rules)$/, (req, res) => { + res.reply({ + body: tpl.render('rules', { + tmp: null, + domain: cfg.main.url.domain, + session: req.session ? { ...req.session } : false, + page_meta: { + title: 'rules', + description: 'Rules and guidelines', + url: `https://${cfg.main.url.domain}/rules` + } + }, req) + }); + }); + + + + router.get(/^\/mode\/(\d)/, async (req, res) => { + const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/); + const mode = modeMatch ? +modeMatch[1] : 0; + + const isGuest = !req.session || !req.session.user; + if (isGuest) { + if (mode !== 0) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ + code: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'Only SFW mode is allowed for guests' }) + }); + } + return res.redirect('/'); + } + } else if (isAnonSession(req.session)) { + if (!canAnonDo('filter') || !canAnonMode(mode)) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ + code: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'Mode not permitted for anonymous users' }) + }); + } + return res.redirect('/'); + } + } + + if (cfg.allowedModes[mode]) { + if (req.session) { + req.session.mode = mode; + const blah = { + user_id: req.session.id, + mode: mode, + theme: req.theme ?? (cfg.websrv.theme || "f0ck") + }; + + await db` + insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme') + } + on conflict ("user_id") do update set + mode = excluded.mode, + theme = excluded.theme, + user_id = excluded.user_id + `; + } + } + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `mode=${mode}; ${lib.getCookieOptions(31536000, false)}` + }, + body: JSON.stringify({ success: true, mode: mode }) + }); + } + + return res.writeHead(302, { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + "Set-Cookie": `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`, + "Location": "/" + }).end(); + }); + + router.get(/^\/strict\/(0|1)$/, async (req, res) => { + const urlStr = req.url.pathname || req.url; + const strict = +urlStr.split("/")[2] === 1; + + if (req.session) { + console.log(`[DEBUG] Setting strict mode to ${strict} for user ${req.session.id}`); + req.session.strict_mode = strict; + + await db` + insert into "user_options" (user_id, strict_mode, mode, theme, fullscreen) + values (${req.session.id}, ${strict}, ${req.session.mode ?? 0}, ${req.session.theme ?? (cfg.websrv.theme || 'f0ck')}, ${req.session.fullscreen ?? 0}) + on conflict ("user_id") do update set + strict_mode = excluded.strict_mode + `; + } else { + console.log(`[DEBUG] No session found for strict toggle!`); + } + + return res.reply({ + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ success: true, strict: strict }) + }); + }); + + + return router; +}; diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs index ab252e0..cbf37bc 100644 --- a/src/inc/routes/mod.mjs +++ b/src/inc/routes/mod.mjs @@ -16,12 +16,16 @@ export default (router, tpl) => { // Moderator Dashboard router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => { const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c; + const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c; + const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c; res.reply({ body: tpl.render("mod", { session: req.session, pendingCount: parseInt(pendingCount), + trashCount: parseInt(trashCount), + reportsCount: parseInt(reportsCount) || 0, manualApproval: getManualApproval(), tmp: null }, req) @@ -39,11 +43,31 @@ export default (router, tpl) => { }); // Approval Queue (View only — GET is safe, no state change) + // Tag badge classes for queue cards (shared by the approval queue and soft-deleted views) + const processQueueItems = (items) => items.map(p => ({ + ...p, + tags: (p.tags || []) + .filter(t => t.tag !== null) + .map(t => { + let badge = "badge-light"; + if (t.tag.startsWith(">")) badge = "badge-greentext badge-light"; + else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light"; + else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light"; + else if (t.normalized === "german") badge = "badge-german badge-light"; + else if (t.normalized === "dutch") badge = "badge-dutch badge-light"; + else if (t.normalized === "sfw") badge = "badge-success"; + else if (t.normalized === "nsfw") badge = "badge-danger"; + return { ...t, badge }; + }) + })); + + const QUEUE_PAGE_SIZE = 20; + const queuePage = (req) => Math.max(1, +req.url.qs.page || 1); + + // Approval queue: uploads waiting for approval (not deleted) router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { - // View Queue - const page = +req.url.qs.page || 1; - const limit = 20; - // Fetch Pending (not deleted) + const page = queuePage(req); + const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`; const pending = await db` select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags from "items" i @@ -52,12 +76,27 @@ export default (router, tpl) => { where i.active = false and i.is_deleted = false group by i.id order by i.id desc - limit ${limit} offset ${(page - 1) * limit} + limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE} `; - // Fetch Trash (deleted) + res.reply({ + body: tpl.render('mod/approve', { + pending: processQueueItems(pending), + total, + page, + pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)), + session: req.session, + tmp: null + }, req) + }); + }); + + // Soft deleted: removed items that still exist on disk and can be restored or purged + router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => { + const page = queuePage(req); + const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`; const trash = await db` - select i.id, i.mime, i.username, i.dest, + select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags, (select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason from "items" i @@ -66,45 +105,121 @@ export default (router, tpl) => { where i.active = false and i.is_deleted = true and i.is_purged = false group by i.id order by i.id desc - limit 20 + limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE} `; - const processItems = (items) => { - return items.map(p => { - const tags = (p.tags || []) - .filter(t => t.tag !== null) - .map(t => { - let badge = "badge-light"; - if (t.tag.startsWith(">")) badge = "badge-greentext badge-light"; - else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light"; - else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light"; - else if (t.normalized === "german") badge = "badge-german badge-light"; - else if (t.normalized === "dutch") badge = "badge-dutch badge-light"; - else if (t.normalized === "sfw") badge = "badge-success"; - else if (t.normalized === "nsfw") badge = "badge-danger"; - - return { ...t, badge }; - }); - - return { - ...p, - tags - }; - }); - }; - res.reply({ - body: tpl.render('mod/approve', { - pending: processItems(pending), - trash: processItems(trash), + body: tpl.render('mod/trash', { + trash: processQueueItems(trash), + total, page, - stats: { total: pending.length + trash.length }, + pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)), session: req.session, tmp: null }, req) }); }); + const jsonReply = (res, code, obj) => { + const body = JSON.stringify(obj); + return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); + }; + + // Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders. + const moveItemFilesToPublic = async (item, id) => { + const movePaths = [ + { b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) }, + { b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) } + ]; + + const isYouTube = item.mime === 'video/youtube'; + for (const p of movePaths) { + try { + if (isYouTube) { + await fs.access(p.t); + } else { + await fs.access(p.b); + } + console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`); + + const moveSafe = async (src, dst) => { + try { + const lstat = await fs.lstat(src); + if (lstat.isSymbolicLink()) { + const target = await fs.readlink(src); + const absTarget = path.resolve(path.dirname(src), target); + const relTarget = path.relative(path.dirname(dst), absTarget); + await fs.symlink(relTarget, dst); + await fs.unlink(src).catch(() => {}); + } else { + await fs.copyFile(src, dst); + await fs.unlink(src).catch(() => {}); + } + } catch (e) { + if (e.code !== 'ENOENT') { + console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message); + } + } + }; + + const bDst = path.join(cfg.paths.b, item.dest); + const tDst = path.join(cfg.paths.t, `${id}.webp`); + const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`); + const caDst = path.join(cfg.paths.ca, `${id}.webp`); + + if (!isYouTube) { + await moveSafe(p.b, bDst); + } + await moveSafe(p.t, tDst); + + const blurSrc = p.t.replace('.webp', '_blur.webp'); + await moveSafe(blurSrc, blurDst); + + if (item.mime.startsWith('audio')) { + await moveSafe(p.ca, caDst); + } + + if (item.is_album) { + try { + const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`; + for (const sub of subItems) { + const subBase = sub.dest.replace(/\.[^.]+$/, ''); + await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest)); + await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`)); + } + } catch (_) {} + } + break; + } catch (e) { } + } + }; + + // Permanently remove an item's files and comments and flag it purged (admin action). + const purgeItem = async (item, id) => { + await safeDeleteMediaFile(item.dest, id); + await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { }); + if (item.mime?.startsWith('audio')) { + await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { }); + } + await db`update "items" set is_purged = true where id = ${id}`; + await db`delete from comments where item_id = ${id}`; + }; + + const uploaderInfoFor = async (username) => { + try { + const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`; + return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {}; + } catch { return {}; } + }; + // F-005 Security: Approve action — POST with CSRF protection router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { const id = +(req.post?.id || 0); @@ -120,12 +235,12 @@ export default (router, tpl) => { where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id from "items" i - where i.id = ${id} and i.active = false + where i.id = ${id} and i.active = false and i.is_deleted = false limit 1 `; if (f0ck.length === 0) { - const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` }); + const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` }); return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } @@ -230,72 +345,7 @@ export default (router, tpl) => { } } - // Move files to public location - const movePaths = [ - { b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) }, - { b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) } - ]; - - const isYouTube = f0ck[0].mime === 'video/youtube'; - for (const p of movePaths) { - try { - if (isYouTube) { - await fs.access(p.t); - } else { - await fs.access(p.b); - } - console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`); - - const moveSafe = async (src, dst) => { - try { - const lstat = await fs.lstat(src); - if (lstat.isSymbolicLink()) { - const target = await fs.readlink(src); - const absTarget = path.resolve(path.dirname(src), target); - const relTarget = path.relative(path.dirname(dst), absTarget); - await fs.symlink(relTarget, dst); - await fs.unlink(src).catch(() => {}); - } else { - await fs.copyFile(src, dst); - await fs.unlink(src).catch(() => {}); - } - } catch (e) { - if (e.code !== 'ENOENT') { - console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message); - } - } - }; - - const bDst = path.join(cfg.paths.b, f0ck[0].dest); - const tDst = path.join(cfg.paths.t, `${id}.webp`); - const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`); - const caDst = path.join(cfg.paths.ca, `${id}.webp`); - - if (!isYouTube) { - await moveSafe(p.b, bDst); - } - await moveSafe(p.t, tDst); - - const blurSrc = p.t.replace('.webp', '_blur.webp'); - await moveSafe(blurSrc, blurDst); - - if (f0ck[0].mime.startsWith('audio')) { - await moveSafe(p.ca, caDst); - } - - if (f0ck[0].is_album) { - try { - const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`; - for (const sub of subItems) { - const subBase = sub.dest.replace(/\.[^.]+$/, ''); - await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest)); - await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`)); - } - } catch (_) {} - } - break; - } catch (e) { } - } + await moveItemFilesToPublic(f0ck[0], id); if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" }); @@ -320,26 +370,11 @@ export default (router, tpl) => { if (item.is_deleted) { // PURGE LOGIC (Strict Admin) if (!req.session.admin) { - return res.reply({ success: false, msg: "Only admins can purge items permanently." }); + const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." }); + return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } - // Delete files — respect symlink ownership - await safeDeleteMediaFile(item.dest, id); - await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { }); - if (item.mime?.startsWith('audio')) { - await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { }); - } - - // DB Flag instead of delete - await db`update "items" set is_purged = true where id = ${id}`; - // Delete comments permanently on purge - await db`delete from comments where item_id = ${id}`; + await purgeItem(item, id); // Fetch uploader details for audit log let uploaderInfo = {}; @@ -701,26 +736,66 @@ export default (router, tpl) => { } }); + // ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ── + + // Restore a soft-deleted item: back to public, no "approved" notification or webhook + router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => { + const id = +(req.post?.id || 0); + if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' }); + + const rows = await db` + select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc, + (select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id + where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id + from "items" i + where i.id = ${id} and i.is_deleted = true and i.is_purged = false + limit 1 + `; + if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` }); + const item = rows[0]; + + const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`; + if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' }); + + await moveItemFilesToPublic(item, id); + await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) }); + + // Live grid update so the item reappears for open tabs + if ((item.visibility || 0) === 0) { + db`SELECT pg_notify('new_item', ${JSON.stringify({ + id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id, + is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0 + })})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err)); + } + + return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' }); + }); + + // Permanently purge one soft-deleted item (admins only) + router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => { + const id = +(req.post?.id || 0); + if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' }); + const reason = (req.post?.reason || '').toString().trim(); + if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' }); + + const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`; + if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` }); + const item = rows[0]; + + await purgeItem(item, id); + await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) }); + return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' }); + }); + // Purge Trash (POST) - Strict Admin - router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => { + router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => { try { // lib.auth already ensures session.admin const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`; let count = 0; for (const item of trash) { try { - await safeDeleteMediaFile(item.dest, item.id); - await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { }); - if (item.mime?.startsWith('audio')) { - await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); - await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { }); - } - - await db`update "items" set is_purged = true where id = ${item.id}`; - // Delete comments permanently on purge - await db`delete from comments where item_id = ${item.id}`; + await purgeItem(item, item.id); count++; } catch (e) { } } diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 7c32cdc..3ff07e8 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -1,7 +1,7 @@ import db from "../sql.mjs"; import cfg from "../config.mjs"; import lib from "../lib.mjs"; -import { isAnonymizeSession } from "../settings.mjs"; +import { isAnonymizeSession, canAnonDo, getSessionOwnerName } from "../settings.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; import fs from "fs/promises"; import path from "path"; @@ -174,6 +174,9 @@ export default (router, tpl) => { const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); + // Rating may also be changed by an anonymous uploader on their own item + const _ownerName = getSessionOwnerName(session); + data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.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]))); data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 20d61d5..99fd4fa 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -47,6 +47,7 @@ export const DEFAULT_ANON_PERMISSIONS = Object.freeze({ rate_item: false, filter: true, exclude_tags: true, + chan: false, // 4chan viewer & rehost ("4chan mode") anonymize_users: false, allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'], allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf'] @@ -124,6 +125,37 @@ export const isAnonSession = (session) => { return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))); }; +// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value). +// config `onara: false` disables it for everyone; `onara: true` is only the default for users +// without a stored preference. `forceOn` covers explicit requests like ?onara=1. +export const isOnaraEnabledFor = (req, forceOn = false) => { + const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + if (c === false) return false; + if (forceOn) return true; + const ck = req?.cookies || {}; + const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara; + if (raw !== undefined) return raw === '1' || raw === 'true'; + if (req?.session && req.session.onara !== undefined && req.session.onara !== null) return !!req.session.onara; + return c === true; +}; + +// The name items.username holds for this session's uploads. Anonymous sessions have +// user = 'anonymous' (shared by all anon users); their real account is the shadow login (anon_xxxx). +export const getSessionOwnerName = (session) => { + if (!session || typeof session !== 'object') return null; + if (session.is_anon) return session.anon_login || session.login || null; + return session.user || null; +}; + +// 4chan viewer & rehost access: admins, members of the '4chan' group, and anonymous +// users when anonymous_permissions.chan is enabled +export const canUseChan = (session) => { + if (!session || typeof session !== 'object') return false; + if (session.admin) return true; + if (Array.isArray(session.groups) && session.groups.includes('4chan')) return true; + return !!(session.is_anon && canAnonDo('chan')); +}; + export const checkAnonPermission = (session, action) => { if (!isAnonSession(session)) return true; return canAnonDo(action); diff --git a/src/index.mjs b/src/index.mjs index 269f178..932bf4d 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -21,7 +21,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; @@ -647,6 +647,28 @@ process.on('uncaughtException', err => { await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`); await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`); + // Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters + // all reference that id, and tags_assign has a FK on tags.id + if (cfg.enable_nsfl) { + const nsflId = parseInt(cfg.nsfl_tag_id, 10); + if (nsflId > 0) { + try { + const [byId] = await db`SELECT id, normalized FROM tags WHERE id = ${nsflId} LIMIT 1`; + const [byName] = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`; + if (byId && byId.normalized !== 'nsfl') { + console.warn(`[BOOT] nsfl_tag_id ${nsflId} points to tag "${byId.normalized}", not "nsfl" — check config`); + } else if (!byId && byName) { + console.warn(`[BOOT] NSFL tag exists as id ${byName.id} but nsfl_tag_id is ${nsflId} — set nsfl_tag_id: ${byName.id} in config`); + } else if (!byId) { + await db`INSERT INTO tags (id, tag) VALUES (${nsflId}, 'nsfl') ON CONFLICT DO NOTHING`; + console.log(`[BOOT] Created missing NSFL rating tag (id ${nsflId})`); + } + } catch (e) { + console.error('[BOOT] NSFL tag check failed:', e.message); + } + } + } + // Initial halls cache (only if halls are enabled) if (cfg.websrv.halls_enabled !== false) { try { @@ -1026,7 +1048,7 @@ process.on('uncaughtException', err => { } // csrf_token is loaded from user_sessions table via the session query above if (req.session) { - req.session.can_chan = !!(req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'))); + req.session.can_chan = canUseChan(req.session); } // Ban check (Session) @@ -1955,9 +1977,11 @@ process.on('uncaughtException', err => { const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; return !!c; }, + // onara_cfg locks the per-user setting; only `onara: false` (feature disabled) does that. + // `onara: true` is just the default for users who haven't chosen. get onara_cfg() { const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - return (c !== undefined && c !== null) ? !!c : null; + return c === false ? false : null; }, is_onara_item: false, can_extract_meta: false, @@ -2120,7 +2144,7 @@ process.on('uncaughtException', err => { const activeSession = activeReq?.session || data?.session || null; const isAnonymized = isAnonymizeSession(activeSession); const anonAnonymize = getAnonAnonymize(); - const canChan = !!(activeSession && (activeSession.admin || (Array.isArray(activeSession.groups) && activeSession.groups.includes('4chan')))); + const canChan = canUseChan(activeSession); if (activeSession && typeof activeSession === 'object') { activeSession.can_chan = canChan; } @@ -2135,6 +2159,13 @@ process.on('uncaughtException', err => { is_anonymized: isAnonymized, anon_anonymize: anonAnonymize, can_chan: canChan, + // Item partials read can_rate_item; routes that only set can_manage_item fall back to it + can_rate_item: !!(data && (data.can_rate_item ?? data.can_manage_item)), + // Moderator-area page (/mod…): the back bar points to the moderator dashboard instead of /admin + // Includes are rendered without req; keep the value the page render already computed + is_mod_area: (data && typeof data.is_mod_area === 'boolean') + ? data.is_mod_area + : !!(activeReq?.url?.pathname && /^\/mod(\/|$)/.test(activeReq.url.pathname)), item_has_dimensions: !!(data && data.item && data.item.width && data.item.height), user_alternative_infobox: useAltInfobox, user_alternative_steuerung: useAltSteuerung, @@ -2169,15 +2200,10 @@ process.on('uncaughtException', err => { data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload; + // Onara is a per-user setting; config `onara: false` disables it, `onara: true` is only the default const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - const cookieOnara = activeReq.cookies?.f0ck_onara !== undefined - ? (activeReq.cookies.f0ck_onara === '1' || activeReq.cookies.f0ck_onara === 'true') - : (activeReq.cookies?.onara !== undefined ? (activeReq.cookies.onara === '1' || activeReq.cookies.onara === 'true') : null); - const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) - ? !!cfgOnara - : (cookieOnara !== null ? cookieOnara : !!activeReq.session?.onara); - data.onara = effectiveOnara; - data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null; + data.onara = isOnaraEnabledFor(activeReq); + data.onara_cfg = cfgOnara === false ? false : null; } else { data.recaptcha_enabled = perRequestRecaptcha; data.theme = data.theme || cfg.websrv.theme || 'f0ck'; @@ -2186,8 +2212,8 @@ process.on('uncaughtException', err => { globals.csrf_token = data.csrf_token || ''; const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - data.onara = !!cfgOnara; - data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null; + data.onara = cfgOnara === true; + data.onara_cfg = cfgOnara === false ? false : null; } return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req); diff --git a/views/admin.html b/views/admin.html index 31bf86c..6b9f87a 100644 --- a/views/admin.html +++ b/views/admin.html @@ -1,136 +1,230 @@ @include(snippets/header)
-
-

ADMINBEREICH

-
Hallo, {{ session.user }}
- Hier entsteht eine Internetpräsenz!
-
-

f0ck stats: @if(typeof totals !== "undefined")total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}@endif

-
-
-

Adminwerkzeuge

- -
+
+ - -
- -

Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg — max 5 MB.

- -
- -
- @if(current_brand_image) - current brand - @else - No image set - @endif -
- - -
- - - -
-
- + +
+
+

Settings

+
-
- - -
-
- -

If enabled, mods must approve every upload.

+

Uploads

+
+
+
+ +

If enabled, mods must approve every upload.

+
+ +
+
+
+ +

Minimum number of tags required per upload.

+
+ +
+
+
+ +

Users with fewer approved uploads than this still need manual approval, even if the toggle above is off. 0 disables it.

+
+
-
- - @if(registration_web_toggle_enabled) -
-
- -

Allow registration without invite tokens. Requires email activation.

+

Registration

+
+
+
+ +

Allow registration without invite tokens. Requires email activation.

+
+
-
@endif - -
-
- -

Minimum number of tags required per upload.

+

Branding

+
+
+
+ +

Shown in the navbar instead of the site name. gif, jpg, png, webp or svg, max 5 MB.

+
+
+
+ @if(current_brand_image) + current brand + @else + No image set + @endif +
+ + + +
- +
-
-
- -

New users with fewer than this many approved uploads must still go through manual approval, even if the global toggle is off. Set to 0 to disable.

+

Maintenance

+
+
+
+ +

Regenerate thumbnails for the /tags page. Runs in the background and may take a while.

+
+
-
- - -
-
- -

Regenerate thumbnails for the /tags page. This may take a while.

-
- -
- - - -
+
- + @include(snippets/adm-dashboard-style)
-@include(snippets/footer) \ No newline at end of file +@include(snippets/footer) diff --git a/views/admin/about.html b/views/admin/about.html index 6d68aba..14c872f 100644 --- a/views/admin/about.html +++ b/views/admin/about.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)

About Page Content

This text is displayed on the /about page. Supports Markdown. Leave empty to show the default static template.

diff --git a/views/admin/bans.html b/views/admin/bans.html index ef40da1..1e4c5d7 100644 --- a/views/admin/bans.html +++ b/views/admin/bans.html @@ -2,6 +2,7 @@
+ @include(snippets/admin-back) -
-
+ @include(snippets/footer) diff --git a/views/admin/chat.html b/views/admin/chat.html index fed356f..e96b7bf 100644 --- a/views/admin/chat.html +++ b/views/admin/chat.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)

ADMINBEREICH

Global Chat Management
diff --git a/views/admin/cleanup.html b/views/admin/cleanup.html index a21e9eb..16da040 100644 --- a/views/admin/cleanup.html +++ b/views/admin/cleanup.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)

Cleanup Manager

Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.

diff --git a/views/admin/emojis.html b/views/admin/emojis.html index d103392..2d6a2a0 100644 --- a/views/admin/emojis.html +++ b/views/admin/emojis.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)

Custom Emojis & Sticker Packs

diff --git a/views/admin/halls.html b/views/admin/halls.html index c33e0a3..e780fec 100644 --- a/views/admin/halls.html +++ b/views/admin/halls.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)
+ @include(snippets/admin-back)
+ + @if(p.has_media) +
+ +
+ @if(p.fsize){{ p.fsize }}@endif + @if(p.w && p.h){{ p.w }}×{{ p.h }}@endif +
+ + +
+ @if(p.local_id) + + + Rehosted /{{ p.local_path || p.local_id }} + + + + @else + + @endif + +
+
+ @endif +
@if(p.has_media) @@ -107,44 +144,11 @@ @endif @if(p.local_id) - #{{ p.local_id }} + /{{ p.local_path || p.local_id }} @endif
-
- -
- @if(p.fsize){{ p.fsize }}@endif - @if(p.w && p.h){{ p.w }}×{{ p.h }}@endif -
- - -
- @if(p.local_id) - - - Rehosted #{{ p.local_id }} - - - - @else - - @endif - -
-
@endif @@ -176,7 +180,7 @@ margin-bottom: 25px; background: var(--bg-card, rgba(25, 25, 25, 0.85)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); - border-radius: 12px; + border-radius: 0; padding: 16px 20px; backdrop-filter: blur(8px); } @@ -192,7 +196,7 @@ align-items: center; gap: 8px; padding: 6px 14px; - border-radius: 8px; + border-radius: 0; background: rgba(255, 255, 255, 0.06); color: var(--text-main, #fff); border: 1px solid rgba(255, 255, 255, 0.1); @@ -216,7 +220,7 @@ align-items: center; gap: 6px; padding: 6px 12px; - border-radius: 8px; + border-radius: 0; font-size: 0.85rem; font-weight: 600; background: rgba(255, 255, 255, 0.06); @@ -272,7 +276,7 @@ display: inline-flex; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; + border-radius: 0; padding: 3px; gap: 3px; } @@ -280,7 +284,7 @@ background: none; border: none; padding: 5px 12px; - border-radius: 6px; + border-radius: 0; font-size: 0.82rem; font-weight: 600; color: #aaa; @@ -301,7 +305,7 @@ .chan-post-card { background: var(--bg-card, rgba(25, 25, 25, 0.85)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); - border-radius: 10px; + border-radius: 0; padding: 14px 18px; transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s; scroll-margin-top: 80px; @@ -388,7 +392,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { align-items: center; gap: 4px; padding: 2px 7px; - border-radius: 4px; + border-radius: 0; background: rgba(74, 222, 128, 0.2); color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.4); @@ -411,7 +415,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { font-size: 0.68rem; font-weight: 700; padding: 2px 6px; - border-radius: 4px; + border-radius: 0; backdrop-filter: blur(4px); display: inline-flex; align-items: center; @@ -506,7 +510,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { width: 100%; aspect-ratio: 16 / 10; background: #0d0d0d; - border-radius: 8px; + border-radius: 0; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.08); } @@ -549,7 +553,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { right: 6px; width: 24px; height: 24px; - border-radius: 50%; + border-radius: 0; background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(4px); color: #fff; @@ -566,9 +570,29 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { /* File Info (filename, dimensions, size) */ .chan-file-info { display: flex; - flex-direction: column; - gap: 4px; + flex-direction: row; + align-items: center; + gap: 10px; width: 100%; + min-width: 0; + margin-bottom: 8px; +} +.chan-file-info .chan-filename-row { + flex: 0 1 auto; + min-width: 0; +} +.chan-file-info .chan-file-meta-row { + flex-shrink: 0; + white-space: nowrap; +} +.chan-file-info .chan-rehost-container { + flex-shrink: 0; + margin-left: auto; + width: auto; +} +.chan-file-info .chan-rehost-btn { + width: auto; + flex: 0 0 auto; } .chan-filename-row { display: flex; @@ -617,7 +641,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { align-items: center; gap: 6px; padding: 4px 10px; - border-radius: 6px; + border-radius: 0; font-size: 0.78rem; font-weight: 600; background: rgba(74, 222, 128, 0.15); @@ -671,7 +695,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { width: 28px; min-width: 28px; padding: 0; - border-radius: 6px; + border-radius: 0; font-size: 0.85rem; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); @@ -715,7 +739,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { gap: 4px 6px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 6px; + border-radius: 0; padding: 2px 7px; font-size: 0.78rem; max-width: 100%; @@ -743,7 +767,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { font-size: 0.78rem; cursor: pointer; padding: 1px 5px; - border-radius: 4px; + border-radius: 0; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); transition: all 0.15s ease; @@ -773,7 +797,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { background: #333; color: #333; padding: 0 3px; - border-radius: 2px; + border-radius: 0; text-decoration: none; cursor: pointer; transition: color 0.15s; @@ -793,7 +817,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { overflow-y: auto; background: rgba(22, 22, 24, 0.98); border: 1px solid #4ade80; - border-radius: 10px; + border-radius: 0; padding: 12px 16px; box-shadow: 0 12px 35px rgba(0, 0, 0, 0.8), 0 0 20px rgba(74, 222, 128, 0.2); pointer-events: none; @@ -823,6 +847,14 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img { .chan-quote-preview-popup .chan-post-backlinks { display: none !important; } + +/* Style guide: no rounded corners anywhere in the thread (covers site CSS, inline and JS-inserted elements) */ +.chan-thread-page, +.chan-thread-page *, +.chan-thread-page *::before, +.chan-thread-page *::after { + border-radius: 0 !important; +}
+ {{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}} + @include(snippets/mod-cards) +
-@include(snippets/footer) \ No newline at end of file +@include(snippets/footer) diff --git a/views/mod/audit.html b/views/mod/audit.html index 96e4391..e44f7a7 100644 --- a/views/mod/audit.html +++ b/views/mod/audit.html @@ -1,5 +1,6 @@ @include(snippets/header)
+ @include(snippets/admin-back)

AUDIT LOG

Actions performed by moderators and admins.

diff --git a/views/mod/motd.html b/views/mod/motd.html index 4d14be8..64b45dd 100644 --- a/views/mod/motd.html +++ b/views/mod/motd.html @@ -1,6 +1,7 @@ @include(snippets/header)
+ @include(snippets/admin-back)

Moderator Message of the Day (MOTD)

This message is displayed site-wide. It will automatically be tagged with your name (t. {!! session.display_name || session.user !!}).

diff --git a/views/mod/trash.html b/views/mod/trash.html new file mode 100644 index 0000000..a090b82 --- /dev/null +++ b/views/mod/trash.html @@ -0,0 +1,138 @@ +@include(snippets/header) + +
+
+ @include(snippets/admin-back) +
+
+
+

Soft Deleted

+
{{ total }} removed item@if(total !== 1)s@endif still on disk. Restore makes an item public again@if(session.admin); purge deletes it and its files for good@endif.
+
+ @if(trash.length > 0 && session.admin) + + @endif +
+ + + @if(trash.length > 0) +
+ @each(trash as post) +
+
+ @if(post.mime === 'video/youtube') +
+ +
+ @elseif(post.mime.startsWith('video')) + + @elseif(post.mime === 'application/pdf') +
+ +
+ @else + Preview + @endif +
+
+
+ #{!! post.id !!} + by {!! post.username !!} + {!! post.mime !!} +
+ @if(post.delete_reason) +
Reason: {!! post.delete_reason !!}
+ @endif +
+ @each(post.tags as tag) + {!! tag.tag !!} + @endeach +
+
+ + @if(session.admin) + + @else + + @endif + +
+
+
+ @endeach +
+ @else +
Nothing soft deleted.Removed items show up here until they are restored or purged.
+ @endif + + @if(pages > 1) +
+ @if(page > 1) + « Prev + @endif + Page {!! page !!} of {!! pages !!} + @if(page < pages) + Next » + @endif +
+ @endif +
+ {{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}} + @include(snippets/mod-cards) + +
+
+@include(snippets/footer) diff --git a/views/mod_reports.html b/views/mod_reports.html index 163c453..13e10e9 100644 --- a/views/mod_reports.html +++ b/views/mod_reports.html @@ -2,6 +2,7 @@
+ @include(snippets/admin-back) diff --git a/views/snippets/admin-back.html b/views/snippets/admin-back.html new file mode 100644 index 0000000..c76b8e1 --- /dev/null +++ b/views/snippets/admin-back.html @@ -0,0 +1,81 @@ +{{-- Back-to-dashboard bar for admin/mod subpages: /mod… pages (and non-admins) go to /mod, /admin… pages to /admin. Include right after #main opens (AJAX nav only swaps #main). --}} + + +@include(snippets/admin-skin) + + + + diff --git a/views/snippets/admin-skin.html b/views/snippets/admin-skin.html new file mode 100644 index 0000000..721ad65 --- /dev/null +++ b/views/snippets/admin-skin.html @@ -0,0 +1,87 @@ +{{-- Admin/mod subpage skin (style guide: square, flat, theme colours). Loaded by snippets/admin-back, scoped to pages that show the back bar, except the .mq card pages which have their own design. --}} + diff --git a/views/snippets/footer.html b/views/snippets/footer.html index f4a9c5e..45b35e7 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -68,36 +68,45 @@ @endif @if(!private_society || session)