From cf9f3262c000c04ba72dce94bdf84df7be678631 Mon Sep 17 00:00:00 2001 From: Frederik Aulich Date: Sat, 18 Apr 2026 16:20:11 +0200 Subject: [PATCH 1/2] Add cookie consent banner. --- app/app.vue | 1 + app/components/CookieNotice.vue | 90 ++++++++++++++++++++++++++++++ app/composables/useCookieNotice.ts | 46 +++++++++++++++ app/pages/index.vue | 3 - 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 app/components/CookieNotice.vue create mode 100644 app/composables/useCookieNotice.ts diff --git a/app/app.vue b/app/app.vue index fa4ee3e..feff3c5 100644 --- a/app/app.vue +++ b/app/app.vue @@ -2,5 +2,6 @@
+
diff --git a/app/components/CookieNotice.vue b/app/components/CookieNotice.vue new file mode 100644 index 0000000..4e0c19d --- /dev/null +++ b/app/components/CookieNotice.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/app/composables/useCookieNotice.ts b/app/composables/useCookieNotice.ts new file mode 100644 index 0000000..c15a11f --- /dev/null +++ b/app/composables/useCookieNotice.ts @@ -0,0 +1,46 @@ +export interface UseCookieNoticeOptions { + /** Key used to persist the dismissed state. */ + storageKey?: string + /** 'session' resets on tab close; 'local' persists across sessions. */ + storage?: 'session' | 'local' +} + +/** + * Reactive visibility + persistence for a cookie notice. + * Client-only: reads storage in onMounted to avoid SSR hydration mismatch. + */ +export function useCookieNotice(options: UseCookieNoticeOptions = {}) { + const storageKey = options.storageKey ?? 'cookie-notice:dismissed' + const storageType = options.storage ?? 'session' + const isVisible = ref(false) + + const store = () => (storageType === 'local' ? window.localStorage : window.sessionStorage) + + onMounted(() => { + try { + if (!store().getItem(storageKey)) isVisible.value = true + } catch { + isVisible.value = true + } + }) + + const dismiss = () => { + isVisible.value = false + try { + store().setItem(storageKey, '1') + } catch { + // storage blocked (private mode, disabled) — non-fatal. + } + } + + const reset = () => { + try { + store().removeItem(storageKey) + } catch { + // ignore + } + isVisible.value = true + } + + return { isVisible, dismiss, reset } +} diff --git a/app/pages/index.vue b/app/pages/index.vue index acfe48f..d5ef9e7 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -44,9 +44,6 @@ useHead({
- From 1214462c3b6d63262a02f79742f77df497cd8637 Mon Sep 17 00:00:00 2001 From: Frederik Aulich Date: Sat, 18 Apr 2026 16:20:37 +0200 Subject: [PATCH 2/2] Add logger to api routes. --- server/api/spotify/auth.get.ts | 5 ++++ server/api/spotify/callback.get.ts | 18 ++++++++++++-- server/api/spotify/last-liked.get.ts | 9 +++++++ server/utils/logger.ts | 35 ++++++++++++++++++++++++++++ server/utils/spotifyToken.ts | 14 +++++++++-- 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 server/utils/logger.ts diff --git a/server/api/spotify/auth.get.ts b/server/api/spotify/auth.get.ts index 9ce67e5..bc8f8b8 100644 --- a/server/api/spotify/auth.get.ts +++ b/server/api/spotify/auth.get.ts @@ -1,16 +1,21 @@ import { randomBytes } from 'node:crypto' +import { createLogger } from '../../utils/logger' + +const log = createLogger('spotify:auth') export default defineEventHandler((event) => { const config = useRuntimeConfig() const { secret } = getQuery(event) as { secret?: string } if (!config.spotifyAuthSecret || secret !== config.spotifyAuthSecret) { + log.warn('auth attempt with invalid or missing secret') throw createError({ statusCode: 403, message: 'Forbidden' }) } const state = randomBytes(16).toString('hex') setCookie(event, 'spotify_oauth_state', state, { httpOnly: true, sameSite: 'lax', maxAge: 600 }) + log.info('initiating OAuth flow — redirecting to Spotify') const params = new URLSearchParams({ client_id: config.spotifyClientId, response_type: 'code', diff --git a/server/api/spotify/callback.get.ts b/server/api/spotify/callback.get.ts index fb06f0c..8ebb51a 100644 --- a/server/api/spotify/callback.get.ts +++ b/server/api/spotify/callback.get.ts @@ -1,15 +1,27 @@ +import { createLogger } from '../../utils/logger' + +const log = createLogger('spotify:callback') + export default defineEventHandler(async (event) => { const { code, error, state } = getQuery(event) as { code?: string; error?: string; state?: string } - if (error) throw createError({ statusCode: 400, message: `Spotify auth denied: ${error}` }) - if (!code) throw createError({ statusCode: 400, message: 'Missing authorization code' }) + if (error) { + log.warn('OAuth callback returned an error from Spotify') + throw createError({ statusCode: 400, message: `Spotify auth denied: ${error}` }) + } + if (!code) { + log.warn('OAuth callback missing authorization code') + throw createError({ statusCode: 400, message: 'Missing authorization code' }) + } const expectedState = getCookie(event, 'spotify_oauth_state') if (!state || state !== expectedState) { + log.warn('state mismatch in OAuth callback — possible CSRF attempt') throw createError({ statusCode: 400, message: 'Invalid state parameter — possible CSRF attempt' }) } deleteCookie(event, 'spotify_oauth_state') + log.info('state verified — exchanging code for tokens') const config = useRuntimeConfig() const credentials = Buffer.from(`${config.spotifyClientId}:${config.spotifyClientSecret}`).toString('base64') @@ -28,12 +40,14 @@ export default defineEventHandler(async (event) => { if (!res.ok) { const body = await res.json().catch(() => ({})) + log.error(`token exchange failed — status ${res.status}`) throw createError({ statusCode: res.status, message: `Token exchange failed: ${body.error_description ?? res.statusText}`, }) } + log.info('token exchange successful') const data = await res.json() return { diff --git a/server/api/spotify/last-liked.get.ts b/server/api/spotify/last-liked.get.ts index d0f604a..885a8a1 100644 --- a/server/api/spotify/last-liked.get.ts +++ b/server/api/spotify/last-liked.get.ts @@ -1,5 +1,10 @@ +import { createLogger } from '../../utils/logger' + +const log = createLogger('spotify:last-liked') + export default defineCachedEventHandler( async () => { + log.info('handling request') const accessToken = await getSpotifyAccessToken() const res = await fetch('https://api.spotify.com/v1/me/tracks?limit=1', { @@ -8,11 +13,13 @@ export default defineCachedEventHandler( if (res.status === 429) { const retryAfter = res.headers.get('Retry-After') ?? '60' + log.warn(`rate limited by Spotify — retry after ${retryAfter}s`) throw createError({ statusCode: 429, message: `Rate limited. Retry after ${retryAfter}s.` }) } if (!res.ok) { const body = await res.json().catch(() => ({})) + log.error(`Spotify API error — status ${res.status}`) throw createError({ statusCode: res.status, message: body.error?.message ?? `Spotify API error: ${res.statusText}`, @@ -23,9 +30,11 @@ export default defineCachedEventHandler( const item = data.items?.[0] if (!item) { + log.warn('no liked tracks found in response') throw createError({ statusCode: 404, message: 'No liked tracks found' }) } + log.info('request successful — returning track') const track = item.track return { title: track.name as string, diff --git a/server/utils/logger.ts b/server/utils/logger.ts new file mode 100644 index 0000000..4f64185 --- /dev/null +++ b/server/utils/logger.ts @@ -0,0 +1,35 @@ +type Level = 'INFO' | 'WARN' | 'ERROR' + +export interface LoggerOptions { + /** Include an ISO timestamp prefix. Defaults to true. */ + timestamp?: boolean +} + +export interface Logger { + info(message: string): void + warn(message: string): void + error(message: string): void +} + +function format(level: Level, namespace: string, message: string, timestamp: boolean): string { + const prefix = timestamp ? `${new Date().toISOString()} ` : '' + return `${prefix}[${level}] [${namespace}] ${message}` +} + +/** + * Returns a namespaced logger. info → stdout, warn/error → stderr. + * + * Usage: + * const log = createLogger('spotify:token') + * log.info('cache hit') + * log.error('refresh failed: 401') + */ +export function createLogger(namespace: string, options: LoggerOptions = {}): Logger { + const timestamp = options.timestamp ?? true + + return { + info: (message) => process.stdout.write(format('INFO', namespace, message, timestamp) + '\n'), + warn: (message) => process.stderr.write(format('WARN', namespace, message, timestamp) + '\n'), + error: (message) => process.stderr.write(format('ERROR', namespace, message, timestamp) + '\n'), + } +} diff --git a/server/utils/spotifyToken.ts b/server/utils/spotifyToken.ts index cb7c4c6..7f69f3d 100644 --- a/server/utils/spotifyToken.ts +++ b/server/utils/spotifyToken.ts @@ -1,20 +1,28 @@ +import { createLogger } from './logger' + +const log = createLogger('spotify:token') + let cached: { accessToken: string; expiresAt: number } | null = null export async function getSpotifyAccessToken(): Promise { if (cached && Date.now() < cached.expiresAt - 60_000) { + log.info('cache hit — reusing access token') return cached.accessToken } const config = useRuntimeConfig() - const credentials = Buffer.from(`${config.spotifyClientId}:${config.spotifyClientSecret}`).toString('base64') if (!config.spotifyRefreshToken) { + log.error('NUXT_SPOTIFY_REFRESH_TOKEN is not set') throw createError({ statusCode: 500, message: 'NUXT_SPOTIFY_REFRESH_TOKEN is not set. Visit /api/spotify/auth to complete the one-time auth flow.', }) } + log.info('access token expired or absent — requesting refresh') + const credentials = Buffer.from(`${config.spotifyClientId}:${config.spotifyClientSecret}`).toString('base64') + const res = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { @@ -29,6 +37,7 @@ export async function getSpotifyAccessToken(): Promise { if (!res.ok) { const body = await res.json().catch(() => ({})) + log.error(`token refresh failed — status ${res.status}`) throw createError({ statusCode: 502, message: `Spotify token refresh failed (${res.status}): ${body.error_description ?? body.error ?? res.statusText}`, @@ -38,9 +47,10 @@ export async function getSpotifyAccessToken(): Promise { const data = await res.json() if (data.refresh_token) { - console.warn('[spotify] New refresh token issued — update SPOTIFY_REFRESH_TOKEN in your env:', data.refresh_token) + log.warn('Spotify issued a new refresh token — update NUXT_SPOTIFY_REFRESH_TOKEN in your env') } cached = { accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 } + log.info(`token refreshed — expires in ${data.expires_in}s`) return cached.accessToken }