Extend the push pipeline to Android devices through the Silo push relay's /v1/fcm/send endpoint. push_devices gains platform-conditional FCM token columns (encrypted at rest with row AAD, hashed like APNs tokens), the generic POST /notifications/push/devices endpoint the Android client already calls registers FCM tokens, and fanout, operational dispatch, retries, and terminal UNREGISTERED device disabling all reuse the existing Apple machinery. Delivery is gated by a new notifications.android_push_delivery_enabled setting, advertised through the capability endpoint's android_push block, and testable via POST /admin/notifications/push/fcm/test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
440 lines
16 KiB
Go
440 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
notificationsDefaultLimit = 25
|
|
notificationsMaxLimit = 100
|
|
notificationsSyncLimit = 50
|
|
)
|
|
|
|
// NotificationsHandler serves the profile-scoped notification inbox,
|
|
// preferences, capability, and websocket-ticket endpoints. All routes are
|
|
// mounted behind RequireProfile.
|
|
type NotificationsHandler struct {
|
|
system *notifications.System
|
|
hub *evt.Hub
|
|
}
|
|
|
|
// NewNotificationsHandler creates a NotificationsHandler.
|
|
func NewNotificationsHandler(system *notifications.System, hub *evt.Hub) *NotificationsHandler {
|
|
return &NotificationsHandler{system: system, hub: hub}
|
|
}
|
|
|
|
type notificationListResponse struct {
|
|
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
|
|
// NextCursor pages further into the past via the `before` query param.
|
|
// Empty when this page may be the last.
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
type notificationSyncResponse struct {
|
|
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
UnreadCount int `json:"unread_count"`
|
|
}
|
|
|
|
type notificationApplePushDisplayResponse = notifications.NotificationDisplay
|
|
|
|
type unreadCountResponse struct {
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type wsTicketResponse struct {
|
|
Ticket string `json:"ticket"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
func parseNotificationsLimit(r *http.Request, fallback int) int {
|
|
raw := r.URL.Query().Get("limit")
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
limit, err := strconv.Atoi(raw)
|
|
if err != nil || limit <= 0 {
|
|
return fallback
|
|
}
|
|
return min(limit, notificationsMaxLimit)
|
|
}
|
|
|
|
// HandleList handles GET /notifications (newest-first inbox page).
|
|
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
unreadOnly := r.URL.Query().Get("status") == "unread"
|
|
limit := parseNotificationsLimit(r, notificationsDefaultLimit)
|
|
|
|
var before *notifications.Cursor
|
|
if raw := r.URL.Query().Get("before"); raw != "" {
|
|
cursor, err := notifications.DecodeCursor(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid before cursor")
|
|
return
|
|
}
|
|
before = &cursor
|
|
}
|
|
|
|
rows, err := h.system.Deliveries.ListInbox(r.Context(), profileID, unreadOnly, limit, before)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
|
|
return
|
|
}
|
|
response := notificationListResponse{Notifications: h.system.PayloadsForRows(r.Context(), rows)}
|
|
if len(rows) == limit {
|
|
last := rows[len(rows)-1]
|
|
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// HandleSync handles GET /notifications/sync — the forward (ascending) cursor
|
|
// sync used by clients waking from a push or reconnecting after a gap.
|
|
func (h *NotificationsHandler) HandleSync(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
limit := parseNotificationsLimit(r, notificationsSyncLimit)
|
|
|
|
var since *notifications.Cursor
|
|
if raw := r.URL.Query().Get("since"); raw != "" {
|
|
cursor, err := notifications.DecodeCursor(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid since cursor")
|
|
return
|
|
}
|
|
since = &cursor
|
|
}
|
|
|
|
rows, err := h.system.Deliveries.ListSync(r.Context(), profileID, since, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync notifications")
|
|
return
|
|
}
|
|
unread, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
|
|
return
|
|
}
|
|
|
|
response := notificationSyncResponse{
|
|
Notifications: h.system.PayloadsForRows(r.Context(), rows),
|
|
UnreadCount: unread,
|
|
}
|
|
if len(rows) > 0 {
|
|
last := rows[len(rows)-1]
|
|
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
|
|
} else if since != nil {
|
|
response.NextCursor = since.Encode()
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// HandleGet handles GET /notifications/{id}; 404 for other profiles' rows.
|
|
func (h *NotificationsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
id := chi.URLParam(r, "id")
|
|
|
|
row, err := h.system.Deliveries.GetByID(r.Context(), profileID, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification")
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, h.system.PayloadForRow(r.Context(), *row))
|
|
}
|
|
|
|
// HandleApplePushDisplay handles GET /notifications/push/apple/display/{delivery_id}.
|
|
// It returns only compact display metadata for notification-service-extension
|
|
// enrichment, scoped to the active profile.
|
|
func (h *NotificationsHandler) HandleApplePushDisplay(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
id := chi.URLParam(r, "delivery_id")
|
|
if id == "" {
|
|
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
|
return
|
|
}
|
|
row, err := h.system.Deliveries.GetByID(r.Context(), profileID, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification")
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, notificationApplePushDisplayResponse(notifications.BuildNotificationDisplay(*row)))
|
|
}
|
|
|
|
// HandleUnreadCount handles GET /notifications/unread-count.
|
|
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
count, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, unreadCountResponse{Count: count})
|
|
}
|
|
|
|
// HandleMarkRead handles POST /notifications/{id}/read. Idempotent.
|
|
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
id := chi.URLParam(r, "id")
|
|
|
|
transitioned, err := h.system.Deliveries.MarkRead(r.Context(), profileID, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
|
|
return
|
|
}
|
|
if !transitioned {
|
|
// Already read is fine (idempotent); unknown IDs are a 404.
|
|
exists, err := h.system.Deliveries.Exists(r.Context(), profileID, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
|
|
return
|
|
}
|
|
if !exists {
|
|
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
|
return
|
|
}
|
|
}
|
|
if transitioned {
|
|
h.publishReadEvent(r, userID, profileID, id)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleReadAll handles POST /notifications/read-all.
|
|
func (h *NotificationsHandler) HandleReadAll(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
|
|
if _, err := h.system.Deliveries.MarkAllRead(r.Context(), profileID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
|
|
return
|
|
}
|
|
h.publishReadEvent(r, userID, profileID, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// publishReadEvent lets other connected tabs of the same profile reconcile
|
|
// read state. An empty id means "all read".
|
|
func (h *NotificationsHandler) publishReadEvent(r *http.Request, userID int, profileID, id string) {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
payload := map[string]any{"profile_id": profileID}
|
|
if id != "" {
|
|
payload["id"] = id
|
|
} else {
|
|
payload["all"] = true
|
|
}
|
|
_ = h.hub.PublishJSON(r.Context(), evt.ChannelNotifications, notifications.EventNotificationRead,
|
|
payload, evt.PublishOptions{UserID: userID, ProfileID: profileID})
|
|
}
|
|
|
|
// HandleGetPreferences handles GET /notifications/preferences.
|
|
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, prefs)
|
|
}
|
|
|
|
type updatePreferencesRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
NotifyFavorites *bool `json:"notify_favorites"`
|
|
NotifyWatchlist *bool `json:"notify_watchlist"`
|
|
NotifyContinueWatching *bool `json:"notify_continue_watching"`
|
|
NotifyNextUp *bool `json:"notify_next_up"`
|
|
}
|
|
|
|
// HandleUpdatePreferences handles PUT /notifications/preferences. Fields are
|
|
// optional; omitted fields keep their current value. Idempotent.
|
|
func (h *NotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
|
|
var req updatePreferencesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
|
|
return
|
|
}
|
|
if req.Enabled != nil {
|
|
prefs.Enabled = *req.Enabled
|
|
}
|
|
if req.NotifyFavorites != nil {
|
|
prefs.NotifyFavorites = *req.NotifyFavorites
|
|
}
|
|
if req.NotifyWatchlist != nil {
|
|
prefs.NotifyWatchlist = *req.NotifyWatchlist
|
|
}
|
|
if req.NotifyContinueWatching != nil {
|
|
prefs.NotifyContinueWatching = *req.NotifyContinueWatching
|
|
}
|
|
if req.NotifyNextUp != nil {
|
|
prefs.NotifyNextUp = *req.NotifyNextUp
|
|
}
|
|
if err := h.system.Preferences.Upsert(r.Context(), prefs); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save notification preferences")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, prefs)
|
|
}
|
|
|
|
type capabilityResponse struct {
|
|
InApp capabilityInApp `json:"in_app"`
|
|
ApplePush capabilityPush `json:"apple_push"`
|
|
AndroidPush capabilityPush `json:"android_push"`
|
|
WebPush capabilityWebPush `json:"web_push"`
|
|
Webhooks capabilityWebhooks `json:"webhooks"`
|
|
Email capabilityAccountChannel `json:"email"`
|
|
Discord capabilityAccountChannel `json:"discord"`
|
|
}
|
|
|
|
// capabilityAccountChannel describes an account-level digest channel (email,
|
|
// Discord DMs).
|
|
type capabilityAccountChannel struct {
|
|
Available bool `json:"available"`
|
|
// Modes lists the cadences users may pick (per-episode is an admin
|
|
// allowance); DigestHour tells the UI when daily digests go out.
|
|
Modes []string `json:"modes"`
|
|
DigestHour int `json:"digest_hour"`
|
|
}
|
|
|
|
type capabilityWebPush struct {
|
|
Available bool `json:"available"`
|
|
PublicKey string `json:"public_key,omitempty"`
|
|
}
|
|
|
|
type capabilityInApp struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type capabilityPush struct {
|
|
Available bool `json:"available"`
|
|
Provider string `json:"provider"`
|
|
SupportedModes []string `json:"supported_modes"`
|
|
}
|
|
|
|
type capabilityWebhooks struct {
|
|
Available bool `json:"available"`
|
|
MaxPerProfile int `json:"max_per_profile"`
|
|
SupportedTypes []string `json:"supported_types"`
|
|
}
|
|
|
|
// HandleCapability handles GET /notifications/capability. Clients render
|
|
// setup UI from this response instead of introspecting admin settings.
|
|
func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
|
|
webhooks := capabilityWebhooks{Available: false, MaxPerProfile: 0, SupportedTypes: []string{}}
|
|
if h.system.Webhooks != nil && h.system.Settings.WebhooksEnabled(r.Context()) {
|
|
webhooks = capabilityWebhooks{
|
|
Available: true,
|
|
MaxPerProfile: h.system.Settings.WebhooksMaxPerProfile(r.Context()),
|
|
SupportedTypes: []string{"discord", "generic"},
|
|
}
|
|
}
|
|
webPush := capabilityWebPush{}
|
|
if h.system.WebPush != nil && h.system.Settings.WebPushEnabled(r.Context()) {
|
|
if publicKey, err := h.system.WebPush.PublicKey(r.Context()); err == nil && publicKey != "" {
|
|
webPush = capabilityWebPush{Available: true, PublicKey: publicKey}
|
|
}
|
|
}
|
|
email := capabilityAccountChannel{Modes: []string{}}
|
|
if h.system.EmailAvailable(r.Context()) {
|
|
modes := []string{notifications.ChannelModeDailyDigest}
|
|
if h.system.Settings.EmailAllowPerEpisode(r.Context()) {
|
|
modes = append(modes,
|
|
notifications.ChannelModePerEpisode,
|
|
notifications.ChannelModePerEpisodeAndDigest)
|
|
}
|
|
email = capabilityAccountChannel{
|
|
Available: true,
|
|
Modes: modes,
|
|
DigestHour: h.system.Settings.EmailDigestHour(r.Context()),
|
|
}
|
|
}
|
|
discordCap := capabilityAccountChannel{Modes: []string{}}
|
|
if h.system.DiscordAvailable(r.Context()) {
|
|
modes := []string{notifications.ChannelModeDailyDigest}
|
|
if h.system.Settings.DiscordAllowPerEpisode(r.Context()) {
|
|
modes = append(modes,
|
|
notifications.ChannelModePerEpisode,
|
|
notifications.ChannelModePerEpisodeAndDigest)
|
|
}
|
|
discordCap = capabilityAccountChannel{
|
|
Available: true,
|
|
Modes: modes,
|
|
DigestHour: h.system.Settings.DiscordDigestHour(r.Context()),
|
|
}
|
|
}
|
|
applePush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
|
|
androidPush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
|
|
// Like web push above, availability requires both the wiring (cipher/store)
|
|
// and the admin delivery toggle: Available must mean "setup will actually
|
|
// deliver", not "the server could store a token".
|
|
if h.system.PushDevices != nil && h.system.PushDevices.Available() {
|
|
if h.system.Settings.ApplePushDeliveryEnabled(r.Context()) {
|
|
applePush = capabilityPush{
|
|
Available: true,
|
|
Provider: notifications.PushProviderSiloRelay,
|
|
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
|
|
}
|
|
}
|
|
if h.system.Settings.AndroidPushDeliveryEnabled(r.Context()) {
|
|
androidPush = capabilityPush{
|
|
Available: true,
|
|
Provider: notifications.PushProviderSiloRelay,
|
|
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, capabilityResponse{
|
|
InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())},
|
|
ApplePush: applePush,
|
|
AndroidPush: androidPush,
|
|
WebPush: webPush,
|
|
Webhooks: webhooks,
|
|
Email: email,
|
|
Discord: discordCap,
|
|
})
|
|
}
|
|
|
|
// HandleMintWSTicket handles POST /events/ws-ticket: mints a short-lived
|
|
// single-use websocket handshake ticket bound to (user, profile). Long-lived
|
|
// tokens must never ride the websocket query string — reverse-proxy access
|
|
// logs capture it.
|
|
func (h *NotificationsHandler) HandleMintWSTicket(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
|
|
ticket, ttl, err := h.system.Tickets.Mint(r.Context(), userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mint websocket ticket")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, wsTicketResponse{
|
|
Ticket: ticket,
|
|
ExpiresIn: int(ttl.Seconds()),
|
|
})
|
|
}
|