feat: añadir ICS con límite por token y títulos Personal/Grupo/Agregado

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 1 month ago
parent 69487c7e0a
commit 234053c609

@ -1,4 +1,5 @@
import { sha256Hex } from './crypto'; import { sha256Hex } from './crypto';
import { icsRateLimitPerMin } from './env';
function escapeIcsText(s: string): string { function escapeIcsText(s: string): string {
return String(s) return String(s)
@ -55,11 +56,33 @@ export type IcsEvent = {
prefix?: string; // ej: "T" para [T0123] prefix?: string; // ej: "T" para [T0123]
}; };
// Rate limiting por token (ventana 1 minuto)
const __icsRateMap = new Map<string, { start: number; count: number }>();
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 }> { export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> {
const lines: string[] = []; const lines: string[] = [];
lines.push('BEGIN:VCALENDAR'); lines.push('BEGIN:VCALENDAR');
lines.push('VERSION:2.0'); 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('CALSCALE:GREGORIAN');
lines.push('METHOD:PUBLISH'); lines.push('METHOD:PUBLISH');
lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`); lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`);

@ -2,7 +2,7 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; 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'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => { 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 (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 }); 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 today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -52,12 +57,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' 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 // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { 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' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }

@ -2,7 +2,7 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; 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'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => { 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 = ?`) .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; .get(row.group_id) as any;
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) { 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 }); 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 today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -59,13 +63,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' 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 // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { 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' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }

@ -2,7 +2,7 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; 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'; import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
export const GET: RequestHandler = async ({ params, request }) => { 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 (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 }); 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 today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -53,12 +58,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' prefix: 'T'
})); }));
const { body, etag } = await buildIcsCalendar('Mis tareas', events); const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Personal', events);
// 304 si ETag coincide // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { 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' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }

Loading…
Cancel
Save