// SPDX-License-Identifier: AGPL-3.0-or-later // // Dispatcher fan-outs an event payload to every registered device, encrypting // the payload to each device's X25519 public key (NaCl sealed box) and sending // the ciphertext via Firebase Cloud Messaging. package notify import ( "context" "log/slog" "github.com/cyrilinait/birdwatch-relay/internal/crypto" "github.com/cyrilinait/birdwatch-relay/internal/fcm" "github.com/cyrilinait/birdwatch-relay/internal/storage" ) type Dispatcher struct { store *storage.Store sender *fcm.Sender } func NewDispatcher(store *storage.Store, sender *fcm.Sender) *Dispatcher { return &Dispatcher{store: store, sender: sender} } // Send fans the payload out to every registered device. The payload is sealed // to each device's public key independently — Google FCM only ever sees // ciphertext + the device token. Errors per device are logged but do not stop // the rest of the fanout. func (d *Dispatcher) Send(ctx context.Context, payload []byte) { devices, err := d.store.ListAll() if err != nil { slog.Error("dispatcher: list devices failed", "err", err) return } if len(devices) == 0 { slog.Debug("dispatcher: no registered devices, dropping event") return } var sent, failed int for _, dev := range devices { ct, err := crypto.Seal(payload, dev.PublicKey) if err != nil { slog.Error("dispatcher: seal failed", "err", err, "device_id", dev.ID) failed++ continue } if err := d.sender.SendCiphertext(ctx, dev.FCMToken, ct); err != nil { slog.Warn("dispatcher: fcm send failed", "err", err, "device_id", dev.ID) failed++ continue } sent++ } slog.Info("dispatcher fanout", "sent", sent, "failed", failed, "total", len(devices)) }