Miru ti avvisa quando esce un nuovo episodio di un anime che stai guardando. Sembra una funzione piccola, ma mette insieme tre cose che vanno d’accordo a fatica: un’API esterna, un lavoro pianificato e l’invio di notifiche tramite sistemi come APNs e FCM. Qui racconto come l’ho costruita con Supabase e cosa ho dovuto correggere lungo la strada.
L’architettura
Non c’è un server mio da tenere acceso:
pg_cron, dentro il database Postgres di Supabase, lancia un lavoro ogni due ore (0 */2 * * *).- Il lavoro chiama, con
pg_net, una Edge Function (Deno) chiamatanotify-episodes. - La funzione legge chi ha le notifiche attive, chiede ad AniList quando esce il prossimo episodio delle serie in corso e invia le push con un modulo condiviso per APNs (iOS) e FCM (Android).
Cosa fa la funzione
In ordine:
- Token e preferenze. Legge i token push, esclude chi ha disattivato le notifiche degli episodi e ricorda la lingua di ciascuno, per scrivere il testo in italiano o in inglese.
- Serie in corso. Prende le voci in stato «watching» degli utenti rimasti.
- AniList a blocchi. Chiede il prossimo episodio con una query GraphQL, a gruppi da 50 identificativi; se un blocco fallisce gli altri vanno avanti.
- Finestra di 24 ore. Notifica solo se l’episodio esce entro le prossime 24 ore.
- Niente doppioni. Ogni voce ha una colonna
notified_epcon l’ultimo episodio già notificato: si invia solo se l’episodio è più nuovo. - Aggiorna dopo il successo.
notified_epsi scrive solo quando l’invio restituisceok; se il token risulta non valido lo cancello.
Il cuore della deduplicazione sono tre righe:
if (!airing) continue // nessun episodio in programma
if (airing.airingAt > horizon) continue // troppo lontano (oltre 24 ore)
if (airing.episode <= (entry.notified_ep ?? 0)) continue // già notificato
Il testo cambia in base al tempo che manca: «è appena uscito», «esce tra pochi minuti», «esce tra N ore», nella lingua dell’utente.
Primo problema: la funzione era invocabile da chiunque
Il cron chiamava la funzione con la chiave anon di Supabase, che per costruzione è pubblica. Quindi chiunque la conoscesse poteva invocare notify-episodes quando voleva.
Ho aggiunto un segreto condiviso: la funzione risponde 401 alle richieste che non portano il segreto in un header dedicato, e il cron è stato ri-schedulato per inviarlo.
Il valore sta tra i secret della funzione, non nel codice: nella migration che ri-schedula il cron, nel repository, c’è solo un segnaposto, e il valore vero lo inserisco al momento dell’esecuzione, così non finisce in git.
Secondo problema: fidarsi di ciò che manda il client
notify-friend manda la notifica «qualcuno vuole aggiungerti». Riceveva dal client un receiver_id e inviava la push a quella persona, senza controllare nient’altro: un client poteva far arrivare notifiche a qualsiasi utente di cui conoscesse l’identificativo.
La correzione: prima di inviare, la funzione verifica che esista davvero una richiesta di amicizia pending tra mittente e destinatario, altrimenti risponde 403.
Lo stesso controllo vale per le raccomandazioni. È il classico problema IDOR: il server non deve fidarsi di un identificativo solo perché arriva dal client.
Un dettaglio che costa caro: i timeout
Le chiamate verso APNs e FCM hanno un timeout di 10 secondi con AbortController. Senza, una chiamata rimasta appesa blocca l’intera funzione mentre ci sono ancora notifiche da inviare.
Cosa ho imparato
- Una chiave pubblica identifica l’app, non autorizza nessuno: se una funzione fa qualcosa di sensibile serve un segreto vero.
- La deduplicazione va scritta dopo l’invio riuscito, non prima.
- Ogni identificativo che arriva dal client va confrontato con lo stato che il server conosce.
