From 73ae69892f81646b46f66c5de1db31af5716be30 Mon Sep 17 00:00:00 2001 From: borja Date: Tue, 14 Oct 2025 09:43:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20migraci=C3=B3n=20calendar?= =?UTF-8?q?-tokens=20y=20servicio=20ICS=20de=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/server/calendar-tokens.ts | 109 +++++++++++++++++++++ src/db/migrations/index.ts | 30 ++++++ 2 files changed, 139 insertions(+) create mode 100644 apps/web/src/lib/server/calendar-tokens.ts diff --git a/apps/web/src/lib/server/calendar-tokens.ts b/apps/web/src/lib/server/calendar-tokens.ts new file mode 100644 index 0000000..8eb487a --- /dev/null +++ b/apps/web/src/lib/server/calendar-tokens.ts @@ -0,0 +1,109 @@ +import { getDb } from './db'; +import { randomTokenBase64Url, sha256Hex } from './crypto'; +import { WEB_BASE_URL } from './env'; + +export type CalendarTokenType = 'personal' | 'group' | 'aggregate'; + +function toIsoSql(d: Date = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function requireBaseUrl(): string { + const base = (WEB_BASE_URL || '').trim(); + if (!base) { + throw new Error('[calendar-tokens] WEB_BASE_URL no está configurado'); + } + return base.replace(/\/+$/, ''); +} + +export function buildCalendarIcsUrl(type: CalendarTokenType, token: string): string { + const base = requireBaseUrl(); + const segment = type === 'personal' ? 'personal' : type === 'group' ? 'group' : 'aggregate'; + return `${base}/ics/${segment}/${token}.ics`; +} + +export async function findActiveToken( + type: CalendarTokenType, + userId: string, + groupId?: string | null +): Promise<{ + id: number; + type: CalendarTokenType; + user_id: string; + group_id: string | null; + token_hash: string; + created_at: string; + revoked_at: string | null; + last_used_at: string | null; +} | null> { + const db = await getDb(); + const sql = groupId + ? ` + SELECT id, type, user_id, group_id, token_hash, created_at, revoked_at, last_used_at + FROM calendar_tokens + WHERE type = ? AND user_id = ? AND group_id = ? AND revoked_at IS NULL + ORDER BY id DESC + LIMIT 1 + ` + : ` + SELECT id, type, user_id, group_id, token_hash, created_at, revoked_at, last_used_at + FROM calendar_tokens + WHERE type = ? AND user_id = ? AND group_id IS NULL AND revoked_at IS NULL + ORDER BY id DESC + LIMIT 1 + `; + const row = groupId + ? (await db.query(sql).get(type, userId, groupId)) + : (await db.query(sql).get(type, userId)); + return (row as any) || null; +} + +/** + * Crea un nuevo token ICS y devuelve la URL completa (no se guarda el token en claro). + * Lanza si existe una entrada activa y se viola la unicidad; usar findActiveToken antes si quieres evitar error. + */ +export async function createCalendarTokenUrl( + type: CalendarTokenType, + userId: string, + groupId?: string | null +): Promise<{ url: string; token: string; id: number }> { + const db = await getDb(); + + const token = randomTokenBase64Url(32); + const tokenHash = await sha256Hex(token); + const createdAt = toIsoSql(new Date()); + + const insert = db.prepare(` + INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `); + const res = insert.run(type, userId, groupId ?? null, tokenHash, createdAt); + const id = Number(res.lastInsertRowid || 0); + + return { url: buildCalendarIcsUrl(type, token), token, id }; +} + +/** + * Revoca el token activo (si existe) y crea uno nuevo. Devuelve la nueva URL completa. + */ +export async function rotateCalendarTokenUrl( + type: CalendarTokenType, + userId: string, + groupId?: string | null +): Promise<{ url: string; token: string; id: number; revoked: number | null }> { + const db = await getDb(); + const now = toIsoSql(new Date()); + + const existing = await findActiveToken(type, userId, groupId ?? null); + let revoked: number | null = null; + if (existing) { + db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).run( + now, + existing.id + ); + revoked = existing.id; + } + + const created = await createCalendarTokenUrl(type, userId, groupId ?? null); + return { ...created, revoked }; +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9813e49..9a60c07 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -329,5 +329,35 @@ export const migrations: Migration[] = [ db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_user ON web_sessions (user_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);`); } + }, + { + version: 11, + name: 'calendar-tokens', + checksum: 'v11-calendar-tokens-2025-10-14', + up: (db: Database) => { + db.exec(`PRAGMA foreign_keys = ON;`); + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK (type IN ('personal','group','aggregate')), + user_id TEXT NOT NULL, + group_id TEXT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + revoked_at TEXT NULL, + last_used_at TEXT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_calendar_tokens_active + ON calendar_tokens (type, user_id, group_id) + WHERE revoked_at IS NULL; + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user ON calendar_tokens (user_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_group ON calendar_tokens (group_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_type ON calendar_tokens (type);`); + } } ];