From 4e857fd5ef46b808705c25785be518db32df2c7c Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 20 Sep 2026 21:43:02 +0200 Subject: [PATCH] bvcx --- config_example.yaml | 278 +++++++++++ public/s/css/f0ckm.css | 170 +++++++ public/s/js/admin.js | 72 ++- public/s/js/f0ckm.js | 420 +++++++++++++--- public/s/js/scroller.js | 7 - public/s/js/sidebar-activity.js | 244 ++++++++- public/s/js/user.js | 72 ++- src/inc/lib.mjs | 20 + src/inc/routes/admin.mjs | 43 +- src/inc/routes/ajax.mjs | 22 +- src/inc/routes/apiv2/index.mjs | 20 +- src/inc/routes/chan.mjs | 626 +++++++++++++++++++++++ src/inc/routes/external.mjs | 228 +++++++-- src/inc/routes/index.mjs | 11 +- src/inc/routes/user_halls.mjs | 7 +- src/index.mjs | 18 +- views/admin/users.html | 34 ++ views/admin/users_list.html | 1 + views/chan/catalogue.html | 480 ++++++++++++++++++ views/chan/thread.html | 855 ++++++++++++++++++++++++++++++++ views/item-partial-legacy.html | 26 +- views/item-partial-modern.html | 23 +- views/snippets/footer.html | 21 + views/snippets/info-modal.html | 4 +- views/snippets/item-media.html | 4 +- views/snippets/navbar.html | 3 + 26 files changed, 3567 insertions(+), 142 deletions(-) create mode 100644 config_example.yaml create mode 100644 src/inc/routes/chan.mjs create mode 100644 views/chan/catalogue.html create mode 100644 views/chan/thread.html diff --git a/config_example.yaml b/config_example.yaml new file mode 100644 index 0000000..2fbfb97 --- /dev/null +++ b/config_example.yaml @@ -0,0 +1,278 @@ +main: + url: + full: https://example.com + domain: example.com + regex: example\.com + onion: http://your-onion-address.onion + socks: socks5://127.0.0.1:9050 + mail: admin@example.com + maxfilesize: 104857600 + adminmultiplier: 3.5 + upload_limit: 300 + ignored: + - example.net + - example.org + invite_secret: YOUR_SECRET_HERE + hide_comments_from_public: false + guest_anonymize: false + anon_anonymize: false + timezone: UTC + development: true +allowedModes: + - sfw + - nsfw + - untagged + - all + - nsfl +enable_pdf: false +enable_nsfl: false +enable_comments: true +enable_private_uploads: true +enable_expiring_uploads: true +default_upload_visibility: 0 +allow_user_upload_visibility: true +enable_item_slugs: true +enable_anonymous_access: true +anonymous_permissions: + upload: false + comment: true + comment_attachments: false + comment_vote: true + poll_vote: true + tag: true + tag_vote: true + favorite: true + rate_item: false + filter: true + exclude_tags: true + anonymize_users: false + allowed_modes: + - sfw + - nsfw + - untagged + - all + - nsfl + allowed_mimes: + - image + - video + - audio + - flash + - pdf +onara: false +nsfl_tag_id: 4 +allowedMimes: + - audio + - image + - video + - application/x-shockwave-flash + - application/vnd.adobe.flash.movie + - application/zip + - application/x-zip-compressed + - application/x-rar-compressed + - application/vnd.rar + - application/x-rar + - application/rar + - application/x-rar-archive + - application/x-7z-compressed + - application/x-tar + - application/gzip + - application/x-bzip2 + - application/x-xz +nsfp: + - 2 + - 3 + - 4 +websrv: + port: "1337" + language: en + allow_language_change: true + cache: false + eps: 155 + default_thumb_size: m + background: true + log_user_ips: false + hash_user_ips: true + description: Example Description + themes: + - amoled + theme: amoled + default_font: "" + default_layout: legacy + custom_favicon: /s/img/favicon.gif + custom_brand_image: [] + custom_navbar_brand_text: "" + show_koepfe: false + hide_sidebar_default: true + koepfe: [] + enable_tor_hs: true + enable_global_chat: true + enable_danmaku: true + private_messages: true + dm_attachments: true + dm_unencrypted: false + dm_attachment_expiry_days: 90 + halls_enabled: true + userhalls_enabled: true + enable_userhall_image_upload: true + enable_oc: true + abyss_enabled: true + meme_creator: true + enable_cleanup: false + enable_data_export: true + inactivity_ban_days: 60 + enable_user_api_keys: true + enable_user_invites: true + user_invite_slots: 2 + invite_criteria: + uploads: 10 + age_days: 10 + comments: 10 + tags: 10 + cleanup_timeframe_days: 30 + web_url_upload: true + enable_youtube_upload: true + web_meta_extraction: true + bypass_duplicate_check: true + shitpost_mode: false + shitpost_require_rating: false + shitpost_min_tags: 0 + protect_files: false + enable_dynamic_thumbs: false + allowed_comment_images: + - i.imgur.com + - tenor.com + - giphy.com + show_mime_picker: true + embed_youtube_in_comments: true + allow_fileupload_comments: true + allow_comment_deletion: false + enable_comment_polls: false + fileupload_comments_multifile: true + fileupload_comments_size: 104857600 + fileupload_comments_max: 5 + fileupload_comments_mode: attachment + fileupload_comments_mimes: + - image + - video + - audio + show_content_warning: true + default_comment_display_mode: 1 + phrases: + - Hello World + ban_video: "" + enable_xd_score: false + enable_autoplay: false + enable_swiping: true + enable_profile_description: true + user_alternative_infobox: false + user_banner_enabled: true + comment_banner_enabled: true + comment_banner_max_height: 300 + user_alternative_steuerung: false + expose_repost_links_to_guests: false + enable_swf: false + swf_thumb: /s/img/swf.png + enable_archive: true + archive_thumb: /s/img/archive.webp + enable_item_title: true + open_registration: true + open_registration_web_toggle: false + open_registration_require_mail_andor_token: false + private_society: false + private_society_gate: cloudflare + private_society_gate_location: Frankfurt + private_society_gate_template: _gate_template + public_nsfw: false + public_untagged: false + paths: + images: /b + thumbnails: /t + coverarts: /ca + emojis: /emojis + memes: /memes +clients: + - type: tg + enabled: false + token: "" + pollrate: 1001 + - type: matrix + enabled: false + baseUrl: https://matrix.org + token: "" + userId: "@user:matrix.org" + channels: [] + upload_channel_id: "" + notification_channel_id: "" + - type: irc + enabled: false + network: example + host: irc.example.net + port: 6697 + ssl: true + selfSigned: true + sasl: false + nickname: bot + username: bot + password: "" + realname: bot + channels: + - "#example" +sql: + host: localhost + port: 5432 + user: f0ckm + password: f0ckm + database: f0ckm + multipleStatements: true + max: 50 +admins: + - {} +mimes: + image/png: png + video/webm: webm + image/gif: gif + image/jpg: jpg + image/jpeg: jpeg + image/webp: webp + video/mp4: mp4 + video/quicktime: mp4 + audio/mpeg: mpg + audio/mp3: mp3 + audio/ogg: ogg + audio/opus: opus + audio/flac: flac + audio/x-flac: flac + audio/mp4: m4a + audio/x-m4a: m4a + audio/aac: m4a + video/x-m4v: mp4 + video/x-matroska: mkv + application/x-shockwave-flash: swf + application/vnd.adobe.flash.movie: swf + application/pdf: pdf + application/zip: zip + application/x-zip-compressed: zip + application/x-rar-compressed: rar + application/vnd.rar: rar + application/x-rar: rar + application/rar: rar + application/x-rar-archive: rar + application/x-7z-compressed: 7z + application/x-tar: tar + application/gzip: gz + application/x-bzip2: bz2 + application/x-xz: xz +apis: {} +smtp: + enabled: false + host: smtp.example.com + port: 465 + secure: true + user: smtp_user + password: smtp_password + from: admin@example.com + mail_reset_password: false +recaptcha: + enabled: false + site_key: YOUR_RECAPTCHA_V2_SITE_KEY + secret_key: YOUR_RECAPTCHA_V2_SECRET_KEY diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 5a9a3a6..80026d9 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -7317,6 +7317,20 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid { position: relative; } +.location a, +.location .location-link { + color: inherit; + text-decoration: none; + cursor: pointer; + transition: color 0.15s ease; +} + +.location a:hover, +.location .location-link:hover { + color: #4ade80; + text-decoration: underline; +} + .item_title, .item-title, .item-itle { @@ -22424,4 +22438,160 @@ div#flash, .audio-track-artist { font-size: 0.68rem; } +} + +/* Chan Rehost Details Modal */ +.chan-rehost-modal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(6px); + z-index: 100200; + display: flex; + align-items: center; + justify-content: center; +} +.chan-rehost-modal-content { + background: #18181b; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + width: 92%; + max-width: 480px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.7); + padding: 22px 24px; + text-align: left; + box-sizing: border-box; +} +.chan-rehost-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} +.chan-rehost-modal-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + gap: 8px; +} +.chan-rehost-modal-post-link { + font-size: 0.85rem; + color: #4ade80; + text-decoration: none; + font-weight: 600; + margin-left: 6px; +} +.chan-rehost-modal-post-link:hover { + text-decoration: underline; +} +.chan-rehost-modal-close { + background: none; + border: none; + color: #888; + font-size: 1.4rem; + cursor: pointer; + padding: 2px 6px; + line-height: 1; +} +.chan-rehost-modal-close:hover { + color: #fff; +} +.chan-rehost-form-group { + margin-bottom: 16px; +} +.chan-rehost-form-group label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: #aaa; + margin-bottom: 7px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.chan-rehost-ratings { + display: flex; + gap: 8px; +} +.chan-rehost-pill { + flex: 1; + padding: 8px 4px; + font-size: 0.82rem; + font-weight: 600; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #ccc; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; +} +.chan-rehost-pill:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.chan-rehost-pill.active.untagged { + background: #3f3f46; + border-color: #71717a; + color: #fff; +} +.chan-rehost-pill.active.sfw { + background: #15803d; + border-color: #22c55e; + color: #fff; +} +.chan-rehost-pill.active.nsfw { + background: #b91c1c; + border-color: #ef4444; + color: #fff; +} +.chan-rehost-pill.active.nsfl { + background: #450a0a; + border-color: #991b1b; + color: #fca5a5; +} +.chan-rehost-input { + width: 100%; + box-sizing: border-box; + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 9px 12px; + color: #fff; + font-family: inherit; + font-size: 0.88rem; + outline: none; + transition: border-color 0.15s; +} +.chan-rehost-input:focus { + border-color: #3b82f6; +} +.chan-rehost-textarea { + resize: vertical; + min-height: 56px; +} +.chan-rehost-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} +.chan-rehost-edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} +.chan-rehost-edit-btn:hover { + background: rgba(255, 255, 255, 0.2) !important; } \ No newline at end of file diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 0ccc225..391bcba 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -6,7 +6,7 @@ const infoEl = document.querySelector("#a_info"); const idLinkEl = document.querySelector("a.id-link"); - const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; + const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; if (!rawId) return null; const tagsContainer = document.querySelector("#tags"); @@ -292,17 +292,79 @@ } return; } + const favoBtns = document.querySelectorAll("#a_favo"); + const firstFavoBtn = favoBtns[0]; + const chanRehostBtn = document.querySelector('#chan-item-rehost-btn'); + const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn; + const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url; + let localId = firstFavoBtn?.dataset?.localId; + + // If viewing un-rehosted 4chan post, auto-rehost first + if (isChan && !localId && chanUrl) { + favoBtns.forEach(btn => { + btn.className = 'iconset fa-solid fa-spinner fa-spin'; + }); + try { + const csrfToken = window.f0ckSession?.csrf_token || ''; + const rehostResp = await fetch('/api/v2/scroller/rehost', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: new URLSearchParams({ + url: chanUrl, + original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '', + width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '', + height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || '' + }) + }); + const rehostData = await rehostResp.json(); + if (rehostData.success && rehostData.item_id) { + localId = rehostData.item_id; + favoBtns.forEach(btn => { + btn.dataset.itemId = localId; + btn.dataset.localId = localId; + }); + const commentsEl = document.querySelector("#comments-container"); + if (commentsEl) commentsEl.dataset.itemId = localId; + const infoEl = document.querySelector("#a_info"); + if (infoEl) infoEl.dataset.itemId = localId; + + if (chanRehostBtn) { + chanRehostBtn.classList.add('rehosted'); + chanRehostBtn.outerHTML = ` + + #${localId} + + + `; + } + } else { + throw new Error(rehostData.msg || 'Rehost failed'); + } + } catch (err) { + console.error('[CHAN-FAV-ADMIN] Auto-rehost error:', err); + favoBtns.forEach(btn => { + btn.className = 'iconset fa-regular fa-heart'; + }); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Failed to favorite post', 3000, 'error'); + } + return; + } + } + const ctx = getContext(); if (!ctx) return; - const { postid } = ctx; + const postid = localId ? Number(localId) : ctx.postid; - // Read state BEFORE the API call so we have a fallback - const favoBtns = document.querySelectorAll("#a_favo"); const wasAlreadyFav = favoBtns.length > 0 && favoBtns[0].classList.contains('fa-solid'); try { const res = await post('/api/v2/togglefav', { - postid: postid + postid: postid, + chan_url: chanUrl }); if (res && res.success) { if (window.invalidateItemCache) { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 3ced171..f2bd1ca 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1438,7 +1438,7 @@ window.cancelAnimFrame = (function () { gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); - const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); + 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) { const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); @@ -1502,7 +1502,7 @@ window.cancelAnimFrame = (function () { gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); - const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); + 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) { const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); @@ -2331,8 +2331,11 @@ window.cancelAnimFrame = (function () { try { const p = new URL(pathOrUrl, window.location.origin).pathname; const parts = p.split('/').filter(Boolean); + if (parts[0] === '4') { + return parts.length >= 4 && /^\d+$/.test(parts[3]); + } return !p.match(/\/p\//) && ( - /^\/\d+/.test(p) || + /^\/\d+(?:[?#]|$)/.test(p) || /^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/.test(p) || (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || (parts.length >= 2 && parts[0] === 'favs' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) @@ -3334,7 +3337,16 @@ window.cancelAnimFrame = (function () { return; } - fetch(ajaxUrl, { credentials: 'include', priority: 'low', signal }) + fetch(ajaxUrl, { + credentials: 'include', + priority: 'low', + headers: { + 'X-Purpose': 'prefetch', + 'Purpose': 'prefetch', + 'Sec-Purpose': 'prefetch' + }, + signal + }) .then(r => r.ok ? r.text() : null) .then(rawText => { if (!rawText || signal.aborted) return; @@ -11084,6 +11096,11 @@ window.cancelAnimFrame = (function () { // Immediately restore scrollability and hide modals if (window.resetGlobalScrollState) window.resetGlobalScrollState(); if (window.hideAllModals) window.hideAllModals(); + const targetPath = new URL(url, window.location.origin).pathname; + if (targetPath.startsWith('/4/')) { + window.location.href = url; + return; + } const willNavigateToItem = typeof isItemPath === 'function' ? isItemPath(url) : false; if (!isOnaraActive() || !willNavigateToItem) { if (window.closeOnaraModal) window.closeOnaraModal({ skipHistory: true }); @@ -11132,10 +11149,10 @@ window.cancelAnimFrame = (function () { if (window.closeImageModal) window.closeImageModal(); // Auto-clear notification highlight if opening an item - const itemIdMatch = url.match(/\/(\d+)(\?.*|#.*)?$/); + const itemIdMatch = url.match(/\/([a-zA-Z0-9_-]{11}|\d+)(?:\?.*|#.*)?$/); if (itemIdMatch) { const itemId = itemIdMatch[1]; - document.querySelectorAll(`a.thumb.has-notif[href$="/${itemId}"]`).forEach(el => { + document.querySelectorAll(`a.thumb.has-notif[href$="/${itemId}"], a.thumb.has-notif[data-item-id="${itemId}"]`).forEach(el => { el.classList.remove('has-notif'); }); } @@ -11174,12 +11191,7 @@ window.cancelAnimFrame = (function () { const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/); const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); - const parts = pathname.split('/').filter(Boolean); - const isItem = !pathname.match(/\/p\//) && ( - pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || - (parts.length >= 2 && parts[0] === 'favs' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) - ); + const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname)); const isMessages = !!pathname.match(/^\/messages(\/|$)/); const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss'; const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isAbyss; @@ -12197,6 +12209,11 @@ window.cancelAnimFrame = (function () { // Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...) // Split path, filter numeric, pop last. + const isChanUrl = /^\/4\/([a-z0-9_]+)\/(\d+)\/(\d+)/i.test(new URL(url, window.location.origin).pathname); + if (isChanUrl) { + inheritContext = false; + } + const pathSegments = new URL(url, window.location.origin).pathname.split('/'); const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); @@ -12265,7 +12282,7 @@ window.cancelAnimFrame = (function () { // Prev/next arrows in item view always use explicit /h/foo/ or /tag/foo/ hrefs // (via link.main), so they are unaffected by this guard. // Note: Notification links call loadItemAjax(href, false) to skip inheritance directly. - if (inheritContext) { + if (inheritContext && !isChanUrl) { if (!tag) { const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/); if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]); @@ -12300,61 +12317,68 @@ window.cancelAnimFrame = (function () { try { // Construct AJAX URL let ajaxUrl = `/ajax/item/${itemid}`; - - const params = new URLSearchParams(); - params.append('mode', window.activeMode); - const targetHash = new URL(url, window.location.origin).hash.replace(/^#/, '').trim(); - if (targetHash && !targetHash.startsWith('c')) params.append('subf0ck', targetHash); - if (tag) params.append('tag', tag); - if (hall) params.append('hall', hall); - if (userHall && userHallOwner) { - params.append('userHall', userHall); - params.append('userHallOwner', userHallOwner); - } else if (user) { - params.append('user', user); - } - if (tagger) params.append('tagger', tagger); - if (isFavs) { - params.append('fav', 'true'); - if (!user && window.f0ckGuestFavs) { - const guestIds = window.f0ckGuestFavs.get(); - if (guestIds.length > 0) { - params.append('ids', guestIds.slice(0, 100).join(',')); + if (isChanUrl) { + const urlObj = new URL(url, window.location.origin); + ajaxUrl = urlObj.pathname + (urlObj.search ? urlObj.search + '&ajax=1' : '?ajax=1'); + } else { + const params = new URLSearchParams(); + params.append('mode', window.activeMode); + const targetHash = new URL(url, window.location.origin).hash.replace(/^#/, '').trim(); + if (targetHash && !targetHash.startsWith('c')) params.append('subf0ck', targetHash); + if (tag) params.append('tag', tag); + if (hall) params.append('hall', hall); + if (userHall && userHallOwner) { + params.append('userHall', userHall); + params.append('userHallOwner', userHallOwner); + } else if (user) { + params.append('user', user); + } + if (tagger) params.append('tagger', tagger); + if (isFavs) { + params.append('fav', 'true'); + if (!user && window.f0ckGuestFavs) { + const guestIds = window.f0ckGuestFavs.get(); + if (guestIds.length > 0) { + params.append('ids', guestIds.slice(0, 100).join(',')); + } } } - } - const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); + const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); - if (isStrict || url.includes('strict=1') || window.location.search.includes('strict=1')) { - params.append('strict', '1'); - } - - // Note: mime is no longer appended to params, server reads from cookie automatically - // _t cache-buster removed — stable URL enables both HTTP and in-memory caching. + if (isStrict || url.includes('strict=1') || window.location.search.includes('strict=1')) { + params.append('strict', '1'); + } + + // Note: mime is no longer appended to params, server reads from cookie automatically + // _t cache-buster removed — stable URL enables both HTTP and in-memory caching. - const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1'); - if (isRandom) { - params.append('random', '1'); - } + const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1'); + if (isRandom) { + params.append('random', '1'); + } - // Explicit overrides for context (used for immediate mode switches) - if (options.forceParams) { - Object.keys(options.forceParams).forEach(key => { - params.set(key, options.forceParams[key]); - }); - } + // Explicit overrides for context (used for immediate mode switches) + if (options.forceParams) { + Object.keys(options.forceParams).forEach(key => { + params.set(key, options.forceParams[key]); + }); + } - if (useOnara) { - params.append('onara', '1'); - } + if (useOnara) { + params.append('onara', '1'); + } - if (params.toString() !== '') { - ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + params.toString(); + if (params.toString() !== '') { + ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + params.toString(); + } } // ── Stale-while-revalidate item cache ────────────────────────────────── // cacheKey is the stable ajaxUrl (no _t), scoped to mode+context. + if (isChanUrl) { + options.skipCache = true; + } const _itemCacheKey = ajaxUrl; const _cachedItem = !options.skipCache ? itemCacheMap.get(_itemCacheKey) : null; @@ -12524,7 +12548,7 @@ window.cancelAnimFrame = (function () { window.f0ckDebug('[itemCache] Cache hit render complete, revalidating in background...'); // ── Background revalidation — silently refresh cache for next visit ── - fetch(ajaxUrl, { credentials: 'include' }) + fetch(ajaxUrl, { credentials: 'include', keepalive: true }) .then(r => r.ok ? r.text() : null) .then(freshText => { if (!freshText) return; @@ -12578,7 +12602,10 @@ window.cancelAnimFrame = (function () { window.f0ckDebug(`[CLIENT_DEBUG] Using pre-fetched data (skipped network fetch)`); } else { // ── Normal fetch path ──────────────────────────────────────────── - const response = await fetch(ajaxUrl, { credentials: 'include' }); + const response = await fetch(ajaxUrl, { + credentials: 'include', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } + }); const tHeaders = performance.now(); if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); @@ -12802,7 +12829,10 @@ window.cancelAnimFrame = (function () { const itemKey = (window.f0ckSession?.enable_item_slugs !== false && responseSlug) ? responseSlug : (responseSlug || itemid); let pushUrl = `/${itemKey}`; // Logic from ajax.mjs context reconstruction: - if (userHall && userHallOwner) { + if (isChanUrl) { + const u = new URL(url, window.location.origin); + pushUrl = u.pathname + u.search + u.hash; + } else if (userHall && userHallOwner) { pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`; } else if (user) { pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; @@ -12815,13 +12845,13 @@ window.cancelAnimFrame = (function () { // Append tagger filter so item nav stays in tagger context if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`; - if (mime) { + if (!isChanUrl && mime) { // If it already has itemKey at the end, insert mime before it pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`); } // Re-append hash if present - if (hash) pushUrl += hash; + if (hash && !isChanUrl) pushUrl += hash; if (!options.keepMedia && !options.skipPush) { // We overwrite proper URL even if the link clicked was "naked" @@ -12844,7 +12874,16 @@ window.cancelAnimFrame = (function () { if (window.initVisualizer) window.initVisualizer(); } // Try to extract ID from response if possible or just use itemid - document.title = `${window.f0ckDomain} - ${itemKey}`; + if (isChanUrl) { + const chanM = new URL(url, window.location.origin).pathname.match(/^\/4\/([a-z0-9_]+)\/(\d+)\/(\d+)/i); + if (chanM) { + document.title = `/4/${chanM[1]}/${chanM[2]} #${chanM[3]} - ${window.f0ckDomain || 'f0ck'}`; + } else { + document.title = `${window.f0ckDomain} - #${itemid}`; + } + } else { + document.title = `${window.f0ckDomain} - ${itemKey}`; + } window.f0ckDebug("AJAX load complete"); // Notify extensions — also triggers CommentSystem init which renders comments @@ -13324,7 +13363,23 @@ window.cancelAnimFrame = (function () { return false; } - if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) { + if (!isSpecialLink && ( + (!pathname.startsWith('/4/') && ( + pathname === '/' || + pathname.startsWith('/halls') || + pathname.startsWith('/h/') || + pathname.startsWith('/notifications') || + pathname.startsWith('/tag') || + pathname.startsWith('/user/') || + pathname.match(/^\/(image|video|audio)/) || + pathname.match(/\/p\/\d+/) || + pathname.match(/^\/\d+(?:[?#]|$)/) || + pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || + pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || + pathname.startsWith('/abyss') + )) || + (pathname.startsWith('/4/') && isItemPath(pathname)) + )) { e.preventDefault(); e.stopImmediatePropagation(); @@ -13348,7 +13403,244 @@ window.cancelAnimFrame = (function () { } } - if (target.closest('#togglebg')) { + window.openChanRehostModal = function({ itemId, isPlayerView = false, onSave = null }) { + if (!itemId) return; + + let modal = document.getElementById('chan-rehost-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'chan-rehost-modal'; + modal.className = 'modal-overlay chan-rehost-modal'; + modal.style.display = 'none'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` + + `; + + modal.style.display = 'flex'; + document.body.classList.add('modal-open'); + + const ratingGroup = modal.querySelector('#chan-rehost-rating-group'); + let selectedRating = 'untagged'; + + ratingGroup.addEventListener('click', (e) => { + const pill = e.target.closest('.chan-rehost-pill'); + if (!pill) return; + ratingGroup.querySelectorAll('.chan-rehost-pill').forEach(b => b.classList.remove('active')); + pill.classList.add('active'); + selectedRating = pill.dataset.rating; + }); + + const tagsInput = modal.querySelector('#chan-rehost-tags-input'); + const commentInput = modal.querySelector('#chan-rehost-comment-input'); + + // Pre-fill current rating and tags if available + fetch(`/api/v2/scroller/rehost/details/${itemId}`) + .then(r => r.json()) + .then(data => { + if (data && data.success) { + if (data.rating) { + selectedRating = data.rating; + ratingGroup.querySelectorAll('.chan-rehost-pill').forEach(b => { + b.classList.toggle('active', b.dataset.rating === data.rating); + }); + } + if (data.tags && data.tags.length) { + tagsInput.value = data.tags.join(', '); + } + } + }).catch(() => {}); + + setTimeout(() => tagsInput.focus(), 100); + + const closeModal = () => { + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + document.removeEventListener('keydown', handleKeydown); + }; + + const handleKeydown = (e) => { + if (e.key === 'Escape') closeModal(); + }; + document.addEventListener('keydown', handleKeydown); + + modal.querySelector('.chan-rehost-modal-close').onclick = closeModal; + modal.querySelector('.chan-rehost-modal-cancel').onclick = closeModal; + modal.onclick = (e) => { + if (e.target === modal) closeModal(); + }; + + const saveDetails = async (andView = false) => { + const saveBtn = modal.querySelector('.chan-rehost-modal-save'); + const saveViewBtn = modal.querySelector('.chan-rehost-modal-save-view'); + if (saveBtn) saveBtn.disabled = true; + if (saveViewBtn) saveViewBtn.disabled = true; + + try { + const csrfToken = window.f0ckSession?.csrf_token || ''; + const resp = await fetch('/api/v2/scroller/rehost/details', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: new URLSearchParams({ + item_id: itemId, + rating: selectedRating, + tags: tagsInput.value.trim(), + comment: commentInput.value.trim() + }) + }); + + const resData = await resp.json(); + if (resData.success) { + if (typeof window.showToast === 'function') { + window.showToast('Details saved for #' + itemId + '!'); + } + closeModal(); + if (typeof onSave === 'function') { + onSave(resData); + } + if (andView) { + if (typeof window.loadItemAjax === 'function') { + window.loadItemAjax('/' + itemId); + } else { + window.location.href = '/' + itemId; + } + } + } else { + alert(resData.msg || 'Failed to save details'); + if (saveBtn) saveBtn.disabled = false; + if (saveViewBtn) saveViewBtn.disabled = false; + } + } catch (err) { + console.error('[REHOST-MODAL] Save error:', err); + alert('Error saving details: network error'); + if (saveBtn) saveBtn.disabled = false; + if (saveViewBtn) saveViewBtn.disabled = false; + } + }; + + modal.querySelector('.chan-rehost-modal-save').onclick = () => saveDetails(false); + if (isPlayerView && modal.querySelector('.chan-rehost-modal-save-view')) { + modal.querySelector('.chan-rehost-modal-save-view').onclick = () => saveDetails(true); + } + + tagsInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveDetails(false); + } + }); + }; + + if (target.closest('#chan-item-rehost-btn')) { + e.preventDefault(); + const btn = target.closest('#chan-item-rehost-btn'); + if (btn && !btn.classList.contains('loading') && !btn.classList.contains('rehosted')) { + const mediaUrl = btn.dataset.url; + if (mediaUrl) { + btn.classList.add('loading'); + const origHtml = btn.innerHTML; + btn.innerHTML = ' Rehosting…'; + + (async () => { + try { + const csrfToken = window.f0ckSession?.csrf_token || ''; + const resp = await fetch('/api/v2/scroller/rehost', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: new URLSearchParams({ + url: mediaUrl, + original_filename: btn.dataset.filename || btn.dataset.title || '', + width: btn.dataset.width || '', + height: btn.dataset.height || '' + }) + }); + + const data = await resp.json(); + if (data.success && data.item_id) { + btn.classList.remove('loading'); + btn.classList.add('rehosted'); + btn.outerHTML = ` + + + #${data.item_id} + + + + `; + if (typeof window.showToast === 'function') { + window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!'); + } + if (typeof window.openChanRehostModal === 'function') { + window.openChanRehostModal({ itemId: data.item_id, isPlayerView: true }); + } + } else { + btn.classList.remove('loading'); + btn.innerHTML = origHtml; + alert(data.msg || data.message || 'Rehost failed'); + } + } catch (err) { + console.error('[CHAN-REHOST] Error:', err); + btn.classList.remove('loading'); + btn.innerHTML = origHtml; + alert('Rehost failed due to network error'); + } + })(); + } + } + } else if (target.closest('.chan-rehost-edit-btn')) { + e.preventDefault(); + const editBtn = target.closest('.chan-rehost-edit-btn'); + const itemId = editBtn?.dataset?.itemId; + if (itemId && typeof window.openChanRehostModal === 'function') { + const isPlayer = !!document.querySelector('.media-object'); + window.openChanRehostModal({ itemId: Number(itemId), isPlayerView: isPlayer }); + } + } else if (target.closest('#togglebg')) { e.preventDefault(); window.toggleBackground(); } else if (target.closest('#toggleautoplay')) { diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index 15cb2f5..f2d0de0 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -1936,10 +1936,6 @@ const origClass = icon.className; icon.className = 'fa-solid fa-spinner'; - let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw')); - if (item.external_board === 'wsg') rating = 'sfw'; - else if (item.external_board === 'gif') rating = 'nsfw'; - try { const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || ''; const resp = await fetch('/api/v2/scroller/rehost', { @@ -1950,9 +1946,6 @@ }, body: new URLSearchParams({ url: item.external_media_url || item.dest, - rating: rating, - tags: '', - comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`, ...(item.original_filename ? { original_filename: item.original_filename } : {}) }) }); diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index bc70f51..13d89b7 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -1559,9 +1559,11 @@ if (txt) ids.add(txt); } - // 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId) + // 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId, /4/:board/:thread/:post) const segments = window.location.pathname.split('/').filter(Boolean); - if (segments.length >= 2 && segments[0] === 'tag') { + if (segments.length >= 4 && segments[0] === '4') { + ids.add(decodeURIComponent(segments[3]).trim()); + } else if (segments.length >= 2 && segments[0] === 'tag') { if (segments[2]) { ids.add(decodeURIComponent(segments[2]).trim()); } @@ -1576,7 +1578,7 @@ ids.add(decodeURIComponent(segments[2]).trim()); } } else if (segments.length === 1) { - const forbidden = ['s', 'b', 't', 'ca', 'a', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p']; + const forbidden = ['s', 'b', 't', 'ca', 'a', '4', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p']; if (!forbidden.includes(segments[0])) { ids.add(decodeURIComponent(segments[0]).trim()); } @@ -2089,20 +2091,232 @@ }, { passive: true }); }; + // ── Dedicated 4chan Thread Feed (mirrors Tag/Favs Feed for /4/:board/:thread context) ───── + let currentChanBoard = null; + let currentChanThread = null; + let chanFeedLoaded = false; + let chanFeedLoading = false; + + const getCurrentChanContext = () => { + const m = window.location.pathname.match(/^\/4\/([a-z0-9_]+)\/(\d+)(?:\/(\d+))?/i); + if (m) { + return { + board: m[1].toLowerCase(), + thread: m[2], + post: m[3] || null + }; + } + return null; + }; + + const highlightActiveChanCard = (scrollIntoView = false) => { + const chanCtx = getCurrentChanContext(); + const container = document.getElementById('sidebar-chan-container'); + if (!container || !chanCtx) return; + + const currentPost = chanCtx.post || ''; + let matchedCard = null; + + container.querySelectorAll('.sidebar-video-card').forEach(card => { + const cardId = card.dataset.id ? String(card.dataset.id).trim() : ''; + const isMatch = (currentPost && cardId === currentPost); + if (isMatch) { + card.classList.add('active-tag-item'); + if (!matchedCard) matchedCard = card; + } else { + card.classList.remove('active-tag-item'); + } + }); + + if (matchedCard && scrollIntoView) { + const isChanTabActive = document.querySelector('.sidebar-tab.active')?.dataset.tab === 'chan' && + container.style.display !== 'none'; + if (isChanTabActive) { + try { + const cr = container.getBoundingClientRect(); + const mr = matchedCard.getBoundingClientRect(); + const targetScrollTop = container.scrollTop + (mr.top - cr.top) - (container.clientHeight / 2) + (mr.height / 2); + container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' }); + } catch (_) { + matchedCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + } + }; + + const loadChanFeed = async (reset = false) => { + const chanCtx = getCurrentChanContext(); + const container = document.getElementById('sidebar-chan-container'); + const itemsContainer = container?.querySelector('.sidebar-chan-items-container'); + if (!container || !itemsContainer || chanFeedLoading) return; + if (!chanCtx) return; + + chanFeedLoading = true; + + if (reset) { + itemsContainer.innerHTML = ` + + `; + } + + try { + const res = await fetch(`/api/v2/chan/${encodeURIComponent(chanCtx.board)}/${encodeURIComponent(chanCtx.thread)}/media`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + const data = await res.json(); + const items = data.items || data.media || []; + + if (data.success && items.length > 0) { + chanFeedLoaded = true; + + // Update header + const countEl = container.querySelector('.sidebar-chan-count'); + if (countEl) countEl.textContent = `(${items.length})`; + + const nameEl = container.querySelector('.sidebar-chan-name'); + if (nameEl) nameEl.textContent = `/${chanCtx.board}/${chanCtx.thread}`; + + const overviewBtn = container.querySelector('.sidebar-chan-overview-btn'); + if (overviewBtn) { + overviewBtn.href = `/4/${chanCtx.board}/${chanCtx.thread}`; + } + + let html = ''; + items.forEach(v => { + const isActive = chanCtx.post && String(v.no) === String(chanCtx.post); + const activeClass = isActive ? ' active-tag-item' : ''; + const targetHref = `/4/${chanCtx.board}/${chanCtx.thread}/${v.no}`; + const formatBadge = v.ext ? `${escapeHtml(v.ext.replace('.', '').toUpperCase())}` : ''; + const displayTitle = v.filename ? `${v.filename}` : `#${v.no}`; + const overlayIcon = v.is_video ? 'fa-solid fa-play' : 'fa-solid fa-image'; + const fsizeStr = v.fsize ? `${(v.fsize / 1024 / 1024).toFixed(1)} MB` : ''; + + html += ` + + `; + }); + + itemsContainer.innerHTML = html; + setTimeout(() => highlightActiveChanCard(true), 60); + } else if (reset) { + itemsContainer.innerHTML = ` + + `; + } + } catch (err) { + console.error('[CHAN-FEED] Failed:', err); + if (reset) { + itemsContainer.innerHTML = ``; + } + } finally { + chanFeedLoading = false; + } + }; + + const updateChanTabVisibility = (forceSwitchToChan = false) => { + const chanCtx = getCurrentChanContext(); + const chanBtn = document.getElementById('sidebar-tab-chan'); + if (!chanBtn) return; + + if (chanCtx) { + chanBtn.style.display = 'inline-flex'; + chanBtn.title = `/${chanCtx.board}/${chanCtx.thread}`; + + if (currentChanBoard !== chanCtx.board || currentChanThread !== chanCtx.thread) { + currentChanBoard = chanCtx.board; + currentChanThread = chanCtx.thread; + chanFeedLoaded = false; + userManuallySelectedNonTagTab = false; + switchSidebarTab('chan'); + } else { + if (forceSwitchToChan || (!userManuallySelectedNonTagTab && document.querySelector('.sidebar-tab.active')?.dataset.tab !== 'chan')) { + switchSidebarTab('chan'); + } else { + highlightActiveChanCard(true); + } + } + } else { + chanBtn.style.display = 'none'; + currentChanBoard = null; + currentChanThread = null; + chanFeedLoaded = false; + + const activeTab = document.querySelector('.sidebar-tab.active')?.dataset.tab; + if (activeTab === 'chan') { + if (getCurrentTag()) { + switchSidebarTab('tag'); + } else { + switchSidebarTab('recommendations'); + } + } + } + }; + + const bindChanContainerEvents = () => { + const container = document.getElementById('sidebar-chan-container'); + if (!container || container.dataset.eventsBound === 'true') return; + container.dataset.eventsBound = 'true'; + container.addEventListener('click', (e) => { + const link = e.target.closest('a.sidebar-video-link'); + if (link && link.href) { + e.preventDefault(); + container.querySelectorAll('.sidebar-video-card').forEach(c => c.classList.remove('active-tag-item')); + const card = link.closest('.sidebar-video-card'); + if (card) card.classList.add('active-tag-item'); + if (typeof window.loadItemAjax === 'function') { + window.loadItemAjax(link.href, false); + } else { + window.location.href = link.href; + } + } + }); + }; + const switchSidebarTab = (tabName) => { if (window.clearHoverPreview) { window.clearHoverPreview(); } const normalizedTab = (tabName === 'videos' || tabName === 'recommendations') ? 'recommendations' - : (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : tabName)); + : (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : (tabName === 'chan' ? 'chan' : tabName))); const tabs = document.querySelectorAll('.sidebar-tab'); const contents = document.querySelectorAll('.sidebar-tab-content'); tabs.forEach(t => { const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations') ? 'recommendations' - : (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : t.dataset.tab)); + : (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : (t.dataset.tab === 'chan' ? 'chan' : t.dataset.tab))); if (tTab === normalizedTab) { t.classList.add('active'); } else { @@ -2113,7 +2327,7 @@ contents.forEach(c => { const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations') ? 'recommendations' - : (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : c.dataset.tabContent)); + : (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : (c.dataset.tabContent === 'chan' ? 'chan' : c.dataset.tabContent))); if (cTab === normalizedTab) { c.classList.add('active'); c.style.display = 'block'; @@ -2143,6 +2357,12 @@ } else { setTimeout(() => highlightActiveFavsCard(true), 60); } + } else if (normalizedTab === 'chan') { + if (!chanFeedLoaded) { + loadChanFeed(true); + } else { + setTimeout(() => highlightActiveChanCard(true), 60); + } } }; window.switchSidebarTab = switchSidebarTab; @@ -2154,7 +2374,7 @@ const btn = e.target.closest('.sidebar-tab'); if (btn && btn.dataset.tab) { e.preventDefault(); - if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs') { + if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs' && btn.dataset.tab !== 'chan') { userManuallySelectedNonTagTab = true; } else { userManuallySelectedNonTagTab = false; @@ -2170,7 +2390,9 @@ savedTab = localStorage.getItem('sidebar_active_tab') || 'comments'; } catch (_) {} - if (getCurrentFavsUser()) { + if (getCurrentChanContext()) { + switchSidebarTab('chan'); + } else if (getCurrentFavsUser()) { switchSidebarTab('favs'); } else if (getCurrentTag()) { switchSidebarTab('tag'); @@ -2410,22 +2632,28 @@ initTagOrderButton(); bindTagContainerEvents(); bindFavsContainerEvents(); + bindChanContainerEvents(); updateTagTabVisibility(); updateFavsTabVisibility(); + updateChanTabVisibility(); document.addEventListener('f0ck:contentLoaded', () => { updateTagTabVisibility(); updateFavsTabVisibility(); + updateChanTabVisibility(); setTimeout(() => { highlightActiveTagCard(true); highlightActiveFavsCard(true); + highlightActiveChanCard(true); }, 60); }); window.addEventListener('popstate', () => { setTimeout(() => { updateTagTabVisibility(); updateFavsTabVisibility(); + updateChanTabVisibility(); highlightActiveTagCard(true); highlightActiveFavsCard(true); + highlightActiveChanCard(true); }, 60); }); bindRecommendationEvents(); diff --git a/public/s/js/user.js b/public/s/js/user.js index 2bacd77..a31d01c 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -6,7 +6,7 @@ const infoEl = document.querySelector("#a_info"); const idLinkEl = document.querySelector("a.id-link"); - const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; + const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; if (!rawId) return null; const tagsContainer = document.querySelector("#tags"); @@ -256,17 +256,79 @@ } return; } + const favoBtns = document.querySelectorAll("#a_favo"); + const firstFavoBtn = favoBtns[0]; + const chanRehostBtn = document.querySelector('#chan-item-rehost-btn'); + const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn; + const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url; + let localId = firstFavoBtn?.dataset?.localId; + + // If viewing un-rehosted 4chan post, auto-rehost first + if (isChan && !localId && chanUrl) { + favoBtns.forEach(btn => { + btn.className = 'iconset fa-solid fa-spinner fa-spin'; + }); + try { + const csrfToken = window.f0ckSession?.csrf_token || ''; + const rehostResp = await fetch('/api/v2/scroller/rehost', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: new URLSearchParams({ + url: chanUrl, + original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '', + width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '', + height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || '' + }) + }); + const rehostData = await rehostResp.json(); + if (rehostData.success && rehostData.item_id) { + localId = rehostData.item_id; + favoBtns.forEach(btn => { + btn.dataset.itemId = localId; + btn.dataset.localId = localId; + }); + const commentsEl = document.querySelector("#comments-container"); + if (commentsEl) commentsEl.dataset.itemId = localId; + const infoEl = document.querySelector("#a_info"); + if (infoEl) infoEl.dataset.itemId = localId; + + if (chanRehostBtn) { + chanRehostBtn.classList.add('rehosted'); + chanRehostBtn.outerHTML = ` + + #${localId} + + + `; + } + } else { + throw new Error(rehostData.msg || 'Rehost failed'); + } + } catch (err) { + console.error('[CHAN-FAV] Auto-rehost error:', err); + favoBtns.forEach(btn => { + btn.className = 'iconset fa-regular fa-heart'; + }); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Failed to favorite post', 3000, 'error'); + } + return; + } + } + const ctx = getContext(); if (!ctx) return; - const { postid } = ctx; + const postid = localId ? Number(localId) : ctx.postid; - // Read state BEFORE the API call so we have a fallback - const favoBtns = document.querySelectorAll("#a_favo"); const wasAlreadyFav = favoBtns.length > 0 && favoBtns[0].classList.contains('fa-solid'); try { const res = await post('/api/v2/togglefav', { - postid: postid + postid: postid, + chan_url: chanUrl }); if (res && res.success) { if (window.invalidateItemCache) { diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index c2d4978..b9480f4 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -544,6 +544,26 @@ export default new class { return next(); }; + async chanAuth(req, res, next) { + if (!req.session || !req.session.user) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: "Unauthorized" }), type: 'application/json' }); + } + return res.redirect('/login'); + } + const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')); + if (!hasGroup) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' }); + } + return res.reply({ + code: 403, + body: `Access Denied
🍀

Access Restricted

This 4chan viewer is only accessible to users with the 4chan group.

← Return to Home
` + }); + } + return next(); + }; + // Middleware: authenticate via X-Api-Key header (upload-only) async apiKeyAuth(req, res, next) { const key = req.headers['x-api-key']; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 5fde7b4..471afc9 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -1165,7 +1165,7 @@ export default (router, tpl) => { WITH ghost_users AS ( SELECT NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email, - to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated, + to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, ARRAY[]::text[] as groups, true as activated, NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method FROM items i WHERE i.username IS NOT NULL AND i.username != '' @@ -1240,7 +1240,7 @@ export default (router, tpl) => { users = await db` WITH filtered_users AS ( SELECT - u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated, + u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.groups, u.activated, uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode, (SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method FROM "user" u @@ -1446,6 +1446,45 @@ export default (router, tpl) => { } }); + router.post(/^\/api\/v2\/admin\/users\/set-groups\/?$/, lib.auth, async (req, res) => { + try { + const { user_id, groups } = req.post; + if (!user_id) throw new Error('Missing user_id'); + const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`; + if (!target.length) throw new Error('User not found.'); + + let groupsArr = []; + if (Array.isArray(groups)) { + groupsArr = groups.map(s => String(s).trim().toLowerCase()).filter(Boolean); + } else if (typeof groups === 'string') { + groupsArr = groups.split(',').map(s => s.trim().toLowerCase()).filter(Boolean); + } + + await db` + UPDATE "user" + SET groups = ${groupsArr} + WHERE id = ${+user_id} + `; + + // Invalidate target user's session cache and session table so new groups load immediately + await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`; + + await audit.log(req.session.id, 'admin_set_groups', 'user', +user_id, { + target_login: target[0].login, + groups: groupsArr + }); + + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: true, + groups: groupsArr, + msg: `Groups for "${target[0].login}" updated to: ${groupsArr.join(', ') || 'none'}` + })); + } catch (err) { + console.error('[ADMIN] Set groups failed:', err); + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); + } + }); + router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => { try { diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 78ca750..62cb3f6 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -17,6 +17,14 @@ export default (router, tpl) => { query = req.url.qs || {}; } + const isPrefetch = !!( + query.prefetch === '1' || + req.url?.qs?.prefetch === '1' || + req.headers?.['x-purpose'] === 'prefetch' || + req.headers?.['purpose'] === 'prefetch' || + req.headers?.['sec-purpose'] === 'prefetch' + ); + const isGuest = !req.session || !req.session.user; const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode); const ratingsRaw = req.cookies.ratings; @@ -122,7 +130,7 @@ export default (router, tpl) => { // xD Score + comments — parallelize subscription + comments fetch if (req.session || !cfg.main.hide_comments_from_public) { - if (req.session?.id) { + if (req.session?.id && !isPrefetch) { f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {}); } const [sub, commentsForScore] = await Promise.all([ @@ -296,6 +304,14 @@ export default (router, tpl) => { query = req.url.qs || {}; } + const isPrefetch = !!( + query.prefetch === '1' || + req.url?.qs?.prefetch === '1' || + req.headers?.['x-purpose'] === 'prefetch' || + req.headers?.['purpose'] === 'prefetch' || + req.headers?.['sec-purpose'] === 'prefetch' + ); + let contextUrl = `/${req.params.itemid}`; if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`; if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`; @@ -372,8 +388,8 @@ export default (router, tpl) => { // Comments are always loaded async by the client via /api/comments/:id to avoid // blocking the browser's main thread on posts with huge comment payloads. if (req.session || !cfg.main.hide_comments_from_public) { - // Mark notifications as read - if (req.session?.id) { + // Mark notifications as read (only when actually viewed, not on prefetch) + if (req.session?.id && !isPrefetch) { f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {}); } const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false; diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 34e4b83..a1bec5d 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -1866,12 +1866,30 @@ export default router => { // Support both numeric item ID and string slug const isNumeric = /^\d+$/.test(String(rawPostid)); - const itemRow = await db` + let itemRow = await db` SELECT id FROM items WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false LIMIT 1 `; + if (!itemRow.length) { + const chanUrl = req.post?.chan_url ?? req.body?.chan_url ?? req.post?.url ?? req.body?.url; + if (chanUrl) { + itemRow = await db` + SELECT id FROM items + WHERE src = ${chanUrl} AND active = true AND is_deleted = false + LIMIT 1 + `; + } + if (!itemRow.length && isNumeric) { + itemRow = await db` + SELECT id FROM items + WHERE (src LIKE ${'%/' + rawPostid + '.%'} OR src LIKE ${'%/' + rawPostid + 's.%'}) AND active = true AND is_deleted = false + LIMIT 1 + `; + } + } + if (!itemRow.length) { return res.json({ success: false, msg: 'Item not found' }, 404); } diff --git a/src/inc/routes/chan.mjs b/src/inc/routes/chan.mjs new file mode 100644 index 0000000..182f2a2 --- /dev/null +++ b/src/inc/routes/chan.mjs @@ -0,0 +1,626 @@ +import cfg from "../config.mjs"; +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import queue from "../queue.mjs"; + +/** + * chan.mjs — 4chan thread viewer & catalogue routes + * Restricted to users with the '4chan' group (and admins). + */ +export default (router, tpl) => { + + const COMMON_BOARDS = [ + { id: 'wsg', name: 'Worksafe GIF', icon: 'fa-film' }, + { id: 'gif', name: 'Adult GIF', icon: 'fa-video' }, + { id: 'b', name: 'Random', icon: 'fa-dice' }, + { id: 'v', name: 'Video Games', icon: 'fa-gamepad' }, + { id: 'vg', name: 'Video Game Generals', icon: 'fa-gamepad' }, + { id: 'a', name: 'Anime & Manga', icon: 'fa-tv' }, + { id: 'g', name: 'Technology', icon: 'fa-microchip' }, + { id: 'pol', name: 'Politically Incorrect', icon: 'fa-globe' }, + { id: 'tv', name: 'Television & Film', icon: 'fa-tv' }, + { id: 'mu', name: 'Music', icon: 'fa-music' }, + { id: 'fit', name: 'Fitness', icon: 'fa-dumbbell' }, + { id: 'ck', name: 'Food & Cooking', icon: 'fa-utensils' } + ]; + + /** + * 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 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)}`); + } + return JSON.parse(text); + } + + function formatComment(com) { + if (!com) return ''; + // Format quote lines + let formatted = com.replace(/(?:^|)((?:>|>)((?!>|>)[^\n<]+))/g, '$1>$2'); + // Format post cross-references >>123456 + formatted = formatted.replace(/(?:>|>)>(\d+)/g, '>>$1'); + return formatted; + } + + // ─────────────────────────────────────────────────────────────────────────── + // 0. GET /4 -> redirect to default board catalogue + // ─────────────────────────────────────────────────────────────────────────── + router.get(/^\/4\/?$/, lib.chanAuth, (req, res) => res.redirect('/4/wsg/catalogue')); + + // ─────────────────────────────────────────────────────────────────────────── + // 1. GET /4/:board/catalogue (and alias /4/:board/catalog) + // ─────────────────────────────────────────────────────────────────────────── + router.get(/^\/4\/(?[a-z0-9]+)\/(?:catalogue|catalog)\/?$/, lib.chanAuth, async (req, res) => { + const board = req.params.board.toLowerCase(); + + try { + const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`); + const threads = []; + for (const page of pages) { + for (const t of (page.threads || [])) { + threads.push({ + no: t.no, + sub: t.sub || '', + com: t.com ? t.com.replace(//gi, ' ').replace(/<[^>]+>/g, '').slice(0, 180) : '', + replies: t.replies || 0, + images: t.images || 0, + tim: t.tim, + ext: t.ext, + sticky: !!t.sticky, + closed: !!t.closed, + time: t.time, + thumb: t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : null + }); + } + } + + const opUrls = []; + threads.forEach(t => { + if (t.tim && t.ext) { + const ext = (t.ext || '').toLowerCase(); + opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${t.ext}`); + opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${ext}`); + opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${t.ext}`); + opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${ext}`); + opUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${t.tim}${ext}`); + } + }); + const rehosts = {}; + if (opUrls.length > 0) { + try { + const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`; + rows.forEach(r => { + rehosts[r.src] = r.id; + const m = r.src.match(/(\d{13,20})/); + if (m) rehosts[m[1]] = r.id; + }); + } catch (_) {} + } + 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; + }); + + const data = { + board, + threads, + common_boards: COMMON_BOARDS, + session: req.session ? { ...req.session } : false, + domain: cfg.main.url.domain, + tmp: null, + page_meta: { + title: `/${board}/ - Catalogue`, + description: `4chan /${board}/ thread catalogue`, + url: `https://${cfg.main.url.domain}/4/${board}/catalogue` + } + }; + + return res.reply({ + body: tpl.render('chan/catalogue', data, req) + }); + } catch (err) { + console.error(`[CHAN] Failed to fetch catalogue for /${board}/:`, err.message); + return res.reply({ + code: 500, + body: tpl.render('error', { + message: `Could not load catalogue for /${board}/. The board may not exist or 4chan is currently unreachable.`, + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false + }, req) + }); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. GET /4/:board/:thread — Overview of all posts in that thread + // ─────────────────────────────────────────────────────────────────────────── + router.get(/^\/4\/(?[a-z0-9]+)\/(?\d+)\/?$/, lib.chanAuth, async (req, res) => { + const board = req.params.board.toLowerCase(); + const tid = Number(req.params.thread); + + try { + const data = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`); + const posts = data.posts || []; + if (!posts.length) throw new Error('Empty thread data'); + + const op = posts[0]; + const mediaPosts = posts.filter(p => p.tim && p.ext); + + // Check which media URLs are already rehosted on this platform + const rehosts = {}; + const cdnUrls = []; + mediaPosts.forEach(p => { + const ext = (p.ext || '').toLowerCase(); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); + }); + if (cdnUrls.length > 0) { + try { + const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`; + rows.forEach(r => { + rehosts[r.src] = r.id; + const m = r.src.match(/(\d{13,20})/); + if (m) rehosts[m[1]] = r.id; + }); + } catch (e) { + console.error('[CHAN] Rehost check error:', e.message); + } + } + + // Find which rehosted items the current user has favorited + const userFavSet = new Set(); + const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean); + if (req.session?.id && rehostedIdList.length > 0) { + try { + const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`; + userFavs.forEach(f => userFavSet.add(Number(f.item_id))); + } catch (_) {} + } + + // Format posts for template + const formattedPosts = posts.map(p => { + const ext = (p.ext || '').toLowerCase(); + const isVideo = ['.webm', '.mp4'].includes(ext); + const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext); + const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null; + const localId = (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null; + + return { + no: p.no, + sub: p.sub || '', + com_raw: p.com || '', + com_formatted: formatComment(p.com || ''), + name: p.name || 'Anonymous', + trip: p.trip || '', + time: p.time, + timeago: lib.timeAgo(p.time * 1000, req.lang || 'en'), + date_str: p.now || new Date(p.time * 1000).toLocaleString(), + has_media: !!(p.tim && p.ext), + tim: p.tim, + ext: ext, + filename: p.filename ? `${p.filename}${ext}` : null, + w: p.w, + h: p.h, + fsize: p.fsize ? lib.formatSize(p.fsize) : null, + dest: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}` : null, + thumb: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg` : null, + external_media_url: externalMediaUrl, + is_video: isVideo, + is_image: isImage, + local_id: localId, + rehosted: !!localId, + user_has_favorited: localId ? userFavSet.has(Number(localId)) : false + }; + }); + + const firstMediaPost = formattedPosts.find(p => p.has_media); + + const viewData = { + board, + tid, + op, + posts: formattedPosts, + media_count: mediaPosts.length, + first_media_no: firstMediaPost ? firstMediaPost.no : null, + rehosts_count: Object.keys(rehosts).length, + session: req.session ? { ...req.session } : false, + domain: cfg.main.url.domain, + tmp: null, + page_meta: { + title: `/${board}/${tid} - ${op.sub || 'Thread Overview'}`, + description: op.sub || (op.com ? op.com.replace(/<[^>]+>/g, '').slice(0, 160) : `4chan /${board}/ thread ${tid}`), + url: `https://${cfg.main.url.domain}/4/${board}/${tid}` + } + }; + + return res.reply({ + body: tpl.render('chan/thread', viewData, req) + }); + } catch (err) { + console.error(`[CHAN] Failed to load thread /${board}/${tid}:`, err.message); + return res.reply({ + code: 404, + body: tpl.render('error', { + message: `Could not load thread /${board}/${tid}. It may be archived or 4chan is currently unreachable.`, + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false + }, req) + }); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. GET /4/:board/:thread/:post — Show media item in normal item view + // ─────────────────────────────────────────────────────────────────────────── + router.get(/^\/4\/(?[a-z0-9]+)\/(?\d+)\/(?\d+)\/?$/, lib.chanAuth, async (req, res) => { + const board = req.params.board.toLowerCase(); + const tid = Number(req.params.thread); + const postNo = Number(req.params.post); + + try { + const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`); + const posts = threadData.posts || []; + if (!posts.length) throw new Error('Empty thread data'); + + const op = posts[0]; + const mediaPosts = posts.filter(p => p.tim && p.ext); + if (!mediaPosts.length) { + // No media at all in thread, redirect to thread overview + return res.redirect(`/4/${board}/${tid}`); + } + + // Find target post + let targetPost = mediaPosts.find(p => p.no === postNo); + if (!targetPost) { + // Post has no media or doesn't exist; pick closest or first media post + targetPost = mediaPosts[0]; + } + + // Check rehost status in DB + const cdnUrls = []; + mediaPosts.forEach(p => { + const ext = (p.ext || '').toLowerCase(); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); + }); + const rehosts = {}; + if (cdnUrls.length > 0) { + try { + const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`; + rows.forEach(r => { + rehosts[r.src] = r.id; + const m = r.src.match(/(\d{13,20})/); + if (m) rehosts[m[1]] = r.id; + }); + } catch (e) { + console.error('[CHAN] Rehost check error:', e.message); + } + } + + const mediaIndex = mediaPosts.findIndex(p => p.no === targetPost.no); + const prevMedia = mediaIndex > 0 ? mediaPosts[mediaIndex - 1] : null; + const nextMedia = mediaIndex < mediaPosts.length - 1 ? mediaPosts[mediaIndex + 1] : null; + + const ext = (targetPost.ext || '').toLowerCase(); + const isVideo = ['.webm', '.mp4'].includes(ext); + const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext); + const externalMediaUrl = targetPost.tim ? `https://i.4cdn.org/${board}/${targetPost.tim}${ext}` : null; + const localId = (targetPost.tim && rehosts[targetPost.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null; + + let itemRating = null; + let itemTags = []; + let canManage = false; + let localDest = null; + let localThumb = null; + let localTitle = null; + let itemFavorites = []; + let userHasFavorited = false; + if (localId) { + try { + const localItem = await db`SELECT id, username, dest, title, mime, width, height FROM items WHERE id = ${localId} LIMIT 1`; + if (localItem.length > 0) { + const li = localItem[0]; + if (li.title) localTitle = li.title; + if (li.dest) { + localDest = `/b/${li.dest}`; + localThumb = `/t/${li.id}.webp`; + } + if (req.session) { + canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) || + req.session.admin || req.session.is_moderator); + } + } + itemTags = await lib.getTags(localId, req.session); + const ratingTag = itemTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + if (ratingTag) itemRating = ratingTag.normalized; + itemFavorites = await db` + select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint + from "favorites" + left join "user" on "user".id = "favorites".user_id + left join "user_options" on "user_options".user_id = "favorites".user_id + left join "anon_identities" on "anon_identities".user_id = "favorites".user_id + where "favorites".item_id = ${localId} + `; + userHasFavorited = lib.userHasFavorited(req.session, itemFavorites); + } catch (e) { + console.error('[CHAN] Failed to fetch tags for rehosted item:', e.message); + } + } + + // Construct item matching f0ckm's standard item view expectations + const item = { + id: targetPost.no, + slug: null, + title: localTitle || targetPost.sub || op.sub || `/${board}/${tid} #${targetPost.no}`, + dest: localDest || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}${ext}`, + thumbnail: localThumb || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}s.jpg`, + mime: isVideo ? (ext === '.mp4' ? 'video/mp4' : 'video/webm') : (ext === '.gif' ? 'image/gif' : (ext === '.png' ? 'image/png' : 'image/jpeg')), + width: targetPost.w || null, + height: targetPost.h || null, + size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B', + username: targetPost.name || 'Anonymous', + stamp: targetPost.time, + timestamp: { + timeago: lib.timeAgo(targetPost.time * 1000, req.lang || 'en'), + timefull: new Date(targetPost.time * 1000).toISOString() + }, + comment: targetPost.com ? targetPost.com.replace(//gi, '\n').replace(/<[^>]+>/g, '') : '', + is_chan: true, + external_board: board, + external_tid: tid, + external_id: targetPost.no, + external_media_url: externalMediaUrl, + original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null, + local_id: localId, + rehosted: !!localId, + is_sfw: itemRating === 'sfw', + is_nsfw: itemRating === 'nsfw', + is_nsfl: itemRating === 'nsfl', + is_untagged: !itemRating, + favorites: itemFavorites, + user_has_favorited: userHasFavorited, + halls: [], + user_halls: [], + tags: itemTags + }; + + // F0ckm convention: Next post (right arrow / D) corresponds to pagination.prev, + // Previous post (left arrow / A) corresponds to pagination.next. + const pagination = { + prev: nextMedia ? nextMedia.no : null, + next: prevMedia ? prevMedia.no : null + }; + + const link = { + main: `/4/${board}/${tid}/`, + mainDisplay: `/4/${board}/${tid}/`, + suffix: '' + }; + + // Find which rehosted items the current user has favorited + const userFavSet = new Set(); + const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean); + if (req.session?.id && rehostedIdList.length > 0) { + try { + const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`; + userFavs.forEach(f => userFavSet.add(Number(f.item_id))); + } catch (_) {} + } + + const chanMediaList = mediaPosts.map((p, idx) => { + const pExt = (p.ext || '').toLowerCase(); + const pVid = ['.webm', '.mp4'].includes(pExt); + const pUrl = `https://i.4cdn.org/${board}/${p.tim}${pExt}`; + const pLocalId = (p.tim && rehosts[p.tim]) || (pUrl && rehosts[pUrl]) || null; + return { + no: p.no, + tim: p.tim, + ext: pExt, + dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${pExt}`, + thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`, + is_video: pVid, + is_image: !pVid, + is_active: p.no === targetPost.no, + index: idx + 1, + local_id: pLocalId, + rehosted: !!pLocalId, + user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false, + width: p.w || null, + height: p.h || null + }; + }); + + const chanThreadMeta = { + board, + tid, + subject: op.sub || `Thread #${tid}`, + active_post: targetPost.no, + active_index: mediaIndex + 1, + media_count: mediaPosts.length, + media_posts: chanMediaList + }; + + const data = { + item, + pagination, + link, + chan_thread: chanThreadMeta, + session: req.session ? { ...req.session } : false, + domain: cfg.main.url.domain, + tmp: null, + hidePagination: true, + enable_item_title: true, + enable_item_slugs: false, + user_alternative_steuerung: req.session?.user_alternative_steuerung, + user_alternative_infobox: req.session?.user_alternative_infobox, + can_manage_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, + item_username_lower: (item.username || '').toLowerCase(), + item_rating_class: item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')), + item_rating_label: item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')), + is_flash_item: false, + is_archive_item: false, + item_has_dimensions: !!(item.width && item.height), + is_mod_or_admin: !!(req.session && (req.session.admin || req.session.is_moderator)), + halls_enabled: false, + default_layout: cfg.websrv?.default_layout || 'legacy', + page_meta: { + title: `/${board}/${tid}/${targetPost.no} - ${item.title}`, + description: item.comment || `4chan media post #${targetPost.no} in /${board}/${tid}`, + url: `https://${cfg.main.url.domain}/4/${board}/${tid}/${targetPost.no}` + } + }; + + // Handle AJAX requests from loadItemAjax + const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' || + req.url.qs?.ajax === '1' || + (req.headers.accept && req.headers.accept.includes('application/json')); + + if (isAjax) { + const isModern = req.session?.use_new_layout; + const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy'; + const html = tpl.render(partialTpl, data, req); + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success: true, + html, + item, + pagination, + chan_thread: chanThreadMeta + }) + }); + } + + // Standard full page render + return res.reply({ + body: tpl.render('item', data, req) + }); + } catch (err) { + console.error(`[CHAN] Failed to load item /4/${board}/${tid}/${postNo}:`, err.message); + const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' || + req.url.qs?.ajax === '1' || + (req.headers.accept && req.headers.accept.includes('application/json')); + if (isAjax) { + return res.reply({ + code: 404, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success: false, + message: `Could not load media post #${postNo} in /${board}/${tid}: ${err.message}` + }) + }); + } + return res.reply({ + code: 404, + body: tpl.render('error', { + message: `Could not load media post #${postNo} in /${board}/${tid}.`, + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false + }, req) + }); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 4. GET /api/v2/chan/:board/:thread/media — Media list for thread (sidebar) + // ─────────────────────────────────────────────────────────────────────────── + router.get(/^\/api\/v2\/chan\/(?[a-z0-9]+)\/(?\d+)\/media\/?$/, lib.chanAuth, async (req, res) => { + const board = req.params.board.toLowerCase(); + const tid = Number(req.params.thread); + + try { + const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`); + const posts = threadData.posts || []; + const op = posts[0] || {}; + const mediaPosts = posts.filter(p => p.tim && p.ext); + + const cdnUrls = []; + mediaPosts.forEach(p => { + const ext = (p.ext || '').toLowerCase(); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`); + cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); + }); + const rehosts = {}; + if (cdnUrls.length > 0) { + try { + const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`; + rows.forEach(r => { + rehosts[r.src] = r.id; + const m = r.src.match(/(\d{13,20})/); + if (m) rehosts[m[1]] = r.id; + }); + } catch (_) {} + } + + const media = mediaPosts.map((p, idx) => { + const ext = (p.ext || '').toLowerCase(); + const isVideo = ['.webm', '.mp4'].includes(ext); + const externalMediaUrl = `https://i.4cdn.org/${board}/${p.tim}${ext}`; + return { + no: p.no, + tim: p.tim, + ext, + filename: p.filename ? `${p.filename}${ext}` : null, + w: p.w, + h: p.h, + dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`, + thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`, + is_video: isVideo, + is_image: !isVideo, + index: idx + 1, + local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null, + rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])) + }; + }); + + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' }, + body: JSON.stringify({ + success: true, + board, + tid, + subject: op.sub || `Thread #${tid}`, + total: media.length, + items: media, + media + }) + }); + } catch (err) { + console.error(`[CHAN] Media API error for /${board}/${tid}:`, err.message); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'Failed to fetch thread media' }) + }); + } + }); + + return router; +}; diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs index 16c6b35..aa9a890 100644 --- a/src/inc/routes/external.mjs +++ b/src/inc/routes/external.mjs @@ -5,6 +5,7 @@ import queue from "../queue.mjs"; import { promises as fs } from "fs"; import path from "path"; import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs"; +import { applyWordFilter } from "../wordfilter.mjs"; /** * external.mjs — External source handlers (4chan threads, etc.) @@ -67,7 +68,7 @@ export default (router) => { // GET /api/v2/scroller/external/4chan/:board/:tid // Proxies 4chan thread JSON - router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/(?\d+)\/?$/, lib.loggedin, async (req, res) => { + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/(?\d+)\/?$/, lib.chanAuth, async (req, res) => { if (!proxyRateLimit(req, res)) return; const { board, tid } = req.params || {}; @@ -86,11 +87,23 @@ export default (router) => { // Check which media URLs are already rehosted on this platform const rehosts = {}; const mediaPosts = posts.filter(p => p.tim && p.ext); - const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + const cdn4Urls = []; + mediaPosts.forEach(p => { + const ext = (p.ext || '').toLowerCase(); + cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`); + cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`); + cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`); + cdn4Urls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); + }); if (cdn4Urls.length > 0) { try { - const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`; - rows.forEach(r => { rehosts[r.src] = r.id; }); + const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`; + rows.forEach(r => { + rehosts[r.src] = r.id; + const m = r.src.match(/(\d{13,20})/); + if (m) rehosts[m[1]] = r.id; + }); } catch (e) { console.error('[EXTERNAL] DB src check error:', e.message); } @@ -159,7 +172,7 @@ export default (router) => { // GET /api/v2/scroller/external/4chan/:board/catalog // Proxies 4chan board catalog JSON - router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => { + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/catalog\/?$/, lib.chanAuth, async (req, res) => { if (!proxyRateLimit(req, res)) return; const { board } = req.params || {}; if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) }); @@ -197,7 +210,7 @@ export default (router) => { // GET /api/v2/scroller/external/4chan/:board/find/:postno // Resolves a post number to its parent thread ID - router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/find\/(?\d+)\/?$/, lib.loggedin, async (req, res) => { + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/find\/(?\d+)\/?$/, lib.chanAuth, async (req, res) => { if (!proxyRateLimit(req, res)) return; const { board, postno } = req.params || {}; if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) }); @@ -259,7 +272,7 @@ export default (router) => { // F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy) const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4']; - router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/media\/(?[^/]+)$/, lib.loggedin, async (req, res) => { + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/media\/(?[^/]+)$/, lib.chanAuth, async (req, res) => { if (!proxyRateLimit(req, res)) return; const { board, file } = req.params || {}; @@ -318,8 +331,8 @@ export default (router) => { // POST /api/v2/scroller/rehost // Downloads an external item and adds it to the platform - router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => { - const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {}; + router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.chanAuth, async (req, res) => { + const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename, width: postWidth, height: postHeight } = req.post || {}; if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) }); @@ -335,13 +348,11 @@ export default (router) => { || url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1] || null; - let rating = initialRating; - if (board === 'gif') rating = 'nsfw'; - else if (board === 'wsg') rating = 'sfw'; - - if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) { - return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) }); - } + // Rating is optional: do not force sfw/nsfw based on board + const validRatings = ['sfw', 'nsfw', 'nsfl']; + const rating = (initialRating && validRatings.includes(String(initialRating).toLowerCase())) + ? String(initialRating).toLowerCase() + : null; const session = req.session; @@ -418,6 +429,29 @@ export default (router) => { } } + let itemWidth = Number(postWidth) || null; + let itemHeight = Number(postHeight) || null; + if (!itemWidth || !itemHeight) { + try { + if (mime.startsWith('image/')) { + const { stdout: magickOut } = await queue.spawn('magick', [ + 'identify', '-format', '%wx%h\n', finalTmp + '[0]' + ], { quiet: true, ignoreExitCode: true }); + const m = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/); + if (m) { itemWidth = parseInt(m[1], 10); itemHeight = parseInt(m[2], 10); } + } else if (mime.startsWith('video/')) { + const { stdout: probeOut } = await queue.spawn('ffprobe', [ + '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', finalTmp + ], { quiet: true, ignoreExitCode: true }); + const parts = probeOut.trim().split(','); + if (parts.length >= 2) { + const w = parseInt(parts[0], 10), h = parseInt(parts[1], 10); + if (w > 0 && h > 0) { itemWidth = w; itemHeight = h; } + } + } + } catch (e) {} + } + const filename = `${uuid}.${ext}`; const isApprovalRequired = getManualApproval(); const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b; @@ -442,8 +476,10 @@ export default (router) => { active: !isApprovalRequired, is_oc: !!is_oc, original_filename: original_filename || null, + width: itemWidth, + height: itemHeight, slug: lib.generateSlug(11) - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')} + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'width', 'height', 'slug')} RETURNING id `; @@ -462,16 +498,14 @@ export default (router) => { console.error('[REHOST] Thumbnail error:', err); } - // Tags - const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); - await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`; + // Tags: Only assign rating tag if explicitly specified; do NOT auto-tag board or sfw/nsfw + if (rating) { + const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); + await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`; + } - const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; - // Board tag in chan-style format e.g. /gif/, /wsg/ - if (board) tags.push(`/${board}/`); - // Auto-tag rating based on board - if (board === 'wsg') tags.push('sfw'); - else if (board === 'gif') tags.push('nsfw'); + const rawList = Array.isArray(tagsRaw) ? tagsRaw : (typeof tagsRaw === 'string' ? tagsRaw.split(',') : []); + const tags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())); for (const tagName of tags) { let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`; if (tagRow.length === 0) { @@ -483,9 +517,21 @@ export default (router) => { } } - - - + // Insert optional first comment if provided + if (comment && typeof comment === 'string' && comment.trim().length > 0) { + try { + const filteredComment = await applyWordFilter(comment.trim()); + await db` + INSERT INTO comments ${db({ + item_id: itemid, + user_id: session.id, + content: filteredComment + })} + `; + } catch (err) { + console.error('[REHOST] Comment insert error:', err); + } + } await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`; @@ -498,7 +544,7 @@ export default (router) => { mime: mime, username: session.user, display_name: session.display_name || null, - tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)), + tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: false, is_album: false, album_count: 0 @@ -542,5 +588,127 @@ export default (router) => { } }); + // GET /api/v2/scroller/rehost/details/:id + // Retrieve current rating and tags for a rehosted item + router.get(/^\/api\/v2\/scroller\/rehost\/details\/(?\d+)\/?$/, lib.chanAuth, async (req, res) => { + const itemId = Number(req.params.id); + try { + const tags = await lib.getTags(itemId, req.session); + const ratingTag = tags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + const rating = ratingTag ? ratingTag.normalized : 'untagged'; + const userTags = tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag); + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, item_id: itemId, rating, tags: userTags }) + }); + } catch (err) { + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: err.message }) + }); + } + }); + + // POST /api/v2/scroller/rehost/details + // Set rating, add tags, and or post a comment directly on a rehosted item + router.post(/^\/api\/v2\/scroller\/rehost\/details\/?$/, lib.chanAuth, async (req, res) => { + const session = req.session; + if (!session || !session.user) { + return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) }); + } + + const { item_id, rating, tags, comment } = req.post || {}; + const itemId = parseInt(item_id, 10); + if (!itemId || isNaN(itemId)) { + return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) }); + } + + try { + const rows = await db`SELECT id, username FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`; + if (!rows.length) { + 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 isAdmin = !!(session.admin || session.is_moderator); + if (!isOwner && !isAdmin) { + return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) }); + } + + // 1. Update Rating if provided + if (rating !== undefined && rating !== null && rating !== '') { + const r = String(rating).toLowerCase().trim(); + const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`; + const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517); + + await db` + DELETE FROM tags_assign + WHERE item_id = ${itemId} + AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))) + `; + + if (r === 'sfw') { + await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 1, user_id: session.id })} ON CONFLICT DO NOTHING`; + } else if (r === 'nsfw') { + await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 2, user_id: session.id })} ON CONFLICT DO NOTHING`; + } else if (r === 'nsfl') { + await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: nsflId, user_id: session.id })} ON CONFLICT DO NOTHING`; + } + // If r === 'untagged', no rating tag is inserted + } + + // 2. Add Tags if provided + const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []); + const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())); + for (const tagName of cleanTags) { + let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`; + if (tagRow.length === 0) { + await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`; + tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`; + } + if (tagRow.length) { + await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`; + } + } + if (cleanTags.length > 0) { + const freshTags = await lib.getTags(itemId, session); + await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags })); + } + + // 3. Add Comment if provided + if (comment && typeof comment === 'string' && comment.trim().length > 0) { + const filteredComment = await applyWordFilter(comment.trim()); + await db` + INSERT INTO comments ${db({ + item_id: itemId, + user_id: session.id, + content: filteredComment + })} + `; + } + + const freshTags = await lib.getTags(itemId, session); + const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success: true, + item_id: itemId, + rating: ratingTag ? ratingTag.normalized : 'untagged', + tags: freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag) + }) + }); + } catch (err) { + console.error('[REHOST-DETAILS] Error:', err); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'Failed to save details' }) + }); + } + }); + return router; }; diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 8c37050..ee119a6 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -371,8 +371,13 @@ export default (router, tpl) => { data.item.otherHalls = []; } if (req.session || !cfg.main.hide_comments_from_public) { - // Mark notifications as read - if (req.session?.id) { + 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 @@ -652,7 +657,7 @@ export default (router, tpl) => { // 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|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(/^(?!\/(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) => { diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 6024816..7c32cdc 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -235,7 +235,12 @@ export default (router, tpl) => { } if (req.session || !cfg.main.hide_comments_from_public) { - if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {}); + const isPrefetch = !!( + req.headers?.['x-purpose'] === 'prefetch' || + req.headers?.['purpose'] === 'prefetch' || + req.headers?.['sec-purpose'] === 'prefetch' + ); + if (req.session?.id && !isPrefetch) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {}); data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false; // xD Score diff --git a/src/index.mjs b/src/index.mjs index 91fa549..269f178 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -604,6 +604,7 @@ process.on('uncaughtException', err => { const runMigration = async (query) => { try { await query; } catch (e) { /* ignore if table doesn't exist yet */ } }; + await runMigration(db`ALTER TABLE "user" ADD COLUMN IF NOT EXISTS groups text[] DEFAULT '{}'`); await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`); await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`); await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`); @@ -989,7 +990,7 @@ process.on('uncaughtException', err => { user = [_cachedRow]; } else { user = await db` - select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint + select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint from "user_sessions" left join "user" on "user".id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id @@ -1024,6 +1025,9 @@ 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'))); + } // Ban check (Session) if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) { @@ -1114,7 +1118,7 @@ process.on('uncaughtException', err => { // Validate that the original session matches the current admin cookie if (impData.orig && impData.orig === lib.sha256(req.cookies.session)) { const targetRow = await db` - SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, + SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, @@ -1956,6 +1960,7 @@ process.on('uncaughtException', err => { return (c !== undefined && c !== null) ? !!c : null; }, is_onara_item: false, + can_extract_meta: false, enable_private_uploads: cfg.enable_private_uploads !== false, get enable_expiring_uploads() { return getEnableExpiringUploads(); }, get enable_item_slugs() { return getEnableItemSlugs(); }, @@ -2115,6 +2120,13 @@ 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')))); + if (activeSession && typeof activeSession === 'object') { + activeSession.can_chan = canChan; + } + if (data && data.item && typeof data.item.size === 'number') { + data.item.size = lib.formatSize(data.item.size); + } data = Object.assign({}, globals, data || {}, { t: perRequestT, @@ -2122,6 +2134,8 @@ process.on('uncaughtException', err => { recaptcha_enabled: perRequestRecaptcha, is_anonymized: isAnonymized, anon_anonymize: anonAnonymize, + can_chan: canChan, + item_has_dimensions: !!(data && data.item && data.item.width && data.item.height), user_alternative_infobox: useAltInfobox, user_alternative_steuerung: useAltSteuerung, user_banner_enabled: cfg.websrv.user_banner_enabled !== false, diff --git a/views/admin/users.html b/views/admin/users.html index 15f3762..2238ade 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -926,6 +926,40 @@ } }, { hideReason: true, confirmText: 'Apply Role', unsafeContent: true }); } + + async function adminSetGroups(btn) { + var id = btn.dataset.id; + var userName = btn.dataset.name; + var currentGroups = (btn.dataset.groups || '').split(',').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean); + var is4chan = currentGroups.indexOf('4chan') !== -1; + + var hint = 'Manage groups for ' + escHTML(userName) + ':

' + + ''; + + ModAction.confirm('Manage Groups', hint, async () => { + var chk = document.getElementById('group-4chan-checkbox') || document.querySelector('#mod-action-content input[type="checkbox"]'); + var newGroups = []; + if (chk && chk.checked) newGroups.push('4chan'); + + var res = await fetch('/api/v2/admin/users/set-groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token }, + body: JSON.stringify({ user_id: id, groups: newGroups }) + }); + var data = await res.json(); + if (data.success) { + showFlash(data.msg, 'success'); + btn.dataset.groups = newGroups.join(','); + var labelEl = document.querySelector('.groups-label-' + id); + if (labelEl) labelEl.textContent = newGroups.indexOf('4chan') !== -1 ? '4chan' : 'Groups'; + } else { + throw new Error(data.msg || 'Failed to update groups'); + } + }, { hideReason: true, confirmText: 'Save Groups', unsafeContent: true }); + } diff --git a/views/admin/users_list.html b/views/admin/users_list.html index e898e6f..bff5724 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -89,6 +89,7 @@ + @elseif(u.login === 'deleted_user') Protected System Account diff --git a/views/chan/catalogue.html b/views/chan/catalogue.html new file mode 100644 index 0000000..0d0bdc9 --- /dev/null +++ b/views/chan/catalogue.html @@ -0,0 +1,480 @@ +@include(snippets/header) +
+
+
+
+ +

/{{ board }}/ Catalogue

+ {{ threads.length }} threads +
+ + +
+ @each(common_boards as b) + + /{{ b.id }}/ + + @endeach +
+ + +
+
+ + +
+
+ + + +
+ +
+ + +
+
+
+ + + + + +
+
+ + + + + +@include(snippets/footer) diff --git a/views/chan/thread.html b/views/chan/thread.html new file mode 100644 index 0000000..c146ac2 --- /dev/null +++ b/views/chan/thread.html @@ -0,0 +1,855 @@ +@include(snippets/header) +
+
+ + +
+ + +
+ +
+

+ @if(op.sub) + {{ op.sub }} + @else + Thread #{{ tid }} + @endif +

+
+ {{ posts.length }} posts + {{ media_count }} media files + @if(rehosts_count > 0) + {{ rehosts_count }} rehosted + @endif +
+
+ + +
+ + + @if(rehosts_count > 0) + + @endif +
+
+
+ + +
+ @each(posts as p) +
+ + +
+ {{ p.name }} + @if(p.trip) + {{ p.trip }} + @endif + + No. {{ p.no }} + @if(p.local_id) + + #{{ p.local_id }} + + @endif + @if(p.sub && p.no !== tid) + {{ p.sub }} + @endif + @if(p.has_media) +
+ + {{ p.ext.toUpperCase().replace('.', '') }} +
+ @endif +
+ + +
+ @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_id }} + + + + @else + + @endif + +
+
+
+ @endif + + + @if(p.com_raw) +
+ {{ p.com_formatted }} +
+ @endif +
+ +
+ @endeach +
+ +
+
+ + + + + +@include(snippets/footer) diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 7e1c121..faf01ce 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -6,7 +6,7 @@
-
{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}
+
@if(item.is_chan)/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}@if(item.local_id)→ #{{ item.local_id }}@endif@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif
@if(enable_item_title) @@ -126,11 +126,21 @@
+ @if(item.is_chan) + @if(item.local_id) + + #{{ item.local_id }} + + + @else + + @endif + @endif @if(session) @if(user_has_favorited) - + @else - + @endif @if(enable_comments) @@ -144,7 +154,7 @@ @if(enable_oc) @endif - @if(can_extract_meta) + @if(typeof can_extract_meta !== 'undefined' && can_extract_meta) @endif @if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie') @@ -177,6 +187,14 @@ @endif @if(!user_alternative_infobox) — @if(item.expires_at) — Expiring ({{ item.expires_in }})@endif @endif + @if(!user_alternative_infobox && item.is_chan) — + /{{ item.external_board }}/{{ item.external_tid }} + @if(item.local_id) + #{{ item.local_id }} + @else + + @endif + @endif @if(halls_enabled && item.primaryHall) {{ item.primaryHall.name }}@if(item.otherHalls && item.otherHalls.length)+{{ item.otherHalls.length }}@each(item.otherHalls as oh){{ oh.name }}@endeach@endif diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index 7cd4caa..935b8a5 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -70,7 +70,7 @@
-
{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}
+
@if(item.is_chan)/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}@if(item.local_id)→ #{{ item.local_id }}@endif@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif
@if(enable_item_title) @@ -150,14 +150,29 @@ @if(item.expires_at) — Expiring ({{ item.expires_in }}) @endif + @if(item.is_chan) + + /{{ item.external_board }}/{{ item.external_tid }} + + @endif
+ @if(item.is_chan) + @if(item.local_id) + + #{{ item.local_id }} + + + @else + + @endif + @endif @if(session) @if(user_has_favorited) - + @else - + @endif @endif @if(session) @@ -170,7 +185,9 @@ @if(enable_oc) @endif + @if(typeof can_extract_meta !== 'undefined' && can_extract_meta) + @endif @if(is_flash_item) @endif diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 0797de1..53d51c4 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -159,6 +159,9 @@ + @@ -198,6 +201,24 @@
+