package aviso import ( "context" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "sync" "time" webpush "github.com/SherClockHolmes/webpush-go" ) // Options tune one batch. Zero values mean aviso's defaults, not the // push service's: webpush-go always sends a TTL header, and a literal // 0 there means "deliver now or drop". type Options struct { // TTL is how long the push service may hold the message; whole // seconds, >= 0. 0 means 24 hours. TTL time.Duration // Urgency is "very-low", "low", "normal" or "high"; "" means normal. Urgency string // Topic collapses pending messages with the same topic; <= 32 // URL-safe characters; "" means none. Topic string } // Result is one device's outcome. Status is the push service's // acceptance, not delivery; a 2xx means the service took it. type Result struct { ID string Status int // 0 when Err is transport-level or the row was never attempted RetryAfter time.Duration // from a 429/503, else 0 Err error } // ErrPayloadTooLarge means the plaintext exceeds RFC 8291's one-record // limit; a larger payload would be split, which no browser accepts. var ErrPayloadTooLarge = errors.New("aviso: payload over 3993 bytes") // ErrBadOptions means Options failed validation. var ErrBadOptions = errors.New("aviso: invalid Options") const ( maxPayload = 3993 defaultTTL = 24 * time.Hour requestTimeout = 30 * time.Second maxBodyRead = 4096 ) func (o Options) validate() error { if o.TTL < 0 || o.TTL%time.Second != 0 { return fmt.Errorf("%w: TTL must be whole non-negative seconds", ErrBadOptions) } switch o.Urgency { case "", "very-low", "low", "normal", "high": default: return fmt.Errorf("%w: Urgency %q", ErrBadOptions, o.Urgency) } if len(o.Topic) > 32 || strings.Trim(o.Topic, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" { return fmt.Errorf("%w: Topic must be <= 32 URL-safe characters", ErrBadOptions) } return nil } // SendTo fans payload out to every device subject enrolled — the // common case, so an app never touches Stored. The batch error covers // what stops the batch (the query, validation, cancellation); each // Result covers one device. func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error) { if err := checkBatch(ctx, payload, o); err != nil { return nil, err } rows, err := s.List(ctx, subject) if err != nil { return nil, err } return s.Send(ctx, rows, payload, o) } // Send delivers payload to each of to, bounded by Config.Concurrency // across the Service. It never retries: RetryAfter is for the app's // own scheduler. // // On cancellation, rows not yet started carry ctx.Err(), rows already // in flight finish (their own request honours ctx), and Send returns // only once every goroutine it started has stopped writing results — // a caller that reads results after Send returns must never race a // straggler. func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error) { if err := checkBatch(ctx, payload, o); err != nil { return nil, err } results := make([]Result, len(to)) var wg sync.WaitGroup var batchErr error for i := range to { st := to[i] results[i].ID = st.ID if st.VAPIDKeyID != s.keyID { results[i].Err = ErrKeyMismatch continue } if err := validateEndpoint(st.Endpoint); err != nil { results[i].Err = err continue } if batchErr != nil { results[i].Err = batchErr continue } select { case s.sem <- struct{}{}: case <-ctx.Done(): batchErr = ctx.Err() results[i].Err = batchErr continue } // A slot freed by a request the cancellation cut short can win // the select above; nothing starts after cancellation. if err := ctx.Err(); err != nil { <-s.sem batchErr = err results[i].Err = err continue } wg.Add(1) go func(i int, st Stored) { defer wg.Done() defer func() { <-s.sem }() results[i] = s.sendOne(ctx, st, payload, o) }(i, st) } wg.Wait() if batchErr == nil && ctx.Err() != nil { batchErr = ctx.Err() } return results, batchErr } func checkBatch(ctx context.Context, payload []byte, o Options) error { if err := ctx.Err(); err != nil { return err } if len(payload) > maxPayload { return ErrPayloadTooLarge } return o.validate() } func (s *Service) sendOne(ctx context.Context, st Stored, payload []byte, o Options) Result { res := Result{ID: st.ID} ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() ttl := o.TTL if ttl == 0 { ttl = defaultTTL } urgency := webpush.Urgency(o.Urgency) if urgency == "" { urgency = webpush.UrgencyNormal } // webpush-go wraps the payload in a bytes.Buffer and appends the // record delimiter and padding in place. With spare capacity, the // concurrent sends of one batch would write into one backing // array; capping the capacity forces each append to reallocate. payload = payload[:len(payload):len(payload)] resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{Endpoint: st.Endpoint, Keys: webpush.Keys{P256dh: st.P256dh, Auth: st.Auth}}, &webpush.Options{ HTTPClient: s.client, Subscriber: s.wireContact, TTL: int(ttl / time.Second), Urgency: urgency, Topic: o.Topic, VAPIDPublicKey: s.pub, VAPIDPrivateKey: s.cfg.PrivateKey, }) if err != nil { res.Err = redact(err) return res } defer resp.Body.Close() // Drain a bounded amount for keep-alive; the body is never read // into anything a log could see. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyRead)) return s.settle(ctx, st, resp.StatusCode, resp.Header.Get("Retry-After")) } // settle turns the push service's answer into a Result and the store // update it implies. Store updates run under a context the caller's // cancellation cannot interrupt — a 410 the service already answered // must prune whether or not the batch was cancelled a moment later — // but with their own bound, because the writer is one connection and // a transaction holding it must not pin a semaphore slot forever. func (s *Service) settle(ctx context.Context, st Stored, status int, retryAfter string) Result { res := Result{ID: st.ID, Status: status} dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.dbTimeout) defer cancel() switch { case status >= 200 && status < 300: if err := s.confirm(dbCtx, st.ID, st.Revision); err != nil { s.cfg.Logger.Warn("aviso: confirm failed", "id", st.ID, "err", err) } case status == http.StatusNotFound || status == http.StatusGone: res.Err = fmt.Errorf("aviso: push service says subscription gone (%d)", status) if err := s.prune(dbCtx, st.ID, st.Revision); err != nil { s.cfg.Logger.Warn("aviso: prune failed", "id", st.ID, "err", err) } case status == http.StatusTooManyRequests || status == http.StatusServiceUnavailable: res.RetryAfter = parseRetryAfter(retryAfter, s.now()) res.Err = fmt.Errorf("aviso: push service throttled (%d)", status) default: res.Err = fmt.Errorf("aviso: push service refused (%d)", status) } return res } // ErrTransport is the sanitised form of any transport failure that is // not a cancellation, a timeout or the guard's own refusal. The // original is discarded, not wrapped: net/http's errors quote the // request URL and even a bad Location header verbatim, and the // endpoint is the one secret in this package that must never reach a // log. var ErrTransport = errors.New("aviso: transport error") // ErrDialRefused is the guard's refusal, surfaced without the address. var ErrDialRefused = errors.New("aviso: dial refused by the SSRF guard") func redact(err error) error { switch { case errors.Is(err, context.Canceled): return fmt.Errorf("aviso: send: %w", context.Canceled) case errors.Is(err, context.DeadlineExceeded): return fmt.Errorf("aviso: send: %w", context.DeadlineExceeded) case strings.Contains(err.Error(), "aviso: dial refused"): return ErrDialRefused } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { return fmt.Errorf("aviso: send: %w", context.DeadlineExceeded) } return ErrTransport } // maxRetryAfter caps what a push service can ask for: a value past // this is meaningless to a scheduler, and an unbounded one overflows // time.Duration into a negative number. const maxRetryAfter = 24 * time.Hour func parseRetryAfter(v string, now time.Time) time.Duration { if v == "" { return 0 } var d time.Duration if strings.Trim(v, "0123456789") == "" { // delta-seconds, however many digits secs, err := strconv.ParseInt(v, 10, 64) if err != nil || secs > int64(maxRetryAfter/time.Second) { // err here is only ever "out of range" return maxRetryAfter } d = time.Duration(secs) * time.Second } else if t, err := http.ParseTime(v); err == nil && t.After(now) { d = t.Sub(now) } if d > maxRetryAfter { return maxRetryAfter } return d }