Files
silo-server/internal/notifications/push_devices.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

394 lines
11 KiB
Go

package notifications
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/oklog/ulid/v2"
)
const (
PushPlatformApple = "apple"
PushProviderSiloRelay = "silo_relay"
PushModeOff = "off"
PushModeInAppOnly = "in_app_only"
PushModePrivatePush = "private_push"
APNsEnvironmentProd = "production"
APNsEnvironmentSandbox = "sandbox"
ApplePushTopicSilo = "org.siloserver.silo"
)
var (
ErrPushDeviceUnavailable = errors.New("push device registration unavailable")
ErrPushDeviceInvalid = errors.New("invalid push device registration")
ErrPushDeviceUnsupported = errors.New("unsupported push device registration")
apnsTokenHexPattern = regexp.MustCompile(`^[0-9a-f]+$`)
)
// PushDevice represents one profile-scoped notification endpoint.
type PushDevice struct {
ID string
UserID int
ProfileID string
DeviceID string
Platform string
Provider string
APNsEnvironment string
APNsTopic string
APNsTokenCiphertext string
APNsTokenHash string
ServerDeviceID string
PushMode string
Enabled bool
LastSeenAt *time.Time
LastSuccessAt *time.Time
LastFailureAt *time.Time
LastFailureCode *string
CreatedAt time.Time
UpdatedAt time.Time
}
type ApplePushRegistrationInput struct {
DeviceID string
APNsToken string
APNsEnvironment string
APNsTopic string
PushMode string
}
type ApplePushDeviceRegistration struct {
UserID int
ProfileID string
DeviceID string
APNsToken string
APNsEnvironment string
APNsTopic string
PushMode string
}
type PushDeviceStore interface {
UpsertApple(ctx context.Context, registration ApplePushDeviceRegistration, cipher *secret.Cipher) (*PushDevice, error)
}
type PushDeviceRepository struct {
pool *pgxpool.Pool
}
func NewPushDeviceRepository(pool *pgxpool.Pool) *PushDeviceRepository {
return &PushDeviceRepository{pool: pool}
}
type PushDeviceService struct {
store PushDeviceStore
cipher *secret.Cipher
}
func NewPushDeviceService(store PushDeviceStore, cipher *secret.Cipher) *PushDeviceService {
return &PushDeviceService{store: store, cipher: cipher}
}
func (s *PushDeviceService) Available() bool {
return s != nil && s.store != nil && s.cipher != nil
}
func (s *PushDeviceService) RegisterApple(ctx context.Context, userID int, profileID string, input ApplePushRegistrationInput) (*PushDevice, error) {
if !s.Available() {
return nil, ErrPushDeviceUnavailable
}
if userID <= 0 {
return nil, fmt.Errorf("%w: user_id is required", ErrPushDeviceInvalid)
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return nil, fmt.Errorf("%w: profile_id is required", ErrPushDeviceInvalid)
}
registration, err := normalizeApplePushRegistration(input)
if err != nil {
return nil, err
}
registration.UserID = userID
registration.ProfileID = profileID
return s.store.UpsertApple(ctx, registration, s.cipher)
}
func normalizeApplePushRegistration(input ApplePushRegistrationInput) (ApplePushDeviceRegistration, error) {
deviceID := strings.TrimSpace(input.DeviceID)
if deviceID == "" {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: device_id is required", ErrPushDeviceInvalid)
}
if len(deviceID) > 128 {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: device_id is too long", ErrPushDeviceInvalid)
}
token := strings.ToLower(strings.TrimSpace(input.APNsToken))
if token == "" {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: apns_token is required", ErrPushDeviceInvalid)
}
if len(token) < 64 || len(token) > 256 || !apnsTokenHexPattern.MatchString(token) {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: apns_token must be hex encoded", ErrPushDeviceInvalid)
}
environment := strings.ToLower(strings.TrimSpace(input.APNsEnvironment))
if environment != APNsEnvironmentProd && environment != APNsEnvironmentSandbox {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: apns_environment must be production or sandbox", ErrPushDeviceInvalid)
}
topic := strings.TrimSpace(input.APNsTopic)
if topic == "" {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: apns_topic is required", ErrPushDeviceInvalid)
}
if topic != ApplePushTopicSilo {
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: apns_topic is not supported", ErrPushDeviceUnsupported)
}
pushMode := strings.TrimSpace(input.PushMode)
if pushMode == "" {
pushMode = PushModePrivatePush
}
switch pushMode {
case PushModeOff, PushModeInAppOnly, PushModePrivatePush:
default:
return ApplePushDeviceRegistration{}, fmt.Errorf("%w: push_mode is not supported", ErrPushDeviceUnsupported)
}
return ApplePushDeviceRegistration{
DeviceID: deviceID,
APNsToken: token,
APNsEnvironment: environment,
APNsTopic: topic,
PushMode: pushMode,
}, nil
}
func apnsTokenHash(token string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(token))))
return hex.EncodeToString(sum[:])
}
func pushDeviceAPNsTokenAAD(id string) string {
return secret.RowAAD("push_devices", "apns_token", id)
}
const pushDeviceColumns = `
id,
user_id,
profile_id,
device_id,
platform,
provider,
apns_environment,
apns_topic,
apns_token_ciphertext,
apns_token_hash,
server_device_id,
push_mode,
enabled,
last_seen_at,
last_success_at,
last_failure_at,
last_failure_code,
created_at,
updated_at`
func (r *PushDeviceRepository) UpsertApple(ctx context.Context, registration ApplePushDeviceRegistration, cipher *secret.Cipher) (*PushDevice, error) {
if r == nil || r.pool == nil || cipher == nil {
return nil, ErrPushDeviceUnavailable
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin push device upsert: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// A device install registers for the profile it is currently signed into.
// Purge the same install's registrations under other profiles (attempts
// cascade with them) so a profile switch on a shared device doesn't leave
// the previous profile's notifications flowing to it.
if _, err := tx.Exec(ctx,
`DELETE FROM push_devices WHERE device_id = $1 AND platform = $2 AND profile_id <> $3`,
registration.DeviceID, PushPlatformApple, registration.ProfileID); err != nil {
return nil, fmt.Errorf("purge reassigned push device: %w", err)
}
device, err := r.selectAppleForUpdate(ctx, tx, registration.ProfileID, registration.DeviceID)
if err != nil {
return nil, err
}
if device == nil {
device, err = r.insertApple(ctx, tx, registration, cipher)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if device != nil {
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit push device insert: %w", err)
}
return device, nil
}
device, err = r.selectAppleForUpdate(ctx, tx, registration.ProfileID, registration.DeviceID)
if err != nil {
return nil, err
}
if device == nil {
return nil, fmt.Errorf("push device upsert conflict row missing")
}
}
device, err = r.updateApple(ctx, tx, registration, cipher, device)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit push device update: %w", err)
}
return device, nil
}
// DeleteAllForProfile removes push registrations for a deleted profile.
func (r *PushDeviceRepository) DeleteAllForProfile(ctx context.Context, profileID string) error {
if r == nil || r.pool == nil {
return nil
}
_, err := r.pool.Exec(ctx, `DELETE FROM push_devices WHERE profile_id = $1`, profileID)
return err
}
func (r *PushDeviceRepository) selectAppleForUpdate(ctx context.Context, tx pgx.Tx, profileID, deviceID string) (*PushDevice, error) {
row := tx.QueryRow(ctx, `SELECT `+pushDeviceColumns+` FROM push_devices WHERE profile_id = $1 AND device_id = $2 AND platform = $3 FOR UPDATE`, profileID, deviceID, PushPlatformApple)
device, err := scanPushDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("select push device: %w", err)
}
return device, nil
}
func (r *PushDeviceRepository) insertApple(ctx context.Context, tx pgx.Tx, registration ApplePushDeviceRegistration, cipher *secret.Cipher) (*PushDevice, error) {
id := ulid.Make().String()
serverDeviceID := ulid.Make().String()
ciphertext, err := cipher.Encrypt(registration.APNsToken, pushDeviceAPNsTokenAAD(id))
if err != nil {
return nil, fmt.Errorf("encrypt apns token: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO push_devices (
id,
user_id,
profile_id,
device_id,
platform,
provider,
apns_environment,
apns_topic,
apns_token_ciphertext,
apns_token_hash,
server_device_id,
push_mode,
enabled,
last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, now())
ON CONFLICT (profile_id, device_id, platform) DO NOTHING
RETURNING `+pushDeviceColumns,
id,
registration.UserID,
registration.ProfileID,
registration.DeviceID,
PushPlatformApple,
PushProviderSiloRelay,
registration.APNsEnvironment,
registration.APNsTopic,
ciphertext,
apnsTokenHash(registration.APNsToken),
serverDeviceID,
registration.PushMode,
)
device, err := scanPushDevice(row)
if err != nil {
return nil, err
}
return device, nil
}
func (r *PushDeviceRepository) updateApple(ctx context.Context, tx pgx.Tx, registration ApplePushDeviceRegistration, cipher *secret.Cipher, existing *PushDevice) (*PushDevice, error) {
ciphertext, err := cipher.Encrypt(registration.APNsToken, pushDeviceAPNsTokenAAD(existing.ID))
if err != nil {
return nil, fmt.Errorf("encrypt apns token: %w", err)
}
row := tx.QueryRow(ctx, `
UPDATE push_devices
SET user_id = $1,
provider = $2,
apns_environment = $3,
apns_topic = $4,
apns_token_ciphertext = $5,
apns_token_hash = $6,
push_mode = $7,
enabled = true,
last_seen_at = now(),
last_failure_at = NULL,
last_failure_code = NULL,
updated_at = now()
WHERE id = $8
RETURNING `+pushDeviceColumns,
registration.UserID,
PushProviderSiloRelay,
registration.APNsEnvironment,
registration.APNsTopic,
ciphertext,
apnsTokenHash(registration.APNsToken),
registration.PushMode,
existing.ID,
)
device, err := scanPushDevice(row)
if err != nil {
return nil, fmt.Errorf("update push device: %w", err)
}
return device, nil
}
func scanPushDevice(row pgx.Row) (*PushDevice, error) {
var device PushDevice
if err := row.Scan(
&device.ID,
&device.UserID,
&device.ProfileID,
&device.DeviceID,
&device.Platform,
&device.Provider,
&device.APNsEnvironment,
&device.APNsTopic,
&device.APNsTokenCiphertext,
&device.APNsTokenHash,
&device.ServerDeviceID,
&device.PushMode,
&device.Enabled,
&device.LastSeenAt,
&device.LastSuccessAt,
&device.LastFailureAt,
&device.LastFailureCode,
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
return nil, err
}
return &device, nil
}