diff --git a/config_example.json b/config_example.json index b3754e8..ed11ec0 100644 --- a/config_example.json +++ b/config_example.json @@ -121,6 +121,8 @@ "user_alternative_steuerung": 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, @@ -205,7 +207,16 @@ "video/x-matroska": "mkv", "application/x-shockwave-flash": "swf", "application/vnd.adobe.flash.movie": "swf", - "application/pdf": "pdf" + "application/pdf": "pdf", + "application/zip": "zip", + "application/x-zip-compressed": "zip", + "application/x-rar-compressed": "rar", + "application/vnd.rar": "rar", + "application/x-7z-compressed": "7z", + "application/x-tar": "tar", + "application/gzip": "gz", + "application/x-bzip2": "bz2", + "application/x-xz": "xz" }, "apis": {}, "smtp": { diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index fd2a89f..274e196 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -15907,8 +15907,6 @@ body.scroller-active #gchat-reopen-bubble { ARCHIVE DOWNLOAD VIEW ============================================= */ .archive-download-view { - background: linear-gradient(135deg, #0d1a0d 0%, #111a11 50%, #0a140a 100%) !important; - border: 1px solid rgba(102, 187, 106, 0.15) !important; display: flex !important; align-items: center !important; justify-content: center !important; @@ -15928,15 +15926,8 @@ body.scroller-active #gchat-reopen-bubble { .archive-download-icon { font-size: 64px; - color: #66bb6a; + color: var(--accent); opacity: 0.9; - text-shadow: 0 0 30px rgba(102, 187, 106, 0.4), 0 0 60px rgba(102, 187, 106, 0.15); - animation: archive-icon-pulse 3s ease-in-out infinite; -} - -@keyframes archive-icon-pulse { - 0%, 100% { text-shadow: 0 0 20px rgba(102, 187, 106, 0.3), 0 0 40px rgba(102, 187, 106, 0.1); } - 50% { text-shadow: 0 0 35px rgba(102, 187, 106, 0.6), 0 0 70px rgba(102, 187, 106, 0.25); } } .archive-download-filename { @@ -15947,9 +15938,8 @@ body.scroller-active #gchat-reopen-bubble { overflow-wrap: anywhere; max-width: 100%; line-height: 1.4; - padding: 0 8px; - background: rgba(102, 187, 106, 0.06); - border: 1px solid rgba(102, 187, 106, 0.12); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); border-radius: 4px; padding: 6px 12px; font-family: monospace; @@ -15968,30 +15958,18 @@ body.scroller-active #gchat-reopen-bubble { align-items: center; gap: 10px; padding: 12px 32px; - background: linear-gradient(135deg, #388e3c, #2e7d32); - color: #fff !important; + background: var(--accent); + color: var(--black); font-weight: 700; font-size: 1em; text-decoration: none !important; border-radius: 4px; - border: 1px solid rgba(102, 187, 106, 0.3); + border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent); cursor: pointer; - transition: background 0.2s, transform 0.15s, box-shadow 0.2s; - box-shadow: 0 4px 16px rgba(56, 142, 60, 0.3); letter-spacing: 0.5px; margin-top: 4px; } -.archive-download-btn:hover { - background: linear-gradient(135deg, #43a047, #388e3c); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(56, 142, 60, 0.45); -} - -.archive-download-btn:active { - transform: translateY(0); - box-shadow: 0 2px 8px rgba(56, 142, 60, 0.3); -} .archive-download-btn i { font-size: 1em; diff --git a/public/s/img/archive.webp b/public/s/img/archive.webp new file mode 100644 index 0000000..8ce187d Binary files /dev/null and b/public/s/img/archive.webp differ diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 53ab63e..ff3f529 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -807,6 +807,35 @@ window.initUploadForm = (selector) => { continue; } + // Reject archives when archive uploads are disabled + const archiveEnabled = form.getAttribute('data-enable-archive') !== '0'; + if (!archiveEnabled) { + const archiveExts = new Set(); + try { + const mimesObj = JSON.parse(mimesSource || '{}'); + for (const [mime, ext] of Object.entries(mimesObj)) { + if (mime.startsWith('application/') && ext !== 'swf' && ext !== 'pdf') { + archiveExts.add(ext); + } + } + } catch(e) { /* ignore */ } + + const isArchiveFile = archiveExts.has(fileExt) || + (file.type && file.type.startsWith('application/') && + file.type !== 'application/x-shockwave-flash' && + file.type !== 'application/vnd.adobe.flash.movie' && + file.type !== 'application/pdf'); + + if (isArchiveFile) { + const label = file.type || ('.' + fileExt); + const errorMsg = `${label} uploads are disabled.`; + if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error'); + else if (window.showFlash) window.showFlash(errorMsg, 'error'); + else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; } + continue; + } + } + const mimeOk = !file.type || allowedMimes.includes(file.type); const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt); diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index dbff026..b45fdae 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -560,8 +560,37 @@ export default new class queue { }); } } + else if (mime.startsWith('application/') && cfg.mimes[mime] && !['swf', 'pdf'].includes(cfg.mimes[mime])) { + // Any application/* MIME registered in config that isn't swf or pdf is treated as an archive. + // Both the detection and the label come straight from cfg.mimes — no hardcoded list needed. + let customThumb = cfg.websrv && cfg.websrv.archive_thumb; + if (customThumb && customThumb.startsWith('/')) { + customThumb = path.join(path.resolve(), 'public', customThumb); + } + let usedCustom = false; + if (customThumb) { + try { + const stat = await fs.promises.stat(customThumb).catch(() => null); + if (stat && stat.size > 0) { + await this.spawn('magick', [customThumb, tmpFile]); + usedCustom = true; + } + } catch (_) {} + } + if (!usedCustom) { + const archiveLabel = (cfg.mimes[mime] || 'arc').toUpperCase(); + await this.spawn('magick', [ + '-size', thumbSpec, 'xc:#1a2e1a', + '-gravity', 'center', + '-fill', '#66bb6a', + '-pointsize', '48', + '-annotate', '0', archiveLabel, + tmpFile + ]).catch(() => {}); + } + } + - // Determine if we should use a checkerboard background for transparency const isTransparentMime = mime === 'image/png' || mime === 'image/webp' || mime === 'image/avif' || mime === 'image/gif'; if (isTransparentMime) { // Build a grey/white checkerboard via explicit xc: squares (no pattern replacement tricks): diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 1481e93..74f74b3 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -844,7 +844,8 @@ export default { is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false, reposts: repostItems, width: actitem.width || null, - height: actitem.height || null + height: actitem.height || null, + original_filename: actitem.original_filename || null }, title: `${actitem.id} - ${cfg.websrv.domain}`, pagination: { diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index dd79586..2612d96 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -129,7 +129,7 @@ export default (router, tpl) => { const item = data.item; data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); - data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1); + data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user)); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : ''; @@ -138,6 +138,7 @@ export default (router, tpl) => { data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); data.item_username_lower = (item.username || '').toLowerCase(); data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); + data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index ecfc4d0..72d8db5 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -348,7 +348,7 @@ export default (router, tpl) => { data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); // Is the item's MIME type suitable for metadata extraction? // YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible. - data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1); + data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); // Has the current user favorited this item? data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user)); // Hall columns for display @@ -359,6 +359,7 @@ export default (router, tpl) => { data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); data.item_username_lower = (item.username || '').toLowerCase(); data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); + data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; diff --git a/src/inc/routes/scroller.mjs b/src/inc/routes/scroller.mjs index 7c173eb..56c96fc 100644 --- a/src/inc/routes/scroller.mjs +++ b/src/inc/routes/scroller.mjs @@ -166,8 +166,12 @@ export default (router, tpl) => { const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null; const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie']; + const archiveMimes = Object.entries(cfg.mimes) + .filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext)) + .map(([mime]) => mime); const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``; const excludePdfSQL = db`AND items.mime != 'application/pdf'`; + const excludeArchiveSQL = db`AND items.mime != ALL(${archiveMimes})`; const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m)); const mimeSQL = mimeParts.length > 0 ? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})` @@ -246,7 +250,9 @@ export default (router, tpl) => { WHERE items.id = ${anchorId} AND items.active = true AND ${db.unsafe(modeQuery)} + ${excludeSwfSQL} ${excludePdfSQL} + ${excludeArchiveSQL} ${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``} `; // If the anchor item doesn't pass the rating filter, it's inaccessible to this user. @@ -264,6 +270,7 @@ export default (router, tpl) => { AND items.active = true ${excludeSwfSQL} ${excludePdfSQL} + ${excludeArchiveSQL} AND items.id != ${anchorId} ${excludeSQL} ${mimeSQL} @@ -287,6 +294,7 @@ export default (router, tpl) => { AND items.active = true ${excludeSwfSQL} ${excludePdfSQL} + ${excludeArchiveSQL} ${cursorSQL} ${excludeSQL} ${mimeSQL} @@ -341,6 +349,7 @@ export default (router, tpl) => { fav_count: +row.fav_count || 0, comment_count: +row.comment_count || 0, is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'), + is_archive: !!(row.mime && archiveMimes.includes(row.mime)), is_video: isVideo, is_youtube: isYouTube, is_audio: isAudio, diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 08beb7e..cacb18e 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -163,7 +163,7 @@ export default (router, tpl) => { const item = data.item; data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); - data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1); + data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user)); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : ''; @@ -171,6 +171,7 @@ export default (router, tpl) => { data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); data.item_username_lower = (item.username || '').toLowerCase(); data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); + data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; diff --git a/src/index.mjs b/src/index.mjs index da1b7a4..6dec867 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -77,6 +77,10 @@ const nginx502Fallback = ` `; // Login + Register modal injected before +// This string is built at startup so it can reference cfg.recaptcha values. +const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key); +const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || ''; + const gateLoginInjection = `
@@ -117,6 +121,7 @@ const gateLoginInjection = ` + ${_rcEnabled ? '
' : ''}

@@ -137,6 +142,7 @@ const gateLoginInjection = ` function gateShowView(view) { document.getElementById('gate-login-view').style.display = view === 'login' ? '' : 'none'; document.getElementById('gate-register-view').style.display = view === 'register' ? '' : 'none'; + if (view === 'register') gateRenderRecaptcha(); } function gateSetError(id, msg) { var el = document.getElementById(id); @@ -152,6 +158,24 @@ const gateLoginInjection = ` btn.style.cursor = loading ? 'default' : 'pointer'; } + // reCAPTCHA + var _gateRcWidgetId = null; + var _gateRcLoaded = false; + function gateRenderRecaptcha() { + var el = document.getElementById('gate-recaptcha'); + if (!el || !window.grecaptcha) return; + if (_gateRcWidgetId !== null) { + try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} + } else { + _gateRcWidgetId = grecaptcha.render(el, { sitekey: '${_rcSiteKey}', theme: 'light' }); + } + } + window.onRecaptchaGateReady = function() { + _gateRcLoaded = true; + var rv = document.getElementById('gate-register-view'); + if (rv && rv.style.display !== 'none') gateRenderRecaptcha(); + }; + document.addEventListener('DOMContentLoaded', function() { var modal = document.getElementById('gate-modal'); var close = document.getElementById('gate-modal-close'); @@ -164,7 +188,7 @@ const gateLoginInjection = ` var hc = document.getElementById('hot-corner'); if (hc) hc.onclick = function() { openLoginGate('login'); }; - // ── Login form ────────────────────────────────────────────────────────── + // Login form document.getElementById('gate-login-form').onsubmit = function(e) { e.preventDefault(); gateSetError('gate-login-error', ''); @@ -182,23 +206,28 @@ const gateLoginInjection = ` gateSetError('gate-login-error', d.msg || 'Login failed.'); gateSetBtn('gate-login-btn', false); } else { - // success — server set the cookie, reload to enter the site window.location.reload(); } }) - .catch(function() { - // On redirect (301) fetch follows it — if the redirect lands on '/' we reload - window.location.reload(); - }); + .catch(function() { window.location.reload(); }); }; - // ── Register form ─────────────────────────────────────────────────────── + // Register form document.getElementById('gate-register-form').onsubmit = function(e) { e.preventDefault(); gateSetError('gate-register-error', ''); gateSetError('gate-register-ok', ''); gateSetBtn('gate-register-btn', true); var fd = new FormData(this); + if (_gateRcWidgetId !== null && window.grecaptcha) { + var token = grecaptcha.getResponse(_gateRcWidgetId); + if (!token) { + gateSetError('gate-register-error', 'Please complete the CAPTCHA.'); + gateSetBtn('gate-register-btn', false); + return; + } + fd.append('g-recaptcha-response', token); + } var body = new URLSearchParams(fd).toString(); fetch('/register', { method: 'POST', @@ -210,6 +239,7 @@ const gateLoginInjection = ` gateSetBtn('gate-register-btn', false); if (d.success === false) { gateSetError('gate-register-error', d.msg || 'Registration failed.'); + if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} } } else { document.getElementById('gate-register-ok').textContent = d.msg || 'Account created! You can now sign in.'; document.getElementById('gate-register-ok').style.display = ''; @@ -220,6 +250,7 @@ const gateLoginInjection = ` .catch(function() { gateSetBtn('gate-register-btn', false); gateSetError('gate-register-error', 'An error occurred. Please try again.'); + if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} } }); }; }); @@ -231,6 +262,7 @@ const gateLoginInjection = ` if (_sb.length > 12) _sb = _sb.slice(-12); }); +${_rcEnabled ? '