import { defineMiddleware } from 'astro:middleware'; import { serverClient } from '@/lib/supabase'; const UABC_DOMAIN = '@uabc.edu.mx'; const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api/auth/signout']; // Rutas siempre accesibles para un user baneado (sino, loop de rewrite) const BANNED_ALLOWED = ['/banned', '/api/auth/signout']; // Caches en memoria del proceso — TTL corto, mono-container. Se invalidan en signout // y al actualizar profile. Reducen 2-3 hits a Postgres por request a ~0.05 en promedio. const PROFILE_TTL_MS = 30_000; const BANNED_TTL_MS = 30_000; type CachedProfile = { profile: any; exp: number }; type CachedBanned = { banned: boolean; exp: number }; const profileCache = new Map(); const bannedCache = new Map(); export function invalidateUserCaches(uid: string) { profileCache.delete(uid); bannedCache.delete(uid); } export const onRequest = defineMiddleware(async (context, next) => { // Skip assets estáticos y audio — Astro los sirve por el mismo pipeline y no necesitan auth. const path = context.url.pathname; if ( path.startsWith('/_astro/') || path.startsWith('/audio/') || path === '/favicon.svg' || path === '/favicon.ico' || path === '/robots.txt' ) { return next(); } const supabase = serverClient(context.cookies); context.locals.supabase = supabase; const { data: { user } } = await supabase.auth.getUser(); if (user && !user.email?.toLowerCase().endsWith(UABC_DOMAIN)) { await supabase.auth.signOut(); return context.redirect('/login?error=dominio'); } context.locals.user = user; context.locals.profile = null; if (user) { const now = Date.now(); const cached = profileCache.get(user.id); let profile: any = cached && cached.exp > now ? cached.profile : null; if (!profile) { const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path'; const { data } = await supabase .from('profiles') .select(select) .eq('id', user.id) .maybeSingle(); profile = data ?? null; // Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro // trigger del proyecto vecino en auth.users y no siempre dispara. Si el // user existe pero no hay profile, lo creamos aquí con los datos del OAuth. if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) { const nombre = (user.user_metadata?.full_name as string | undefined) ?? (user.user_metadata?.name as string | undefined) ?? null; const { data: creado } = await supabase .from('profiles') .upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' }) .select(select) .maybeSingle(); profile = creado ?? null; } if (profile) profileCache.set(user.id, { profile, exp: now + PROFILE_TTL_MS }); } context.locals.profile = profile ?? null; } const { pathname } = context.url; const isPublic = PUBLIC_ROUTES.includes(pathname); if (!user && !isPublic) { return context.redirect('/login'); } if (user && pathname === '/login') { return context.redirect('/'); } // Ban guard: si el user está baneado (y no es admin), toda ruta cae a /banned // salvo la propia /banned y el signout. RPC is_banned respeta expires_at. // Cache 30s por uid — se invalida al banear/desbanear desde los endpoints admin. if (user && context.locals.profile && context.locals.profile.rol !== 'admin') { const now = Date.now(); const cached = bannedCache.get(user.id); let banned: boolean; if (cached && cached.exp > now) { banned = cached.banned; } else { const { data } = await supabase.rpc('is_banned', { p_uid: user.id }); banned = data === true; bannedCache.set(user.id, { banned, exp: now + BANNED_TTL_MS }); } if (banned && !BANNED_ALLOWED.includes(pathname)) { return context.rewrite('/banned'); } } if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') { return context.rewrite('/403'); } return next(); });