diff --git a/apps/web/src/lib/server/ics.ts b/apps/web/src/lib/server/ics.ts index dcff319..c5c3df0 100644 --- a/apps/web/src/lib/server/ics.ts +++ b/apps/web/src/lib/server/ics.ts @@ -1,4 +1,5 @@ import { sha256Hex } from './crypto'; +import { icsRateLimitPerMin } from './env'; function escapeIcsText(s: string): string { return String(s) @@ -55,11 +56,33 @@ export type IcsEvent = { prefix?: string; // ej: "T" para [T0123] }; +// Rate limiting por token (ventana 1 minuto) +const __icsRateMap = new Map(); + +export function checkIcsRateLimit(tokenKey: string): { ok: boolean; retryAfterSec?: number } { + const limit = Number(icsRateLimitPerMin || 0); + if (!Number.isFinite(limit) || limit <= 0) return { ok: true }; + const now = Date.now(); + const windowMs = 60 * 1000; + const winStart = Math.floor(now / windowMs) * windowMs; + const rec = __icsRateMap.get(tokenKey); + if (!rec || rec.start !== winStart) { + __icsRateMap.set(tokenKey, { start: winStart, count: 1 }); + return { ok: true }; + } + if (rec.count < limit) { + rec.count += 1; + return { ok: true }; + } + const retryAfterSec = Math.max(1, Math.ceil((rec.start + windowMs - now) / 1000)); + return { ok: false, retryAfterSec }; +} + export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> { const lines: string[] = []; lines.push('BEGIN:VCALENDAR'); lines.push('VERSION:2.0'); - lines.push('PRODID:-//TaskWhatsApp//Calendar//ES'); + lines.push('PRODID:-/Wtask.org//ICS 1.0//ES'); lines.push('CALSCALE:GREGORIAN'); lines.push('METHOD:PUBLISH'); lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`); diff --git a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts index a5d45e3..dea5c66 100644 --- a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; import { icsHorizonMonths } from '$lib/server/env'; -import { buildIcsCalendar } from '$lib/server/ics'; +import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; export const GET: RequestHandler = async ({ params, request }) => { @@ -24,6 +24,11 @@ export const GET: RequestHandler = async ({ params, request }) => { if (row.revoked_at) return new Response('Gone', { status: 410 }); if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 }); + const rl = checkIcsRateLimit(tokenHash); + if (!rl.ok) { + return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); + } + const today = new Date(); const startYmd = ymdUTC(today); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); @@ -52,12 +57,11 @@ export const GET: RequestHandler = async ({ params, request }) => { prefix: 'T' })); - const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events); + const { body, etag } = await buildIcsCalendar('Wtask.org Tareas – Agregado', events); // 304 si ETag coincide const inm = request.headers.get('if-none-match'); if (inm && inm === etag) { - db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); } diff --git a/apps/web/src/routes/ics/group/[token].ics/+server.ts b/apps/web/src/routes/ics/group/[token].ics/+server.ts index 185b946..fdc7917 100644 --- a/apps/web/src/routes/ics/group/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/group/[token].ics/+server.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; import { icsHorizonMonths } from '$lib/server/env'; -import { buildIcsCalendar } from '$lib/server/ics'; +import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; export const GET: RequestHandler = async ({ params, request }) => { @@ -29,10 +29,14 @@ export const GET: RequestHandler = async ({ params, request }) => { .prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`) .get(row.group_id) as any; if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) { - db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id); return new Response('Gone', { status: 410 }); } + const rl = checkIcsRateLimit(tokenHash); + if (!rl.ok) { + return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); + } + const today = new Date(); const startYmd = ymdUTC(today); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); @@ -59,13 +63,11 @@ export const GET: RequestHandler = async ({ params, request }) => { prefix: 'T' })); - const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events); + const { body, etag } = await buildIcsCalendar('Wtask.org Tareas – Grupo', events); // 304 si ETag coincide const inm = request.headers.get('if-none-match'); if (inm && inm === etag) { - // Actualizar last_used_at aunque sea 304 - db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); } diff --git a/apps/web/src/routes/ics/personal/[token].ics/+server.ts b/apps/web/src/routes/ics/personal/[token].ics/+server.ts index 3507d41..28c2986 100644 --- a/apps/web/src/routes/ics/personal/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/personal/[token].ics/+server.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; import { icsHorizonMonths } from '$lib/server/env'; -import { buildIcsCalendar } from '$lib/server/ics'; +import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; export const GET: RequestHandler = async ({ params, request }) => { @@ -24,6 +24,11 @@ export const GET: RequestHandler = async ({ params, request }) => { if (row.revoked_at) return new Response('Gone', { status: 410 }); if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 }); + const rl = checkIcsRateLimit(tokenHash); + if (!rl.ok) { + return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); + } + const today = new Date(); const startYmd = ymdUTC(today); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); @@ -53,12 +58,11 @@ export const GET: RequestHandler = async ({ params, request }) => { prefix: 'T' })); - const { body, etag } = await buildIcsCalendar('Mis tareas', events); + const { body, etag } = await buildIcsCalendar('Wtask.org Tareas – Personal', events); // 304 si ETag coincide const inm = request.headers.get('if-none-match'); if (inm && inm === etag) { - db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); }