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:

  1. pg_cron, dentro il database Postgres di Supabase, lancia un lavoro ogni due ore (0 */2 * * *).
  2. Il lavoro chiama, con pg_net, una Edge Function (Deno) chiamata notify-episodes.
  3. 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_ep con l’ultimo episodio già notificato: si invia solo se l’episodio è più nuovo.
  • Aggiorna dopo il successo. notified_ep si scrive solo quando l’invio restituisce ok; 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.