diff --git a/config_example.json b/config_example.json index ed11ec0..1baed0c 100644 --- a/config_example.json +++ b/config_example.json @@ -118,6 +118,7 @@ "enable_swiping": true, "enable_profile_description": true, "user_alternative_infobox": false, + "user_banner_enabled": true, "user_alternative_steuerung": false, "enable_swf": false, "swf_thumb": "/s/img/swf.png", diff --git a/migrations/add_user_banner.sql b/migrations/add_user_banner.sql new file mode 100644 index 0000000..71bfd32 --- /dev/null +++ b/migrations/add_user_banner.sql @@ -0,0 +1,7 @@ +-- Add banner_file column to user_options for custom profile banners +-- Banners are displayed in the alternative infobox (user-infobox-block) + +ALTER TABLE user_options + ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL; + +COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)'; diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 931a61a..cbce069 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -13725,11 +13725,34 @@ textarea#profile_description { margin-bottom: 10px; } +/* ── Banner inside alternative infobox ──────────────────────────────── */ + +.user-infobox-block { + position: relative; + z-index: 1; +} + +.user-infobox-block::before { + content: ""; + position: absolute; + inset: 0; + background-image: var(--author-banner, none); + background-size: var(--author-banner-size, cover); + background-position: var(--author-banner-position, center); + background-repeat: var(--author-banner-repeat, no-repeat); + opacity: var(--author-banner-opacity, 0.4); + z-index: -1; + border-radius: inherit; + pointer-events: none; +} + +/* ── End banner styles ────────────────────────────────────────────────── */ .user-infobox-avatar { flex-shrink: 0; } + .user-infobox-avatar img { width: 64px; height: 64px; @@ -13758,7 +13781,7 @@ textarea#profile_description { align-self: baseline; margin: 1px 10px 0px 0px; padding: 0; - top: 0; + top: 2px; right: 0; color: var(--accent); } diff --git a/public/s/js/settings.js b/public/s/js/settings.js index 3aae817..03d35d0 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -344,6 +344,414 @@ } }); + // ==== Banner File Upload Logic (with crop modal) ==== + const bannerFileInput = document.getElementById('banner-file-input'); + const bannerChooseBtn = document.getElementById('banner-choose-btn'); + const bannerFilenameSpan = document.getElementById('banner-filename'); + const bannerUploadBtn = document.getElementById('banner-upload-btn'); + const bannerProgressWrapper = document.getElementById('banner-progress-wrapper'); + const bannerProgressFill = document.getElementById('banner-progress-fill'); + const bannerProgressText = document.getElementById('banner-progress-text'); + const bannerStatusDiv = document.getElementById('banner-upload-status'); + + const showBannerStatus = (msg, type) => { + if (bannerStatusDiv) { + bannerStatusDiv.textContent = msg; + bannerStatusDiv.className = 'avatar-status ' + type; + } + }; + + // ── Crop Modal ────────────────────────────────────────────────────────── + const showBannerCropModal = (file) => new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position:fixed;inset:0;z-index:99999; + background:rgba(0,0,0,0.92); + display:flex;flex-direction:column;align-items:center;justify-content:center; + gap:16px;padding:20px;box-sizing:border-box; + font-family: inherit; + user-select: none; + -webkit-user-select: none; + `; + + + const title = document.createElement('div'); + title.textContent = 'Position Banner'; + title.style.cssText = 'color:#fff;font-size:1.2em;font-weight:600;letter-spacing:0.5px;'; + + const hint = document.createElement('div'); + hint.textContent = 'Drag to position. Use the slider below to zoom.'; + hint.style.cssText = 'color:#aaa;font-size:0.85em;text-align:center;margin-bottom:4px;'; + + // Responsive cropper container + const cropperContainer = document.createElement('div'); + cropperContainer.style.cssText = ` + width: 100%; + max-width: 600px; + aspect-ratio: 3 / 1; + position: relative; + overflow: hidden; + border: 1px solid var(--nav-border-color); + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0,0,0,0.8); + background: #111; + `; + + const canvas = document.createElement('canvas'); + canvas.width = 1200; + canvas.height = 400; + canvas.style.cssText = 'width:100%;height:100%;display:block;cursor:move;touch-action:none;pointer-events:auto !important;'; + cropperContainer.appendChild(canvas); + + // Zoom slider container + const zoomContainer = document.createElement('div'); + zoomContainer.style.cssText = 'display:flex;align-items:center;gap:12px;width:100%;max-width:320px;color:#fff;margin-top:4px;'; + + const zoomOutLabel = document.createElement('span'); + zoomOutLabel.textContent = 'A'; + zoomOutLabel.style.cssText = 'font-size:0.8em;opacity:0.6;user-select:none;font-weight:bold;'; + + const zoomInput = document.createElement('input'); + zoomInput.type = 'range'; + zoomInput.style.cssText = 'flex:1;height:4px;border-radius:2px;outline:none;cursor:pointer;'; + + const zoomInLabel = document.createElement('span'); + zoomInLabel.textContent = 'A'; + zoomInLabel.style.cssText = 'font-size:1.2em;opacity:0.9;user-select:none;font-weight:bold;'; + + zoomContainer.append(zoomOutLabel, zoomInput, zoomInLabel); + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:12px;margin-top:8px;'; + + const confirmBtn = document.createElement('button'); + confirmBtn.textContent = 'Save & Upload'; + confirmBtn.className = 'button'; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'button button-danger'; + + btnRow.append(confirmBtn, cancelBtn); + overlay.append(title, hint, cropperContainer, zoomContainer, btnRow); + document.body.appendChild(overlay); + overlay.tabIndex = 0; + overlay.focus(); + + const ctx = canvas.getContext('2d'); + const img = new Image(); + const objectUrl = URL.createObjectURL(file); + + // State variables + let zoom = 1.0; + let minZoom = 1.0; + let maxZoom = 4.0; + let panX = 0; + let panY = 0; + + + + const draw = () => { + ctx.clearRect(0, 0, 1200, 400); + ctx.drawImage(img, panX, panY, img.naturalWidth * zoom, img.naturalHeight * zoom); + + // Update hint with live coords/zoom + hint.textContent = `Drag to position | Zoom: ${Math.round((zoom/minZoom)*100)}% | Pan: ${Math.round(panX)}, ${Math.round(panY)}`; + }; + + const getBounds = (z) => { + return { + minX: 1200 - img.naturalWidth * z, + minY: 400 - img.naturalHeight * z + }; + }; + + img.onload = () => { + if (!img.naturalWidth || !img.naturalHeight) { + hint.textContent = 'Error: Image has 0 dimensions'; + return; + } + + // Determine minimum zoom so the image fully covers 1200x400 canvas without black edges + minZoom = Math.max(1200 / img.naturalWidth, 400 / img.naturalHeight); + maxZoom = minZoom * 4; + zoom = minZoom; + + // Center image initially + panX = (1200 - img.naturalWidth * zoom) / 2; + panY = (400 - img.naturalHeight * zoom) / 2; + + // Configure zoom slider + zoomInput.min = 0; + zoomInput.max = 100; + zoomInput.value = 0; + + draw(); + }; + + const handleZoomChange = (pct) => { + const oldZoom = zoom; + const t = pct / 100; + const newZoom = minZoom + (maxZoom - minZoom) * t; + + const imgCenterX = (600 - panX) / oldZoom; + const imgCenterY = (200 - panY) / oldZoom; + + zoom = newZoom; + const bounds = getBounds(zoom); + panX = Math.max(bounds.minX, Math.min(0, 600 - imgCenterX * zoom)); + panY = Math.max(bounds.minY, Math.min(0, 200 - imgCenterY * zoom)); + + draw(); + }; + + zoomInput.addEventListener('input', (e) => { + handleZoomChange(parseFloat(e.target.value)); + }); + + let dragging = false; + let dragOffX = 0; + let dragOffY = 0; + + const getPos = (e) => { + const r = canvas.getBoundingClientRect(); + const sx = canvas.width / r.width, sy = canvas.height / r.height; + const src = e.touches ? e.touches[0] : e; + return { x: (src.clientX - r.left) * sx, y: (src.clientY - r.top) * sy }; + }; + + const onDown = (e) => { + e.preventDefault(); + dragging = true; + const { x, y } = getPos(e); + dragOffX = x - panX; + dragOffY = y - panY; + canvas.style.cursor = 'grabbing'; + }; + + const onMove = (e) => { + if (!dragging) return; + e.preventDefault(); + const { x, y } = getPos(e); + + const dx = x - dragOffX; + const dy = y - dragOffY; + + const bounds = getBounds(zoom); + panX = Math.max(bounds.minX, Math.min(0, dx)); + panY = Math.max(bounds.minY, Math.min(0, dy)); + + draw(); + }; + + const onUp = () => { + if (dragging) { + dragging = false; + canvas.style.cursor = 'move'; + } + }; + + // Strict fallback to original robust event binding + canvas.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove, { passive: false }); + window.addEventListener('mouseup', onUp); + + canvas.addEventListener('touchstart', onDown, { passive: false }); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onUp); + + // Prevent native HTML5 drag and drop behavior from hijacking our drag + canvas.addEventListener('dragstart', (e) => e.preventDefault()); + + const cleanup = () => { + URL.revokeObjectURL(objectUrl); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + window.removeEventListener('touchmove', onMove); + window.removeEventListener('touchend', onUp); + overlay.remove(); + }; + + + confirmBtn.addEventListener('click', () => { + canvas.toBlob(blob => { + cleanup(); + resolve(blob); + }, 'image/webp', 0.85); + }); + + cancelBtn.addEventListener('click', () => { + cleanup(); + resolve(null); + }); + + overlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + }); + + // Trigger image load AFTER attaching onload listener + img.src = objectUrl; + }); + + + + // ── Upload blob to server ─────────────────────────────────────────────── + const uploadBannerBlob = (blob) => { + if (!blob) return; + + if (bannerUploadBtn) bannerUploadBtn.disabled = true; + if (bannerChooseBtn) bannerChooseBtn.disabled = true; + if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; } + if (bannerProgressFill) bannerProgressFill.style.width = '0%'; + if (bannerProgressText) bannerProgressText.textContent = '0%'; + showBannerStatus('Uploading...', ''); + + const formData = new FormData(); + formData.append('file', blob, 'banner.webp'); + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + if (bannerProgressFill) bannerProgressFill.style.width = pct + '%'; + if (bannerProgressText) bannerProgressText.textContent = pct + '%'; + } + }); + + xhr.addEventListener('load', () => { + try { + const res = JSON.parse(xhr.responseText); + if (xhr.status === 200 && res.success) { + showBannerStatus(res.msg || 'Banner uploaded!', 'success'); + + const bannerPreview = document.getElementById('banner-preview'); + if (bannerPreview) { + if (bannerPreview.tagName === 'IMG') { + bannerPreview.src = '/a/' + res.banner_file + '?t=' + Date.now(); + } else { + const img = document.createElement('img'); + img.id = 'banner-preview'; + img.className = 'banner-preview-img'; + img.style.cssText = 'width:100%;max-width:360px;height:auto;border-radius:4px;border:1px solid var(--nav-border-color);object-fit:cover;'; + img.src = '/a/' + res.banner_file + '?t=' + Date.now(); + bannerPreview.replaceWith(img); + } + } + + const existingRemoveBtn = document.getElementById('banner-remove-btn'); + if (!existingRemoveBtn && bannerUploadBtn) { + const actionsDiv = bannerUploadBtn.closest('.avatar-upload-actions'); + if (actionsDiv) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.id = 'banner-remove-btn'; + btn.className = 'button button-danger'; + btn.textContent = 'Remove Banner'; + actionsDiv.appendChild(btn); + } + } + + if (bannerFileInput) bannerFileInput.value = ''; + if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected'; + } else { + showBannerStatus(res.msg || 'Upload failed', 'error'); + } + } catch (e) { + showBannerStatus('Upload failed: Invalid response', 'error'); + } + + if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; + if (bannerChooseBtn) bannerChooseBtn.disabled = false; + }); + + xhr.addEventListener('error', () => { + showBannerStatus('Upload failed: Network error', 'error'); + if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; + if (bannerChooseBtn) bannerChooseBtn.disabled = false; + }); + + xhr.open('POST', '/api/v2/settings/uploadBanner'); + xhr.setRequestHeader('X-CSRF-Token', window.f0ckSession?.csrf_token); + xhr.send(formData); + }; + + // ── Wire up ───────────────────────────────────────────────────────────── + if (bannerChooseBtn && bannerFileInput) { + bannerChooseBtn.addEventListener('click', () => bannerFileInput.click()); + + bannerFileInput.addEventListener('change', async () => { + const file = bannerFileInput.files[0]; + if (!file) return; + + if (!allowedTypes.includes(file.type)) { + showBannerStatus('Invalid file type. Allowed: gif, jpg, png, webp', 'error'); + return; + } + if (file.size > maxSize) { + showBannerStatus(`File too large. Max 5MB.`, 'error'); + return; + } + + showBannerStatus('', ''); + if (bannerFilenameSpan) bannerFilenameSpan.textContent = file.name; + + try { + const blob = await showBannerCropModal(file); + if (blob) { + uploadBannerBlob(blob); + } else { + // Modal was cancelled + if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected'; + } + } catch (err) { + console.error('[CROPPER] Error:', err); + showBannerStatus('Cropper error', 'error'); + } finally { + // ALWAYS clear the file input value so selecting the same file again works + bannerFileInput.value = ''; + } + }); + } + + // ── Remove banner handler ──────────────────────────────────────────────── + document.addEventListener('click', async (e) => { + if (e.target.id === 'banner-remove-btn') { + e.target.disabled = true; + e.target.textContent = 'Removing...'; + + try { + const res = await fetch('/api/v2/settings/uploadBanner', { + method: 'DELETE', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token } + }); + const data = await res.json(); + + if (data.success) { + showBannerStatus('Banner removed', 'success'); + e.target.remove(); + const bannerPreview = document.getElementById('banner-preview'); + if (bannerPreview) { + const ph = document.createElement('div'); + ph.id = 'banner-preview'; ph.className = 'banner-preview-img banner-placeholder'; + ph.style.cssText = 'width:100%;max-width:360px;height:80px;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;'; + ph.textContent = 'No banner set'; + bannerPreview.replaceWith(ph); + } + } else { + showBannerStatus(data.msg || 'Failed to remove', 'error'); + e.target.disabled = false; e.target.textContent = 'Remove Banner'; + } + } catch (err) { + showBannerStatus('Failed to remove banner', 'error'); + e.target.disabled = false; e.target.textContent = 'Remove Banner'; + } + } + }); + + // Generic Linking Logic const genTokenBtn = document.getElementById('btn-gen-link-token'); const linkedAccountsList = document.getElementById('linked-accounts-list'); diff --git a/src/banner_handler.mjs b/src/banner_handler.mjs new file mode 100644 index 0000000..077b4a8 --- /dev/null +++ b/src/banner_handler.mjs @@ -0,0 +1,255 @@ +import cfg from "./inc/config.mjs"; +import path from "path"; +import { promises as fs } from "fs"; +import db from "./inc/sql.mjs"; +import lib from "./inc/lib.mjs"; +import { parseMultipart, collectBody } from "./inc/multipart.mjs"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; + +const execFile = promisify(_execFile); + +// Helper for JSON response +const sendJson = (res, data, code = 200) => { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +}; + +// Generate UUID using the same method as video uploads +const genuuid = async () => { + const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid; + return raw.substring(0, 48); +}; + +export const handleBannerUpload = async (req, res) => { + console.log('[BANNER HANDLER] Upload started'); + + // Manual Session Lookup + let user = []; + if (req.cookies && req.cookies.session) { + user = await db` + select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".* + 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 + where "user_sessions".session = ${lib.sha256(req.cookies.session)} + limit 1 + `; + } + + if (user.length === 0) { + console.log('[BANNER HANDLER] Unauthorized - No valid session found'); + return sendJson(res, { success: false, msg: 'Unauthorized' }, 401); + } + + req.session = user[0]; + console.log('[BANNER HANDLER] Authorized:', req.session.user); + + // CSRF validation + if (req.session.csrf_token) { + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || csrfToken !== req.session.csrf_token) { + console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`); + return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); + } + } + + try { + const contentType = req.headers['content-type'] || ''; + const boundaryMatch = contentType.match(/boundary=(.+)$/); + + if (!boundaryMatch) { + console.log('[BANNER HANDLER] No boundary'); + return sendJson(res, { success: false, msg: 'Invalid content type' }, 400); + } + + console.log('[BANNER HANDLER] Collecting body...'); + const body = await collectBody(req); + console.log('[BANNER HANDLER] Body collected, size:', body.length); + + console.log('[BANNER HANDLER] Parsing multipart...'); + const parts = parseMultipart(body, boundaryMatch[1]); + const file = parts.file; + console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts)); + + if (!file || !file.data) { + return sendJson(res, { success: false, msg: 'No file provided' }, 400); + } + + // Validate file size (5MB max) + const maxSize = 5 * 1024 * 1024; + if (file.data.length > maxSize) { + return sendJson(res, { + success: false, + msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB` + }, 400); + } + + // Allowed MIME types + const allowedMimes = [ + 'image/gif', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp' + ]; + + // Validate MIME type from content-type header + let mime = (file.contentType || '').toLowerCase().split(';')[0].trim(); + console.log('[BANNER HANDLER] File MIME from header:', mime); + if (!allowedMimes.includes(mime)) { + return sendJson(res, { + success: false, + msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}` + }, 400); + } + + // Save to tmp and verify with file magic + console.log('[BANNER HANDLER] Generating UUID...'); + const uuid = await genuuid(); + const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`); + const finalFilename = `banner_${uuid}.webp`; + const finalPath = path.join(cfg.paths.a, finalFilename); + + await fs.mkdir(cfg.paths.tmp, { recursive: true }); + await fs.mkdir(cfg.paths.a, { recursive: true }); + + console.log('[BANNER HANDLER] Writing tmp file:', tmpPath); + await fs.writeFile(tmpPath, file.data); + + // Verify MIME with file magic + console.log('[BANNER HANDLER] Checking MIME with file magic...'); + const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]); + console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim()); + const allowedActualMimes = [ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp' + ]; + + if (!allowedActualMimes.includes(actualMime.trim())) { + await fs.unlink(tmpPath).catch(() => { }); + return sendJson(res, { + success: false, + msg: `Invalid file type detected: ${actualMime.trim()}` + }, 400); + } + + // Convert to webp using ImageMagick — landscape banner crop (1200x400) + // NOTE: [0] frame selector must be appended to the input path, not a separate arg + console.log('[BANNER HANDLER] Running magick...'); + try { + await execFile('magick', [`${tmpPath}[0]`, '-resize', '1200x400^', '-gravity', 'center', '-background', 'none', '-extent', '1200x400', '-quality', '75', finalPath]); + } catch (err) { + console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr); + await fs.unlink(tmpPath).catch(() => { }); + return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500); + } + console.log('[BANNER HANDLER] Magick done, output:', finalPath); + + // Get current banner_file to delete old one (after magick succeeds) + let currentBanner = null; + try { + currentBanner = (await db` + select banner_file from user_options where user_id = ${+req.session.id} + `)[0]?.banner_file; + } catch (dbErr) { + console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message); + } + + // Clean up tmp file + await fs.unlink(tmpPath).catch(() => { }); + + // Delete old banner file if exists + if (currentBanner) { + const oldPath = path.join(cfg.paths.a, currentBanner); + await fs.unlink(oldPath).catch(() => { }); + } + + // Update database + console.log('[BANNER HANDLER] Updating database...'); + try { + await db` + update user_options + set banner_file = ${finalFilename} + where user_id = ${+req.session.id} + `; + } catch (dbErr) { + console.error('[BANNER HANDLER] DB update failed:', dbErr.message); + await fs.unlink(finalPath).catch(() => {}); + return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500); + } + + console.log('[BANNER HANDLER] Upload complete:', finalFilename); + return sendJson(res, { + success: true, + banner_file: finalFilename, + msg: 'Banner uploaded successfully' + }, 200); + + } catch (err) { + if (err.code === 'BODY_TOO_LARGE') { + return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413); + } + console.error('[BANNER HANDLER ERROR]', err.message, err.stack); + try { + return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500); + } catch (_) {} + } +}; + +export const handleBannerDelete = async (req, res) => { + console.log('[BANNER HANDLER] Delete started'); + + // Manual Session Lookup + let user = []; + if (req.cookies && req.cookies.session) { + user = await db` + select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".* + 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 + where "user_sessions".session = ${lib.sha256(req.cookies.session)} + limit 1 + `; + } + + if (user.length === 0) { + return sendJson(res, { success: false, msg: 'Unauthorized' }, 401); + } + + req.session = user[0]; + + // CSRF validation + if (req.session.csrf_token) { + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || csrfToken !== req.session.csrf_token) { + console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`); + return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); + } + } + + try { + const currentBanner = (await db` + select banner_file from user_options where user_id = ${+req.session.id} + `)[0]?.banner_file; + + if (currentBanner) { + const oldPath = path.join(cfg.paths.a, currentBanner); + await fs.unlink(oldPath).catch(() => { }); + } + + await db` + update user_options + set banner_file = null + where user_id = ${+req.session.id} + `; + + console.log('[BANNER HANDLER] Delete complete'); + return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200); + } catch (err) { + console.error('[BANNER DELETE ERROR]', err); + return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500); + } +}; diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 74f74b3..34faa35 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -534,6 +534,7 @@ export default { uo.display_name as author_display_name, uo.avatar as author_avatar, uo.avatar_file as author_avatar_file, + uo.banner_file as author_banner_file, uo.description as author_description, author_u.id as author_id, items.is_pinned, @@ -794,6 +795,7 @@ export default { author_display_name: actitem.author_display_name || null, author_avatar: actitem.author_avatar, author_avatar_file: actitem.author_avatar_file, + author_banner_file: actitem.author_banner_file, author_description: actitem.author_description, title: actitem.title || null, diff --git a/src/inc/routes/settings.mjs b/src/inc/routes/settings.mjs index 8224af7..d280762 100644 --- a/src/inc/routes/settings.mjs +++ b/src/inc/routes/settings.mjs @@ -23,9 +23,9 @@ export default (router, tpl) => { join tags t on t.id = et.id `; - // Get custom avatar file if exists + // Get custom avatar file and banner file if exists const userOptions = (await db` - select avatar_file from user_options where user_id = ${+req.session.id} + select avatar_file, banner_file from user_options where user_id = ${+req.session.id} `)[0]; // Get full user info @@ -46,8 +46,10 @@ export default (router, tpl) => { sessions, excluded_tags: excluded_tags || [], avatar_file: userOptions?.avatar_file || null, + banner_file: userOptions?.banner_file || null, email: user?.email || '', joined: user?.created_at || null, + user_banner_enabled: cfg.websrv.user_banner_enabled !== false, enable_swf: cfg.enable_swf, enable_data_export: cfg.websrv.enable_data_export, enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false, diff --git a/src/index.mjs b/src/index.mjs index 684f997..e2e38cf 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -10,6 +10,7 @@ import { getAboutText, setAboutText, getRulesText, setRulesText, getTermsText, s import flummpress from "flummpress"; import { handleUpload } from "./upload_handler.mjs"; import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs"; +import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs"; import { handleRethumbUpload } from "./rethumb_handler.mjs"; import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs"; import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs"; @@ -785,7 +786,7 @@ process.on('uncaughtException', err => { // because the session middleware will have completed by the time router callbacks execute. app.use(async (req, res) => { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; - if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return; + if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return; // DM attachment upload validates CSRF internally if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return; // Hall manager routes are handled by bypass middleware with their own session auth @@ -830,6 +831,23 @@ process.on('uncaughtException', err => { } }); + // Bypass middleware for banner upload (needs raw body before router consumes it) + // CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups + app.use(async (req, res) => { + if (req.url.pathname === '/api/v2/settings/uploadBanner') { + if (cfg.websrv.user_banner_enabled === false) { + return res.reply({ success: false, msg: 'Banner feature is currently disabled' }, 403); + } + if (req.method === 'POST') { + await handleBannerUpload(req, res); + req.url.pathname = '/handled_banner_upload_bypass'; + } else if (req.method === 'DELETE') { + await handleBannerDelete(req, res); + req.url.pathname = '/handled_banner_delete_bypass'; + } + } + }); + // Bypass middleware for custom thumbnail uploads app.use(async (req, res) => { const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/); @@ -1343,6 +1361,7 @@ process.on('uncaughtException', err => { lang: perRequestLang, user_alternative_infobox: useAltInfobox, user_alternative_steuerung: useAltSteuerung, + user_banner_enabled: cfg.websrv.user_banner_enabled !== false, comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number') ? req.session.comment_display_mode : (data && typeof data.comment_display_mode === 'number' diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index d3fe0f9..0e23b9a 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -84,7 +84,7 @@
@if(user_alternative_infobox) -
+
diff --git a/views/settings.html b/views/settings.html index 7280915..f36692f 100644 --- a/views/settings.html +++ b/views/settings.html @@ -136,6 +136,49 @@ @endif + @if(user_banner_enabled) +
+ Profile Banner +
+
+
Current Banner
+ @if(banner_file) +
+ + + @else + + @endif +
+ +
+

Upload a custom banner image for your profile card (max 5 MB — gif, jpg, png, webp). Will be cropped to a landscape ratio.

+ +
+ + + +
+ + + +
+ + @if(banner_file) + + @endif +
+ +
+
+
+ @endif +
{{ t('settings.username_color') }}