You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			125 lines
		
	
	
		
			3.8 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			125 lines
		
	
	
		
			3.8 KiB
		
	
	
	
		
			TypeScript
		
	
| import type { Handle } from '@sveltejs/kit';
 | |
| import { getDb } from '$lib/server/db';
 | |
| import { sha256Hex } from '$lib/server/crypto';
 | |
| import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
 | |
| 
 | |
| function toIsoSql(d: Date): string {
 | |
| 	return d.toISOString().replace('T', ' ').replace('Z', '');
 | |
| }
 | |
| 
 | |
| export const handle: Handle = async ({ event, resolve }) => {
 | |
| 	// Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
 | |
| 	const bypass = isDev();
 | |
| 	if (bypass) {
 | |
| 		const qp = event.url.searchParams.get('__as')?.trim();
 | |
| 		const current = event.cookies.get('dev_as') || '';
 | |
| 		const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER);
 | |
| 		if (qp && qp.length && qp !== current) {
 | |
| 			event.cookies.set('dev_as', user, {
 | |
| 				path: '/',
 | |
| 				httpOnly: true,
 | |
| 				sameSite: 'lax',
 | |
| 				secure: isProd(),
 | |
| 				maxAge: 60 * 60 * 24 * 30 // 30 días
 | |
| 			});
 | |
| 		}
 | |
| 		event.locals.userId = user;
 | |
| 	}
 | |
| 	// Sesión por cookie 'sid'
 | |
| 	const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/');
 | |
| 	const sid = event.cookies.get('sid');
 | |
| 	if (!bypass && sid) {
 | |
| 		try {
 | |
| 			const db = await getDb();
 | |
| 			const hash = await sha256Hex(sid);
 | |
| 
 | |
| 			// Validar sesión vigente
 | |
| 			const row = db
 | |
| 				.prepare(
 | |
| 					`SELECT user_id FROM web_sessions
 | |
|            WHERE session_hash = ?
 | |
|              AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')
 | |
|            LIMIT 1`
 | |
| 				)
 | |
| 				.get(hash) as { user_id: string } | undefined;
 | |
| 
 | |
| 			if (row?.user_id) {
 | |
| 				event.locals.userId = row.user_id;
 | |
| 
 | |
| 				// Renovar expiración por inactividad y last_seen_at
 | |
| 				const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
 | |
| 				try {
 | |
| 					db.prepare(
 | |
| 						`UPDATE web_sessions
 | |
|              SET last_seen_at = strftime('%Y-%m-%d %H:%M:%f','now'),
 | |
|                  expires_at = ?
 | |
|              WHERE session_hash = ?`
 | |
| 					).run(newExpIso, hash);
 | |
| 				} catch {
 | |
| 					// Si no existe last_seen_at en el esquema, al menos renovar expires_at
 | |
| 					try {
 | |
| 						db.prepare(
 | |
| 							`UPDATE web_sessions
 | |
|                SET expires_at = ?
 | |
|                WHERE session_hash = ?`
 | |
| 						).run(newExpIso, hash);
 | |
| 					} catch {}
 | |
| 				}
 | |
| 
 | |
| 				// Refrescar cookie (idle) excepto durante /api/logout
 | |
| 				if (!isLogout) {
 | |
| 					event.cookies.set('sid', sid, {
 | |
| 						path: '/',
 | |
| 						httpOnly: true,
 | |
| 						sameSite: 'lax',
 | |
| 						secure: isProd(),
 | |
| 						maxAge: Math.floor(sessionIdleTtlMs / 1000)
 | |
| 					});
 | |
| 				}
 | |
| 			} else {
 | |
| 				// Sesión inválida/expirada
 | |
| 				event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
 | |
| 			}
 | |
| 		} catch {
 | |
| 			// En caso de error de DB, no romper la request; continuar sin sesión
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	const response = await resolve(event);
 | |
| 
 | |
| 	// Cabeceras de seguridad y caché: solo para HTML
 | |
| 	try {
 | |
| 		const ct = response.headers.get('content-type') || '';
 | |
| 		if (ct.includes('text/html')) {
 | |
| 			response.headers.set('cache-control', 'no-store');
 | |
| 			response.headers.set('X-Frame-Options', 'DENY');
 | |
| 			response.headers.set('Referrer-Policy', 'no-referrer');
 | |
| 			response.headers.set('X-Content-Type-Options', 'nosniff');
 | |
| 
 | |
| 			// Mitigar aviso de “preload no usado” en CSS:
 | |
| 			// Filtrar del header Link los preloads con as=style (dejamos modulepreload para JS).
 | |
| 			const link = response.headers.get('Link') || response.headers.get('link');
 | |
| 			if (link) {
 | |
| 				const filtered = link
 | |
| 					.split(',')
 | |
| 					.map((s) => s.trim())
 | |
| 					.filter((seg) => !/;\s*as=style\b/i.test(seg));
 | |
| 				if (filtered.length > 0) {
 | |
| 					response.headers.set('Link', filtered.join(', '));
 | |
| 				} else {
 | |
| 					response.headers.delete('Link');
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	} catch {
 | |
| 		// Ignorar si la implementación de Response no permite set()
 | |
| 	}
 | |
| 	// Indicador de bypass en respuestas (útil en dev)
 | |
| 	try {
 | |
| 		if (bypass) {
 | |
| 			response.headers.set('X-Dev-Auth', 'bypass');
 | |
| 		}
 | |
| 	} catch {}
 | |
| 	return response;
 | |
| };
 |