Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
<CookieNotice />
</div>
</template>
90 changes: 90 additions & 0 deletions app/components/CookieNotice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
interface Props {
title?: string
message?: string
closeLabel?: string
closeIcon?: string
storageKey?: string
storage?: 'session' | 'local'
}

const props = withDefaults(defineProps<Props>(), {
title: 'Cookies',
message:
'This site does not track you. The only cookies used are strictly necessary for the application to function.',
closeLabel: 'Dismiss cookie notice',
closeIcon: '🍪',
storageKey: 'cookie-notice:dismissed',
storage: 'session',
})

const { isVisible, dismiss } = useCookieNotice({
storageKey: props.storageKey,
storage: props.storage,
})

const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible.value) dismiss()
}

onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>

<template>
<Transition name="cookie-notice">
<div
v-if="isVisible"
role="region"
aria-label="Cookie notice"
class="cookie-notice pointer-events-none fixed inset-x-0 bottom-0 z-50"
>
<div aria-hidden="true" class="cookie-notice-fade absolute inset-x-0 bottom-0" />

<div class="relative flex justify-center p-4 sm:p-6">
<div
class="pointer-events-auto flex w-full max-w-xl items-start gap-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg-elevated)] p-4 shadow-xl sm:items-center sm:p-5"
>
<div class="min-w-0 flex-1 text-sm text-[var(--color-fg-muted)]">
<slot name="message" :title="title" :message="message">
<p>
<strong class="text-[var(--color-fg)]">{{ title }}.</strong>
{{ message }}
</p>
</slot>
</div>
<button
type="button"
:aria-label="closeLabel"
:title="closeLabel"
class="shrink-0 rounded-full border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-base leading-none transition hover:scale-105 hover:border-[var(--color-fg-muted)] focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-elevated)] focus-visible:outline-none"
@click="dismiss"
>
<span aria-hidden="true">{{ closeIcon }}</span>
</button>
</div>
</div>
</div>
</Transition>
</template>

<style scoped>
.cookie-notice-fade {
height: 45vh;
background: linear-gradient(to top, var(--color-bg) 0%, var(--color-bg) 18%, transparent 100%);
-webkit-mask-image: linear-gradient(to top, black 0%, black 70%, transparent 100%);
mask-image: linear-gradient(to top, black 0%, black 70%, transparent 100%);
}

.cookie-notice-enter-active,
.cookie-notice-leave-active {
transition:
opacity 260ms ease,
transform 260ms ease;
}
.cookie-notice-enter-from,
.cookie-notice-leave-to {
opacity: 0;
transform: translateY(1rem);
}
</style>
46 changes: 46 additions & 0 deletions app/composables/useCookieNotice.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
3 changes: 0 additions & 3 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@ useHead({
<SiteHeader />
<main>
<HeroSection />
<div class="mx-auto max-w-3xl px-6" aria-hidden="true">
<hr class="border-[var(--color-border)]" />
</div>
<AboutSection />
<SkillsSection />
<ProjectsSection />
Expand Down
5 changes: 5 additions & 0 deletions server/api/spotify/auth.get.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
18 changes: 16 additions & 2 deletions server/api/spotify/callback.get.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions server/api/spotify/last-liked.get.ts
Original file line number Diff line number Diff line change
@@ -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', {
Expand All @@ -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}`,
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions server/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -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'),
}
}
14 changes: 12 additions & 2 deletions server/utils/spotifyToken.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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: {
Expand All @@ -29,6 +37,7 @@ export async function getSpotifyAccessToken(): Promise<string> {

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}`,
Expand All @@ -38,9 +47,10 @@ export async function getSpotifyAccessToken(): Promise<string> {
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
}
Loading