import type { RequestHandler } from './$types'; import { redirect } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; import { sessionIdleTtlMs, isProd } from '$lib/server/env'; function toIsoSql(d: Date): string { return d.toISOString().replace('T', ' ').replace('Z', ''); } function escapeHtml(s: string): string { return s .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } // GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS. // Evita que bots de previsualización canjeen el token antes de que el usuario haga clic. export const GET: RequestHandler = async (event) => { const token = event.url.searchParams.get('token')?.trim(); if (!token) { console.warn('[web/login] Solicitud sin token'); return new Response('Falta el token', { status: 400 }); } // Nonce para "gate de JS" const nonce = randomTokenBase64Url(18); const html = ` Acceder

Acceso seguro

Para continuar, pulsa “Continuar”. Si no funciona, asegúrate de abrir este enlace en tu navegador.

`; return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store, max-age=0' } }); }; // POST: canje real del token (uso único). Crea sesión y redirige a /app. export const POST: RequestHandler = async (event) => { const form = await event.request.formData(); const token = String(form.get('token') || '').trim(); if (!token) { console.warn('[web/login] POST sin token'); return new Response('Falta el token', { status: 400 }); } // Validación del "gate de JS": cookie + nonce deben coincidir const nonce = String(form.get('nonce') || '').trim(); const loginIntent = event.cookies.get('login_intent') || ''; if (!nonce || !loginIntent || nonce !== loginIntent) { console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.'); return new Response('Solicitud inválida', { status: 400 }); } const tokenHash = await sha256Hex(token); const db = await getDb(); // Intentar canjear el token (un solo uso, no caducado) const res = db .prepare( `UPDATE web_tokens SET used_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE token_hash = ? AND used_at IS NULL AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')` ) .run(tokenHash); const changes = Number(res?.changes || 0); if (changes < 1) { console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.'); return new Response('Enlace inválido o caducado', { status: 400 }); } // Recuperar el user_id asociado const row = db .prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`) .get(tokenHash) as { user_id: string } | null; const userId = row?.user_id?.trim(); if (!userId) { return new Response('Token canjeado pero usuario no encontrado', { status: 500 }); } // Crear sesión const sessionToken = randomTokenBase64Url(32); const sessionHash = await sha256Hex(sessionToken); const sessionId = randomTokenBase64Url(16); const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs)); // Datos de agente e IP (best-effort) const userAgent = event.request.headers.get('user-agent') || null; let ip: string | null = null; try { // SvelteKit 2: getClientAddress en adapters compatibles // @ts-ignore if (typeof event.getClientAddress === 'function') { // @ts-ignore ip = event.getClientAddress() || null; } } catch {} if (!ip) { const fwd = event.request.headers.get('x-forwarded-for'); ip = fwd ? fwd.split(',')[0].trim() : null; } db.prepare( `INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)` ).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip); // Cookie de sesión event.cookies.set('sid', sessionToken, { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd(), maxAge: Math.floor(sessionIdleTtlMs / 1000) }); // Eliminar cookie de intento (ya no es necesaria) event.cookies.delete('login_intent', { path: '/' }); // Redirigir a /app throw redirect(303, '/app'); };