diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts
index 78a3bc4..785108f 100644
--- a/apps/web/src/routes/login/+server.ts
+++ b/apps/web/src/routes/login/+server.ts
@@ -8,6 +8,17 @@ 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 para evitar que los “link preview bots” canjeen el token.
+// Muestra un formulario que hace POST automático para canjear el token.
export const GET: RequestHandler = async (event) => {
const token = event.url.searchParams.get('token')?.trim();
if (!token) {
@@ -15,12 +26,52 @@ export const GET: RequestHandler = async (event) => {
return new Response('Falta el token', { status: 400 });
}
- // Hash del token (no loguear el valor en claro)
- const tokenHash = await sha256Hex(token);
+ const html = `
+
+
+
+ Accediendo…
+
+
+
+
+
+
+
+
+
+`;
+
+ 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 });
+ }
+ const tokenHash = await sha256Hex(token);
const db = await getDb();
- // Intentar canjear el token: marcarlo como usado si está vigente y no usado (usar tiempo de SQLite)
+ // Intentar canjear el token (un solo uso, no caducado)
const res = db
.prepare(
`UPDATE web_tokens
diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md
index fee7b98..1fdae28 100644
--- a/docs/plan-interfaz-web.md
+++ b/docs/plan-interfaz-web.md
@@ -45,9 +45,10 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie
- En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario.
- Devolver URL del tipo: https://app.example.com/login?token=XYZ
- Canje (web):
- - Validar hash y caducidad; si ok, invalidar token (marcar usado).
- - Crear sesión en DB (web_sessions) y emitir cookie de sesión (solo cookie de sesión, sin persistencia en disco).
- - Redirigir a /app (sin token en la URL).
+ - GET /login muestra una página intermedia con formulario (y auto-submit JS) para evitar que los “link preview bots” canjeen el token.
+ - POST /login valida hash y caducidad; si ok, invalida el token (marcar usado).
+ - Crea sesión en DB (web_sessions) y emite cookie de sesión (solo cookie de sesión, sin persistencia en disco).
+ - Redirige a /app (sin token en la URL).
- Expiración:
- Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token /t web.
- Seguridad: