From 05922e4700cb8178ee1999dd9b4363dec75abb3a Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:53:00 +0200 Subject: [PATCH] fix(freeleech): address review feedback - a duration of 0 hours is refused instead of starting an open-ended freeleech - enabled flag and end date are written in one transaction, so a failed stop can no longer turn a timed freeleech into an open-ended one - the banner reloads when the user signs in or out and disappears exactly at the end date instead of waiting for the next poll Co-Authored-By: Claude Opus 5.5 --- app/components/admin/Freeleech.vue | 4 ++- app/layouts/default.vue | 40 ++++++++++++++++++++++-------- server/api/admin/freeleech.put.ts | 10 +++++--- server/utils/settings.ts | 20 +++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/app/components/admin/Freeleech.vue b/app/components/admin/Freeleech.vue index 602c91b0..594ad85b 100644 --- a/app/components/admin/Freeleech.vue +++ b/app/components/admin/Freeleech.vue @@ -82,7 +82,9 @@ async function save(enabled: boolean) { method: 'PUT', body: { enabled, - durationHours: enabled && durationHours.value ? Number(durationHours.value) : null, + // Only an empty field means "no end date": 0 is sent as is and refused + durationHours: + enabled && durationHours.value !== '' ? Number(durationHours.value) : null, }, }); } catch (err) { diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 197f6457..d88c5564 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -367,20 +367,40 @@ const { user, clear, fetch } = useUserSession(); const passkey = ref(null); const bonusPoints = ref(null); -// Global freeleech banner, refreshed periodically so it disappears on time -const freeleech = ref<{ active: boolean; until: string | null } | null>(null); +// Global freeleech banner. Reloaded when the user changes (sign in/out) and +// every 5 minutes; the end date is checked locally so it disappears on time. +type FreeleechBanner = { active: boolean; until: string | null }; +const freeleechState = ref(null); +const freeleechNow = ref(Date.now()); +const freeleech = computed(() => { + const state = freeleechState.value; + if (!state) return null; + const active = isFreeleechActive( + { enabled: state.active, until: state.until }, + new Date(freeleechNow.value) + ); + return active ? state : null; +}); async function loadFreeleech() { - if (!user.value) return; - freeleech.value = await $fetch<{ active: boolean; until: string | null }>( - '/api/freeleech' - ).catch(() => null); + if (!user.value) { + freeleechState.value = null; + return; + } + freeleechState.value = await $fetch('/api/freeleech').catch( + () => null + ); } -let freeleechTimer: ReturnType | undefined; +let freeleechPoll: ReturnType | undefined; +let freeleechTick: ReturnType | undefined; onMounted(() => { - loadFreeleech(); - freeleechTimer = setInterval(loadFreeleech, 5 * 60 * 1000); + watch(() => user.value?.id, loadFreeleech, { immediate: true }); + freeleechPoll = setInterval(loadFreeleech, 5 * 60 * 1000); + freeleechTick = setInterval(() => (freeleechNow.value = Date.now()), 30 * 1000); +}); +onUnmounted(() => { + clearInterval(freeleechPoll); + clearInterval(freeleechTick); }); -onUnmounted(() => clearInterval(freeleechTimer)); const router = useRouter(); const showUserMenu = ref(false); diff --git a/server/api/admin/freeleech.put.ts b/server/api/admin/freeleech.put.ts index 5a4d8d26..e6397431 100644 --- a/server/api/admin/freeleech.put.ts +++ b/server/api/admin/freeleech.put.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { requireAdminSession } from '../../utils/adminAuth'; import { validateBody } from '../../utils/schemas'; -import { SETTINGS_KEYS, getFreeleechState, setSetting } from '../../utils/settings'; +import { SETTINGS_KEYS, getFreeleechState, setSettings } from '../../utils/settings'; const freeleechSchema = z.object({ enabled: z.boolean(), @@ -22,8 +22,12 @@ export default defineEventHandler(async (event) => { ? new Date(Date.now() + durationHours * 3600 * 1000).toISOString() : ''; - await setSetting(SETTINGS_KEYS.FREELEECH_UNTIL, until); - await setSetting(SETTINGS_KEYS.FREELEECH_ENABLED, String(enabled)); + // Both together: a half-applied stop must never turn a timed freeleech + // into an open-ended one + await setSettings({ + [SETTINGS_KEYS.FREELEECH_ENABLED]: String(enabled), + [SETTINGS_KEYS.FREELEECH_UNTIL]: until, + }); return getFreeleechState(); }); diff --git a/server/utils/settings.ts b/server/utils/settings.ts index c72047ab..95020632 100644 --- a/server/utils/settings.ts +++ b/server/utils/settings.ts @@ -113,6 +113,26 @@ export async function setSetting(key: string, value: string): Promise { settingsCache.delete(key); } +/** + * Write several settings atomically (all or nothing) + */ +export async function setSettings(values: Record): Promise { + const now = new Date(); + await db.transaction(async (tx) => { + for (const [key, value] of Object.entries(values)) { + await tx + .insert(settings) + .values({ key, value, updatedAt: now }) + .onConflictDoUpdate({ + target: settings.key, + set: { value, updatedAt: now }, + }); + } + }); + + for (const key of Object.keys(values)) settingsCache.delete(key); +} + /** * Remove a setting so its getter falls back to the default */