diff --git a/config_example.json b/config_example.json index 8552c01..4eca651 100644 --- a/config_example.json +++ b/config_example.json @@ -30,6 +30,8 @@ "enable_pdf": false, "enable_nsfl": false, "enable_private_uploads": true, + "default_upload_visibility": 0, + "allow_user_upload_visibility": true, "enable_item_slugs": true, "nsfl_tag_id": 4, "allowedMimes": [ diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 1d15f6f..b8452ad 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -1508,13 +1508,20 @@ CREATE TABLE public.user_options ( force_comment_display_mode integer DEFAULT 0, favorites_private boolean DEFAULT false, hide_fav_badge boolean DEFAULT false, - default_upload_visibility smallint DEFAULT 0 + default_upload_visibility smallint DEFAULT 0, + banner_file character varying(255) DEFAULT NULL::character varying, + banner_position character varying(50) DEFAULT 'center'::character varying, + banner_size character varying(50) DEFAULT 'cover'::character varying ); ALTER TABLE public.user_options OWNER TO f0ckm; COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)'; +COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/'; +COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner'; +COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner'; + -- @@ -2964,4 +2971,9 @@ ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm; CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id); +-- User Banners +ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL; +ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'; +ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'; + \unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG diff --git a/public/s/js/upload.js b/public/s/js/upload.js index c0cdbf2..be32d6a 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -1518,25 +1518,30 @@ window.initUploadForm = (selector) => { `; - const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0'; - const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis; - item.visibility = visValue; - visibilitySwitch = ` -
- `; + const hasVisSection = !!form.querySelector('.global-visibility-section'); + if (hasVisSection) { + const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0'; + const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis; + item.visibility = visValue; + visibilitySwitch = ` + + `; + } else { + visibilitySwitch = ''; + } const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...'; const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : ''; diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index fe67d86..c0eb0fe 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -355,6 +355,9 @@ export default router => { // Update Default Upload Visibility preference group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => { + if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) { + return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403); + } const vis = parseInt(req.post.default_upload_visibility, 10); if (isNaN(vis) || ![0, 1, 2].includes(vis)) { return res.json({ success: false, msg: 'Invalid visibility option' }, 400); diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 21c2afc..de90f52 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -139,12 +139,25 @@ import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../se const getTargetVisibility = (req, postVis) => { if (cfg.enable_private_uploads === false) return 0; + + const sysDefault = (typeof cfg.default_upload_visibility === 'number') + ? cfg.default_upload_visibility + : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0); + + const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false; + + if (!allowUserOverride) { + return sysDefault; + } + const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null; const val = (rawHeader || postVis || '').toString().trim().toLowerCase(); if (val === 'private' || val === '2') return 2; if (val === 'unlisted' || val === '1') return 1; if (val === 'public' || val === '0') return 0; - return req.session?.default_upload_visibility || 0; + return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null) + ? req.session.default_upload_visibility + : sysDefault; }; // Collect request body as buffer with debug logging diff --git a/src/index.mjs b/src/index.mjs index 948e7e2..ff16314 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -875,13 +875,28 @@ process.on('uncaughtException', err => { } }); - // CSRF validation helper — used by route handlers that have already populated req.session - // NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers - // because flummpress runs ALL middlewares in parallel (Promise.all), so the session - // middleware hasn't finished by the time these run. Those handlers validate CSRF inline. - const validateCsrf = (req, res) => { + // Intercept app.readBody to allow memoized reading of request body. + // flummpress runs app.use() in parallel via Promise.all before routing/body-reading, + // so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing. + const _originalReadBody = app.readBody.bind(app); + app.readBody = async (req) => { + if (req.post !== undefined) return req.post; + return await _originalReadBody(req); + }; + + // CSRF validation helper — used by route handlers and global middleware + const validateCsrf = async (req, res) => { if (req.session && req.session.csrf_token) { - const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token; + let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token; + + // If header/query token is missing and body is not parsed yet on a non-GET method, parse it now + if (!token && req.post === undefined && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { + try { + req.post = await app.readBody(req); + token = req.post?.csrf_token; + } catch (e) {} + } + if (!token || token !== req.session.csrf_token) { console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`); res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' })); @@ -903,7 +918,7 @@ process.on('uncaughtException', err => { if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return; // User hall image upload is handled by bypass middleware below if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return; - if (!validateCsrf(req, res)) return; + if (!(await validateCsrf(req, res))) return; }); // Bypass middleware for direct upload handling @@ -1363,6 +1378,8 @@ process.on('uncaughtException', err => { site_description: cfg.websrv.description || "The webs dumpster", enable_nsfl: !!cfg.enable_nsfl, enable_private_uploads: cfg.enable_private_uploads !== false, + default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)), + allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false, nsfl_tag_id: cfg.nsfl_tag_id || 3, scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'], themes_json: JSON.stringify(cfg.websrv.themes || []), diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 194683b..dd27bab 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -145,18 +145,30 @@ export const handleUpload = async (req, res, self) => { // Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference let targetVisibility = 0; if (cfg.enable_private_uploads !== false) { - const rawVisHeader = req.headers['x-upload-visibility']; - const rawVisBody = parts.visibility; - const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase(); + const sysDefault = (typeof cfg.default_upload_visibility === 'number') + ? cfg.default_upload_visibility + : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0); - if (visVal === 'private' || visVal === '2') { - targetVisibility = 2; - } else if (visVal === 'unlisted' || visVal === '1') { - targetVisibility = 1; - } else if (visVal === 'public' || visVal === '0') { - targetVisibility = 0; + const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false; + + if (!allowUserOverride) { + targetVisibility = sysDefault; } else { - targetVisibility = req.session?.default_upload_visibility || 0; + const rawVisHeader = req.headers['x-upload-visibility']; + const rawVisBody = parts.visibility; + const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase(); + + if (visVal === 'private' || visVal === '2') { + targetVisibility = 2; + } else if (visVal === 'unlisted' || visVal === '1') { + targetVisibility = 1; + } else if (visVal === 'public' || visVal === '0') { + targetVisibility = 0; + } else { + targetVisibility = (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null) + ? req.session.default_upload_visibility + : sysDefault; + } } } diff --git a/views/admin.html b/views/admin.html index 04f0edd..34184e0 100644 --- a/views/admin.html +++ b/views/admin.html @@ -117,18 +117,20 @@ status.style.color = 'var(--accent)'; try { + const csrfToken = window.f0ckSession?.csrf_token || '{{ csrf_token }}'; const res = await fetch('/admin/settings', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': csrfToken }, body: new URLSearchParams({ manual_approval: approvalToggle.checked ? 'on' : 'off', ...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}), min_tags: minTagsInput.value, trusted_uploads: trustedUploadsInput.value, - csrf_token: '{{ csrf_token }}' + csrf_token: csrfToken }).toString() }); diff --git a/views/settings.html b/views/settings.html index 93bf14f..cb38d4b 100644 --- a/views/settings.html +++ b/views/settings.html @@ -334,17 +334,19 @@ {{ t('settings.hide_fav_badge_hint') }} + @if(allow_user_upload_visibility !== false)