feat: añadir migración calendar-tokens y servicio ICS de tokens

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent c3737b967b
commit 73ae69892f

@ -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 };
}

@ -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);`);
}
}
];

Loading…
Cancel
Save