diff --git a/migrations/add_favorites_private.sql b/migrations/add_favorites_private.sql new file mode 100644 index 0000000..787adf5 --- /dev/null +++ b/migrations/add_favorites_private.sql @@ -0,0 +1,5 @@ +-- Add favorites_private column to user_options table +ALTER TABLE user_options + ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false; + +COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list'; diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 0a36dd1..3d95514 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -1498,7 +1498,8 @@ CREATE TABLE public.user_options ( receive_user_notifications boolean DEFAULT true, do_not_disturb boolean DEFAULT false, comment_display_mode integer DEFAULT 1, - force_comment_display_mode integer DEFAULT 0 + force_comment_display_mode integer DEFAULT 0, + favorites_private boolean DEFAULT false ); diff --git a/public/s/js/settings.js b/public/s/js/settings.js index ca4e384..4ada4b7 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1119,6 +1119,37 @@ }); } + const favsPrivateToggle = document.getElementById('favorites_private_toggle'); + if (favsPrivateToggle) { + favsPrivateToggle.addEventListener('change', async () => { + const favorites_private = favsPrivateToggle.checked; + try { + const res = await fetch('/api/v2/settings/favorites_private', { + method: 'PUT', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : '' + }, + body: new URLSearchParams({ favorites_private }) + }); + const data = await res.json(); + if (data.success) { + showStatus('Favorites privacy preference updated!', 'success'); + if (window.f0ckSession) { + window.f0ckSession.favorites_private = favorites_private; + } + } else { + alert(data.msg || 'Error saving preference'); + favsPrivateToggle.checked = !favorites_private; // Revert + } + } catch (err) { + console.error('Update Favorites Privacy error:', err); + alert('Connection error'); + favsPrivateToggle.checked = !favorites_private; // Revert + } + }); + } + // New Dual Column Layout Toggle const layoutToggle = document.getElementById('use_new_layout_toggle'); if (layoutToggle) { diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index ec8ed36..2355b0d 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -165,6 +165,8 @@ "hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite", "disable_swiping": "Wischen deaktivieren", "disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren", + "favorites_private": "Private Favoriten", + "favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.", "image_expand_on_click": "Bilder beim Klicken inline erweitern", "image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.", "enable_bg_blur": "Hintergrundunschärfe aktivieren", @@ -535,6 +537,7 @@ "subscribe_uploads_btn": "Benutzer für Uploads abonnieren", "no_uploads": "Keine Uploads gefunden", "no_favs": "Keine Favoriten", + "private_favorites": "private Favoriten", "back_to_profile": "Zurück zum Profil", "ban_modal_title": "Benutzer sperren", "ban_modal_reason": "Grund:", diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index fddd032..3bc8ef9 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -165,6 +165,8 @@ "hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items", "disable_swiping": "Disable Swiping", "disable_swiping_hint": "Disable swipe-to-navigate on mobile devices", + "favorites_private": "Private Favorites", + "favorites_private_hint": "Only you and administrators can view your favorites list.", "image_expand_on_click": "Expand images inline on click", "image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.", "enable_bg_blur": "Enable Background blur", @@ -539,6 +541,7 @@ "subscribe_uploads_btn": "Subscribe user to uploads", "no_uploads": "no uploads found", "no_favs": "no favorites", + "private_favorites": "private favorites", "back_to_profile": "Back to Profile", "ban_modal_title": "Ban User", "ban_modal_reason": "Reason:", diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index d0ead57..539ed77 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -165,6 +165,8 @@ "hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items", "disable_swiping": "Swipen uitschakelen", "disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten", + "favorites_private": "Privé favorieten", + "favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.", "image_expand_on_click": "Afbeeldingen inline vergroten bij klikken", "image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.", "enable_bg_blur": "Achtergrondvervaging inschakelen", @@ -535,6 +537,7 @@ "subscribe_uploads_btn": "Gebruiker abonneren op uploads", "no_uploads": "geen uploads gevonden", "no_favs": "geen favorieten", + "private_favorites": "privé favorieten", "back_to_profile": "Terug naar Profiel", "ban_modal_title": "Gebruiker Bannen", "ban_modal_reason": "Reden:", diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 168ffbc..1cacf08 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -165,6 +165,8 @@ "hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite", "disable_swiping": "Wischen deaktivieren", "disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten", + "favorites_private": "Private Favoriten", + "favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.", "image_expand_on_click": "Bildli beim Klickle inline uffblähe", "image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.", "enable_bg_blur": "Hintergrundunschärfe aktivieren", @@ -536,6 +538,7 @@ "subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren", "no_uploads": "keine Aufladierungen gefunden", "no_favs": "keine Favoriten", + "private_favorites": "private Favoriten", "back_to_profile": "Zurück zum Profil", "ban_modal_title": "Benutzer sperren", "ban_modal_reason": "Grund:", diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index b3fd2a4..7996679 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -136,8 +136,60 @@ const xdScoreMeta = (score) => { return { tier: 5, label: 'xDDDDD+' }; }; +async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) { + if (!rawUser) return { isPrivate: false, isAllowed: true }; + const decodedUser = decodeURI(rawUser); + const targetUserRows = await db` + select u.id, u."user", u.admin, uo.favorites_private + from "user" u + left join user_options uo on uo.user_id = u.id + where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser} + limit 1 + `; + if (!targetUserRows.length || !targetUserRows[0].favorites_private) { + return { isPrivate: false, isAllowed: true }; + } + + const targetUser = targetUserRows[0]; + + let reqUserId = user_id; + let reqIsAdmin = is_admin; + + if (typeof session === 'object' && session !== null) { + if (reqUserId === undefined) reqUserId = session.id; + if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin; + } + + let isAllowed = false; + if (reqUserId) { + if (+reqUserId === +targetUser.id) { + isAllowed = true; + } else if (reqIsAdmin === true) { + isAllowed = true; + } else if (reqIsAdmin === undefined) { + const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`; + if (reqUserRows.length > 0 && reqUserRows[0].admin) { + isAllowed = true; + } + } + } + + return { isPrivate: true, isAllowed }; +} + export default { - getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => { + getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => { + if (fav && rawUser) { + const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); + if (isPrivate && !isAllowed) { + return { + success: false, + is_private: true, + message: "private favorites" + }; + } + } + const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; // --- title: prefix — search items.title instead of the tags table --- @@ -428,7 +480,18 @@ export default { view_mode: fav ? 'favs' : 'uploads' }; }, - getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => { + getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => { + if (fav && rawUser) { + const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); + if (isPrivate && !isAllowed) { + return { + success: false, + is_private: true, + message: "private favorites" + }; + } + } + const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; // --- title: prefix — search items.title instead of the tags table --- @@ -884,7 +947,14 @@ export default { tmp }; return data; - }, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => { + }, + getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => { + if (fav && rawUser) { + const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); + if (isPrivate && !isAllowed) { + return null; + } + } const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const hall = rawHall || null; diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 15c6e56..5b5df55 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -218,9 +218,10 @@ export default (router, tpl) => { mime: query.mime || (req.cookies.mime || null), mode: query.mode !== undefined ? +query.mode : req.mode, ratings: ratingsArr, - session: !!req.session, - exclude: req.session ? (req.session.excluded_tags || []) : [], + session: req.session, user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], fav: query.fav === 'true', random: isRandom, strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode, @@ -231,6 +232,19 @@ export default (router, tpl) => { }); if (!data.success) { + if (data.is_private) { + const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); + const privateHtml = `
${tErr('profile.private_favorites')}
`; + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success: false, + is_private: true, + html: privateHtml, + hasMore: false + }) + }); + } return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index 390acc2..febe441 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -315,6 +315,25 @@ export default router => { } }); + // Update Favorites Privacy preference + group.put(/\/favorites_private/, lib.loggedin, async (req, res) => { + const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true'; + + try { + await db` + update user_options + set favorites_private = ${favorites_private} + where user_id = ${+req.session.id} + `; + // Sync session immediately + if (req.session) req.session.favorites_private = favorites_private; + return res.json({ success: true, favorites_private }, 200); + } catch (e) { + console.error('Update Favorites Privacy pref error:', e); + return res.json({ success: false, msg: 'Error updating preference' }, 500); + } + }); + // Update Username Color preference group.put(/\/username_color/, lib.loggedin, async (req, res) => { const { color } = req.post; diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index be95fff..05c9587 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -99,8 +99,9 @@ export default (router, tpl) => { ratings: ratingsArr, mime: mime, fav: true, - session: !!req.session, + session: req.session, user_id: req.session?.id, + is_admin: req.session?.admin, random: isRandom }); if (favs && 'items' in favs) { @@ -236,8 +237,9 @@ export default (router, tpl) => { fav: req.params.mode == 'favs', mode: req.mode, ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), - session: !!req.session, + session: req.session, user_id: req.session?.id, + is_admin: req.session?.admin, exclude: req.session ? (req.session.excluded_tags || []) : [], url: decodeURIComponent(req.url.pathname || req.url), strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), @@ -251,6 +253,20 @@ export default (router, tpl) => { console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); if (!data.success) { + if (data.is_private) { + const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); + return res.reply({ + code: 403, + body: tpl.render('error', { + message: tErr('profile.private_favorites'), + domain: cfg.main.url.domain, + tmp: null, + session: req.session ? { ...req.session } : false, + error_filter_hint: null, + error_filter_hint_link: null + }, req) + }); + } // For index/grid views with zero items (empty DB), render an empty grid instead of error if (mode !== 'item') { data.items = []; diff --git a/src/index.mjs b/src/index.mjs index 15aedce..862710e 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -672,7 +672,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".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".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 + 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".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".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 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 diff --git a/views/settings.html b/views/settings.html index 83ecc83..565f5db 100644 --- a/views/settings.html +++ b/views/settings.html @@ -320,6 +320,13 @@ {{ t('settings.disable_swiping_hint') }} +
+ + {{ t('settings.favorites_private_hint') }} +
@if(!user.is_ghost)
+ @if(favs && favs.is_private) +
+ {{ t('profile.favs_label') }} +
+
+ {{ t('profile.private_favorites') }} +
+ @else
{{ t('profile.favs_label') }}: {{ count.favs }} {{ t('profile.view_all') }}
@@ -164,6 +172,7 @@ @else {{ t('profile.no_favs') }} @endif + @endif
@endif