CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
196 lines
6.9 KiB
Go
196 lines
6.9 KiB
Go
package push
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
|
)
|
|
|
|
// pushTTL is how long a push service keeps a notification for a device
|
|
// that is offline.
|
|
const pushTTL = 12 * time.Hour
|
|
|
|
// maxDrainBytes bounds how much of a push service response body is read.
|
|
const maxDrainBytes = 4096
|
|
|
|
// notice is what the service worker shows (web/src/push/notification.ts).
|
|
type notice struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
URL string `json:"url"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
// Sender is the notify.Sender of the push channel: it sends a notification
|
|
// to every stored subscription, signed with the VAPID key pair.
|
|
//
|
|
// Per subscription, a notification the push service accepts records
|
|
// last_success_at; one it refuses or cannot be sent increments
|
|
// failure_count, and a 404 or 410 deletes the subscription, which the push
|
|
// service no longer has. Send succeeds when at least one subscription
|
|
// accepted the notification, and when there are no subscriptions at all.
|
|
//
|
|
// Errors and logs name a subscription by its id, never by its endpoint,
|
|
// which is a capability URL.
|
|
type Sender struct {
|
|
st *store.Store
|
|
logger *slog.Logger
|
|
// subscriber is the VAPID subject as webpush-go takes it: an https://
|
|
// URL, or the bare email address of a mailto: subject.
|
|
subscriber string
|
|
publicKey string
|
|
privateKey string
|
|
// client sends the push requests; tests replace it.
|
|
client webpush.HTTPClient
|
|
// now is the clock last_success_at is recorded on; tests replace it.
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewSender returns the push channel's sender over st, signing with the
|
|
// VAPID key pair publicKey and privateKey (vapid's encoding) and naming
|
|
// subject, a mailto: URI or https:// URL, as the contact.
|
|
func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) {
|
|
if st == nil {
|
|
return nil, errors.New("push: nil store")
|
|
}
|
|
if logger == nil {
|
|
return nil, errors.New("push: nil logger")
|
|
}
|
|
pub, err := vapid.ParsePublicKey(publicKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
|
}
|
|
priv, err := vapid.ParsePrivateKey(privateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("push: VAPID private key: %w", err)
|
|
}
|
|
if err := vapid.CheckPair(pub, priv); err != nil {
|
|
return nil, fmt.Errorf("push: VAPID keys: %w", err)
|
|
}
|
|
// webpush-go prefixes mailto: to any subject not starting with https:,
|
|
// so a mailto: subject is handed over as its bare address.
|
|
var subscriber string
|
|
if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" {
|
|
subscriber = addr
|
|
} else if strings.HasPrefix(subject, "https://") {
|
|
subscriber = subject
|
|
} else {
|
|
return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject)
|
|
}
|
|
return &Sender{
|
|
st: st,
|
|
logger: logger,
|
|
subscriber: subscriber,
|
|
publicKey: publicKey,
|
|
privateKey: privateKey,
|
|
client: &http.Client{},
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// Send sends m to every stored subscription. It returns nil when at least
|
|
// one accepted it or none is stored, and otherwise an error listing why
|
|
// each failed, so the outbox retries. A database error is returned at once.
|
|
func (s *Sender) Send(ctx context.Context, m notify.Message) error {
|
|
logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey)
|
|
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("push: list subscriptions: %w", err)
|
|
}
|
|
if len(subs) == 0 {
|
|
logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent")
|
|
return nil
|
|
}
|
|
message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag})
|
|
if err != nil {
|
|
return fmt.Errorf("push: encode notification: %w", err)
|
|
}
|
|
|
|
q := db.New(s.st.Writer)
|
|
delivered := 0
|
|
var failures []string
|
|
for _, sub := range subs {
|
|
status, err := s.sendOne(ctx, message, sub)
|
|
switch {
|
|
case err == nil && status >= 200 && status < 300:
|
|
delivered++
|
|
at := sql.NullString{String: timestamp(s.now()), Valid: true}
|
|
if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil {
|
|
return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err)
|
|
}
|
|
continue
|
|
case err == nil && (status == http.StatusNotFound || status == http.StatusGone):
|
|
failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status))
|
|
logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status)
|
|
if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil {
|
|
return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err)
|
|
}
|
|
continue
|
|
case err == nil:
|
|
failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status))
|
|
default:
|
|
failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err))
|
|
}
|
|
if err := q.RecordPushFailure(ctx, sub.ID); err != nil {
|
|
return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err)
|
|
}
|
|
}
|
|
if delivered == 0 {
|
|
return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; "))
|
|
}
|
|
if len(failures) > 0 {
|
|
logger.WarnContext(ctx, "push notification not accepted by every subscription",
|
|
"delivered", delivered, "failures", strings.Join(failures, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendOne sends message to sub and returns the push service's status. Its
|
|
// error never quotes the endpoint.
|
|
func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) {
|
|
resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{
|
|
Endpoint: sub.Endpoint,
|
|
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
|
|
}, &webpush.Options{
|
|
HTTPClient: s.client,
|
|
Subscriber: s.subscriber,
|
|
TTL: int(pushTTL / time.Second),
|
|
VAPIDPublicKey: s.publicKey,
|
|
VAPIDPrivateKey: s.privateKey,
|
|
})
|
|
if err != nil {
|
|
// A *url.Error quotes the request URL, the endpoint; keep only
|
|
// its cause.
|
|
var ue *url.Error
|
|
if errors.As(err, &ue) {
|
|
return 0, fmt.Errorf("request failed: %w", ue.Err)
|
|
}
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
// The status is the outcome; the body is read only so the connection
|
|
// can be reused.
|
|
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// timestamp returns t in the stored UTC RFC 3339 form.
|
|
func timestamp(t time.Time) string {
|
|
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
|
}
|