Files
silo-server/internal/api/handlers/admin_apple_push.go
T
cf0db385f3 Add Apple push notifications support (#255)
* Add push notifications support

* fix(notifications): address push notification review findings

- Gate the capability endpoint's apple_push availability on the admin
  delivery toggle, matching web push: Available now means setup will
  actually deliver.
- Reject direct admin writes to push_relay_deployment_id/api_key; the
  relay issues them as a pair during registration and a lone write
  desyncs them (and poisons the next rotation request).
- Purge a device's registrations under other profiles when it
  re-registers, so a profile switch on a shared device stops the old
  profile's pushes (attempts cascade); adds a DB-backed test.
- Extract the shared channelDispatcher core + retry sweep and rebuild
  the webhook/web push/Apple push dispatchers on it instead of keeping
  three copies of the worker-pool/retry loop.
- Deduplicate relay URL validation (admin setting + register flow) and
  the push outbox attempt-building loops behind shared helpers.
- Cap free-text decline reasons in notification display bodies.
- Fix TestHandleApplePushDisplayDB expectations to match the shared
  display copy (test previously failed under SILO_TEST_DATABASE_URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(notifications): route push relay URL writes through registration only

Direct writes to notifications.push_relay_url via the admin settings
endpoint bypassed the relay registration flow, letting the stored URL
drift out of sync with the deployment id / API key pair the relay
minted for it. Reject the URL alongside the deployment id and API key
in the settings handler; POST /admin/notifications/push/relay/register
remains the only path that persists all three together.

The admin UI's Relay URL field now edits local draft state and is
applied by the Register/Rotate action instead of the settings save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:25:16 -04:00

280 lines
9.3 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/notifications"
)
type AdminApplePushHandler struct {
system *notifications.System
settings ServerSettingsStore
client httpDoer
}
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
func NewAdminApplePushHandler(system *notifications.System, settings ServerSettingsStore) *AdminApplePushHandler {
return &AdminApplePushHandler{
system: system,
settings: settings,
client: &http.Client{Timeout: 10 * time.Second},
}
}
type adminApplePushTestRequest struct {
ProfileID string `json:"profile_id"`
ServerDeviceID string `json:"server_device_id"`
}
type adminApplePushTestResponse struct {
AttemptID string `json:"attempt_id"`
PushDeviceID string `json:"push_device_id"`
ServerDeviceID string `json:"server_device_id"`
Outcome string `json:"outcome"`
RelayRequestID string `json:"relay_request_id,omitempty"`
UpstreamStatus *int `json:"upstream_status,omitempty"`
UpstreamReason string `json:"upstream_reason,omitempty"`
FailureMessage string `json:"failure_message,omitempty"`
}
type adminPushRelayRegisterRequest struct {
RelayURL string `json:"relay_url"`
}
type pushRelayRegisterRequest struct {
DeploymentID string `json:"deployment_id,omitempty"`
}
type pushRelayRegisterResponse struct {
RequestID string `json:"request_id"`
DeploymentID string `json:"deployment_id"`
APIKey string `json:"api_key"`
KeyPrefix string `json:"key_prefix"`
APNsTopics []string `json:"apns_topics"`
}
type pushRelayNestedError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
} `json:"error"`
}
type adminPushRelayRegisterResponse struct {
RelayURL string `json:"relay_url"`
DeploymentID string `json:"deployment_id"`
KeyPrefix string `json:"key_prefix"`
APIKeyConfigured bool `json:"api_key_configured"`
RelayRequestID string `json:"relay_request_id,omitempty"`
APNsTopics []string `json:"apns_topics,omitempty"`
}
// HandleTest handles POST /admin/notifications/push/apple/test.
func (h *AdminApplePushHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
if h == nil || h.system == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
return
}
var req adminApplePushTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
result, err := h.system.SendApplePushTest(r.Context(), req.ProfileID, req.ServerDeviceID)
if err != nil {
switch {
case errors.Is(err, notifications.ErrPushDeliveryInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, notifications.ErrPushDeliveryNotFound):
writeError(w, http.StatusNotFound, "not_found", "Apple push device not found")
case errors.Is(err, notifications.ErrPushDeliveryUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send Apple push test")
}
return
}
writeJSON(w, http.StatusOK, adminApplePushTestResponse{
AttemptID: result.AttemptID,
PushDeviceID: result.PushDeviceID,
ServerDeviceID: result.ServerDeviceID,
Outcome: result.Outcome,
RelayRequestID: result.RelayRequestID,
UpstreamStatus: result.UpstreamStatus,
UpstreamReason: result.UpstreamReason,
FailureMessage: result.FailureMessage,
})
}
// HandleRegisterRelay handles POST /admin/notifications/push/relay/register.
func (h *AdminApplePushHandler) HandleRegisterRelay(w http.ResponseWriter, r *http.Request) {
if h == nil || h.settings == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Settings store is not available")
return
}
var req adminPushRelayRegisterRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
relayURL, err := normalizePushRelayURL(req.RelayURL)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
existingDeploymentID, err := h.settings.Get(r.Context(), notifications.SettingPushRelayDeploymentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to load relay deployment id")
return
}
relayResp, err := h.registerWithRelay(r, relayURL, strings.TrimSpace(existingDeploymentID))
if err != nil {
status, code, message := mapRelayRegistrationError(err)
writeError(w, status, code, message)
return
}
if strings.TrimSpace(relayResp.DeploymentID) == "" || strings.TrimSpace(relayResp.APIKey) == "" {
writeError(w, http.StatusBadGateway, "relay_bad_response", "Push relay returned an incomplete registration response")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayURL, relayURL); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay URL")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayDeploymentID, relayResp.DeploymentID); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay deployment id")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayAPIKey, relayResp.APIKey); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay API key")
return
}
if h.system != nil && h.system.Settings != nil {
h.system.Settings.Invalidate(
notifications.SettingPushRelayURL,
notifications.SettingPushRelayDeploymentID,
notifications.SettingPushRelayAPIKey,
)
}
writeJSON(w, http.StatusOK, adminPushRelayRegisterResponse{
RelayURL: relayURL,
DeploymentID: relayResp.DeploymentID,
KeyPrefix: relayResp.KeyPrefix,
APIKeyConfigured: true,
RelayRequestID: relayResp.RequestID,
APNsTopics: relayResp.APNsTopics,
})
}
func (h *AdminApplePushHandler) registerWithRelay(r *http.Request, relayURL, deploymentID string) (*pushRelayRegisterResponse, error) {
body, err := json.Marshal(pushRelayRegisterRequest{
DeploymentID: deploymentID,
})
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, relayURL+"/v1/deployments/register", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("User-Agent", "Silo-Server/PushRelayRegistration")
resp, err := h.client.Do(httpReq)
if err != nil {
return nil, relayRegistrationError{status: http.StatusBadGateway, code: "relay_unreachable", message: "Push relay could not be reached"}
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var parsed pushRelayNestedError
_ = json.Unmarshal(data, &parsed)
code := parsed.Error.Code
if code == "" {
code = fmt.Sprintf("relay_http_%d", resp.StatusCode)
}
message := parsed.Error.Message
if message == "" {
message = http.StatusText(resp.StatusCode)
}
return nil, relayRegistrationError{status: resp.StatusCode, code: code, message: message}
}
var parsed pushRelayRegisterResponse
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, relayRegistrationError{status: http.StatusBadGateway, code: "relay_bad_response", message: "Push relay returned invalid JSON"}
}
return &parsed, nil
}
type relayRegistrationError struct {
status int
code string
message string
}
func (e relayRegistrationError) Error() string {
return e.code
}
func mapRelayRegistrationError(err error) (int, string, string) {
var relayErr relayRegistrationError
if !errors.As(err, &relayErr) {
return http.StatusInternalServerError, "internal_error", "Failed to register push relay"
}
switch relayErr.status {
case http.StatusForbidden:
return http.StatusUnprocessableEntity, "relay_deployment_rejected", "Push relay rejected this deployment"
case http.StatusTooManyRequests:
return http.StatusTooManyRequests, "relay_rate_limited", relayErr.message
case http.StatusServiceUnavailable:
return http.StatusServiceUnavailable, "relay_unavailable", relayErr.message
default:
if relayErr.status >= 500 {
return http.StatusBadGateway, "relay_error", relayErr.message
}
return http.StatusBadGateway, relayErr.code, relayErr.message
}
}
// normalizePushRelayURLValue trims a relay URL and enforces https with a
// host. Empty input stays empty so callers choose their own default.
func normalizePushRelayURLValue(raw string) (string, error) {
value := strings.TrimRight(strings.TrimSpace(raw), "/")
if value == "" {
return "", nil
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return "", errors.New("relay_url must be an https URL")
}
return value, nil
}
func normalizePushRelayURL(raw string) (string, error) {
value, err := normalizePushRelayURLValue(raw)
if err != nil {
return "", err
}
if value == "" {
return notifications.DefaultPushRelayURL, nil
}
return value, nil
}