diff --git a/internal/ntfy/account.go b/internal/ntfy/account.go new file mode 100644 index 0000000..84c7b4d --- /dev/null +++ b/internal/ntfy/account.go @@ -0,0 +1,151 @@ +package ntfy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "lazyntfy/internal/config" +) + +type accountBody struct { + SyncTopic string `json:"sync_topic"` + Subscriptions []struct { + BaseURL string `json:"base_url"` + Topic string `json:"topic"` + DisplayName *string `json:"display_name"` + } `json:"subscriptions"` + Reservations []struct { + Topic string `json:"topic"` + } `json:"reservations"` +} + +type grantBody struct { + Topic string `json:"topic"` + Permission string `json:"permission"` +} + +type userBody struct { + Grants []grantBody `json:"grants"` +} + +// AccountTopics lists topics visible to this login: account subscriptions, +// reserved topics, and ACL grants when the token can read /v1/users. +// Wildcard patterns are skipped. Credentials are not included in errors. +func (c *Client) AccountTopics(ctx context.Context, host string, auth config.Auth) ([]config.Sub, error) { + host = strings.TrimRight(strings.TrimSpace(host), "/") + if host == "" || (auth.Token == "" && auth.User == "") { + return nil, nil + } + body, status, err := c.get(ctx, host+"/v1/account", auth) + if err != nil { + return nil, err + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return nil, fmt.Errorf("HTTP %d: not authorized to list topics", status) + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("HTTP %d from /v1/account", status) + } + var account accountBody + if err := json.Unmarshal(body, &account); err != nil { + return nil, fmt.Errorf("account response: %w", err) + } + + var subs []config.Sub + seen := map[string]bool{} + add := func(base, topic, display string) { + topic = strings.TrimSpace(topic) + if topic == "" || strings.Contains(topic, "*") || topic == account.SyncTopic { + return + } + originHost := host + if strings.TrimSpace(base) != "" { + originHost = strings.TrimRight(base, "/") + } + origin, name, err := config.Resolve(topic, originHost) + if err != nil { + return + } + key := origin + "\n" + name + if seen[key] { + return + } + seen[key] = true + subs = append(subs, config.Sub{ + Origin: origin, + Name: name, + Display: strings.TrimSpace(display), + Auth: auth, + }) + } + + for _, sub := range account.Subscriptions { + display := "" + if sub.DisplayName != nil { + display = *sub.DisplayName + } + add(sub.BaseURL, sub.Topic, display) + } + for _, res := range account.Reservations { + add("", res.Topic, "") + } + + usersBody, usersStatus, err := c.get(ctx, host+"/v1/users", auth) + if err != nil || usersStatus < 200 || usersStatus >= 300 { + return subs, nil + } + for _, grant := range parseGrants(usersBody) { + perm := strings.ToLower(grant.Permission) + if perm == "deny-all" || perm == "deny" || perm == "none" { + continue + } + add("", grant.Topic, "") + } + return subs, nil +} + +func parseGrants(body []byte) []grantBody { + var wrapped struct { + Users []userBody `json:"users"` + } + if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Users != nil { + return flattenGrants(wrapped.Users) + } + var list []userBody + if err := json.Unmarshal(body, &list); err != nil { + return nil + } + return flattenGrants(list) +} + +func flattenGrants(users []userBody) []grantBody { + var out []grantBody + for _, user := range users { + out = append(out, user.Grants...) + } + return out +} + +func (c *Client) get(ctx context.Context, endpoint string, auth config.Auth) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + auth.Apply(req) + resp, err := c.http().Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} diff --git a/internal/ntfy/account_test.go b/internal/ntfy/account_test.go new file mode 100644 index 0000000..c9bbe06 --- /dev/null +++ b/internal/ntfy/account_test.go @@ -0,0 +1,82 @@ +package ntfy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "lazyntfy/internal/config" +) + +func TestAccountTopics(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tk_test" { + t.Errorf("missing auth") + } + switch r.URL.Path { + case "/v1/account": + _, _ = w.Write([]byte(`{ + "sync_topic":"st_skip", + "subscriptions":[ + {"base_url":"", "topic":"alerts", "display_name":"Alerts"}, + {"topic":"st_skip"}, + {"topic":"ops*"} + ], + "reservations":[{"topic":"backups"}] + }`)) + case "/v1/users": + _, _ = w.Write([]byte(`[ + {"username":"ada","grants":[ + {"topic":"stats","permission":"read-only"}, + {"topic":"*","permission":"read-write"}, + {"topic":"secret","permission":"deny-all"} + ]} + ]`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + subs, err := NewClient().AccountTopics(context.Background(), srv.URL, config.Auth{Token: "tk_test"}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, sub := range subs { + got[sub.Name] = sub.Display + if sub.Auth.Token != "tk_test" { + t.Fatal("auth dropped") + } + } + _, hasBackups := got["backups"] + _, hasStats := got["stats"] + if got["alerts"] != "Alerts" || !hasBackups || !hasStats { + t.Fatalf("%v", got) + } + if _, ok := got["st_skip"]; ok { + t.Fatal("sync topic included") + } + if _, ok := got["secret"]; ok { + t.Fatal("denied topic included") + } + if _, ok := got["*"]; ok || len(got) != 3 { + t.Fatalf("%v", got) + } +} + +func TestAccountTopicsIgnoresUsersForbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/users" { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = w.Write([]byte(`{"reservations":[{"topic":"alerts"}]}`)) + })) + defer srv.Close() + subs, err := NewClient().AccountTopics(context.Background(), srv.URL, config.Auth{Token: "tk_test"}) + if err != nil || len(subs) != 1 || subs[0].Name != "alerts" { + t.Fatalf("%v %+v", err, subs) + } +} diff --git a/internal/ntfy/client.go b/internal/ntfy/client.go new file mode 100644 index 0000000..a93bd46 --- /dev/null +++ b/internal/ntfy/client.go @@ -0,0 +1,178 @@ +package ntfy + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "lazyntfy/internal/config" +) + +const userAgent = "lazyntfy/0.1.0" + +// Client talks to an ntfy server over HTTP. +type Client struct { + HTTP *http.Client +} + +// NewClient returns a client whose timeout covers the handshake, not the +// long-lived response body. +func NewClient() *Client { + return &Client{HTTP: &http.Client{Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 90 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }}} +} + +func (c *Client) http() *http.Client { + if c != nil && c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} + +// PublishRequest is a plain-text ntfy publish. +type PublishRequest struct { + Title string + Message string + Priority int + Tags []string +} + +// StreamMeta is returned once response headers have been read. +type StreamMeta struct { + Truncated bool +} + +// OpenStream starts a JSON subscription. The caller must close the body. +func (c *Client) OpenStream(ctx context.Context, endpoint string, auth config.Auth) (*http.Response, StreamMeta, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, StreamMeta{}, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/x-ndjson") + auth.Apply(req) + resp, err := c.http().Do(req) + if err != nil { + return nil, StreamMeta{}, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + return nil, StreamMeta{}, httpStatusError(resp) + } + return resp, StreamMeta{Truncated: resp.Header.Get("X-Messages-Truncated") == "1"}, nil +} + +// Publish sends a message to one topic. +func (c *Client) Publish(ctx context.Context, origin, name string, auth config.Auth, msg PublishRequest) error { + if strings.TrimSpace(msg.Message) == "" { + return fmt.Errorf("message is empty") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, PublishURL(origin, name), strings.NewReader(msg.Message)) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + if msg.Title != "" { + req.Header.Set("Title", msg.Title) + } + if msg.Priority > 0 { + req.Header.Set("Priority", priorityHeader(msg.Priority)) + } + if len(msg.Tags) > 0 { + req.Header.Set("Tags", strings.Join(msg.Tags, ",")) + } + auth.Apply(req) + resp, err := c.http().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return httpStatusError(resp) + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil +} + +func httpStatusError(resp *http.Response) error { + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("HTTP %d: not authorized for this topic", resp.StatusCode) + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + msg := strings.TrimSpace(string(body)) + msg = strings.ReplaceAll(msg, "\n", " ") + if msg == "" { + return fmt.Errorf("HTTP %s", resp.Status) + } + return fmt.Errorf("HTTP %s: %s", resp.Status, msg) +} + +func priorityHeader(p int) string { + switch p { + case 1: + return "min" + case 2: + return "low" + case 3: + return "default" + case 4: + return "high" + case 5: + return "max" + default: + return strconv.Itoa(p) + } +} + +// ParsePriority accepts 1-5 or the ntfy names min, low, default, high, and max. +func ParsePriority(s string) (int, error) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return 3, nil + } + switch s { + case "min", "1": + return 1, nil + case "low", "2": + return 2, nil + case "default", "def", "3": + return 3, nil + case "high", "4": + return 4, nil + case "max", "urgent", "5": + return 5, nil + default: + return 0, fmt.Errorf("priority must be 1-5 or min/low/default/high/max") + } +} + +// ParseTags splits a comma-separated tag list. +func ParseTags(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/ntfy/client_test.go b/internal/ntfy/client_test.go new file mode 100644 index 0000000..d958712 --- /dev/null +++ b/internal/ntfy/client_test.go @@ -0,0 +1,102 @@ +package ntfy + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "lazyntfy/internal/config" +) + +func TestOpenStreamAndPublish(t *testing.T) { + var published string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tk_test" { + t.Errorf("auth header missing") + } + switch r.Method { + case http.MethodGet: + if !strings.Contains(r.URL.Path, "/alerts/json") { + t.Errorf("path %s", r.URL.Path) + } + w.Header().Set("X-Messages-Truncated", "1") + _, _ = io.WriteString(w, "{\"id\":\"m1\",\"time\":10,\"event\":\"message\",\"topic\":\"alerts\",\"message\":\"hi\"}\n") + case http.MethodPost: + if r.URL.Path != "/alerts" { + t.Errorf("post path %s", r.URL.Path) + } + if r.Header.Get("Title") != "Disk" || r.Header.Get("Priority") != "high" || r.Header.Get("Tags") != "a,b" { + t.Errorf("headers %v", r.Header) + } + b, _ := io.ReadAll(r.Body) + published = string(b) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("method %s", r.Method) + } + })) + defer srv.Close() + + c := NewClient() + auth := config.Auth{Token: "tk_test"} + resp, meta, err := c.OpenStream(context.Background(), srv.URL+"/alerts/json?since=all", auth) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if !meta.Truncated { + t.Fatal("expected truncation header") + } + var n int + if err := ReadLines(resp.Body, func(ev Event) error { + n++ + if ev.Message != "hi" { + t.Fatalf("%+v", ev) + } + return nil + }, nil); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("events %d", n) + } + + err = c.Publish(context.Background(), srv.URL, "alerts", auth, PublishRequest{ + Title: "Disk", Message: "full", Priority: 4, Tags: []string{"a", "b"}, + }) + if err != nil { + t.Fatal(err) + } + if published != "full" { + t.Fatalf("body %q", published) + } +} + +func TestUnauthorizedHidesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, "token tk_supersecret rejected") + })) + defer srv.Close() + _, _, err := NewClient().OpenStream(context.Background(), srv.URL+"/alerts/json", config.Auth{Token: "tk_test"}) + if err == nil || strings.Contains(err.Error(), "tk_supersecret") || !strings.Contains(err.Error(), "not authorized") { + t.Fatalf("%v", err) + } +} + +func TestParsePriority(t *testing.T) { + p, err := ParsePriority("high") + if err != nil || p != 4 { + t.Fatal(p, err) + } + if _, err := ParsePriority("nope"); err == nil { + t.Fatal("expected error") + } + tags := ParseTags(" a, b ,,c ") + if strings.Join(tags, "|") != "a|b|c" { + t.Fatal(tags) + } +} diff --git a/internal/ntfy/event.go b/internal/ntfy/event.go new file mode 100644 index 0000000..ccb77e4 --- /dev/null +++ b/internal/ntfy/event.go @@ -0,0 +1,72 @@ +package ntfy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +// Event is one NDJSON object from GET /topic/json. +type Event struct { + ID string `json:"id"` + Time int64 `json:"time"` + Expires int64 `json:"expires,omitempty"` + Event string `json:"event"` + Topic string `json:"topic"` + Message string `json:"message"` + Title string `json:"title"` + Tags []string `json:"tags"` + Priority int `json:"priority"` + Click string `json:"click"` + SequenceID string `json:"sequence_id,omitempty"` + Attachment *Attachment `json:"attachment,omitempty"` + Actions json.RawMessage `json:"actions,omitempty"` +} + +// Attachment describes a file linked from a message. +type Attachment struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` +} + +// ParseLine decodes one NDJSON line. ok is false for blank lines. +func ParseLine(line []byte) (Event, bool, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return Event{}, false, nil + } + var ev Event + if err := json.Unmarshal(line, &ev); err != nil { + return Event{}, false, err + } + if ev.Event == "" { + return Event{}, false, fmt.Errorf("missing event") + } + return ev, true, nil +} + +// ReadLines yields each decoded event until EOF or a read error. +// Malformed JSON is reported and skipped so one bad line does not kill the stream. +func ReadLines(r io.Reader, yield func(Event) error, onBad func(error)) error { + sc := newScanner(r) + for sc.Scan() { + ev, ok, err := ParseLine(sc.Bytes()) + if err != nil { + if onBad != nil { + onBad(err) + } + continue + } + if !ok { + continue + } + if err := yield(ev); err != nil { + return err + } + } + return sc.Err() +} diff --git a/internal/ntfy/event_test.go b/internal/ntfy/event_test.go new file mode 100644 index 0000000..f7df73d --- /dev/null +++ b/internal/ntfy/event_test.go @@ -0,0 +1,37 @@ +package ntfy + +import ( + "strings" + "testing" +) + +func TestParseLine(t *testing.T) { + ev, ok, err := ParseLine([]byte(`{"id":"abc","time":10,"event":"message","topic":"alerts","message":"hi","priority":5}`)) + if err != nil || !ok { + t.Fatal(err, ok) + } + if ev.ID != "abc" || ev.Priority != 5 || ev.Message != "hi" { + t.Fatalf("%+v", ev) + } + if _, ok, err := ParseLine([]byte(" ")); err != nil || ok { + t.Fatalf("blank: %v %v", err, ok) + } + if _, _, err := ParseLine([]byte("{")); err == nil { + t.Fatal("expected json error") + } +} + +func TestReadLinesSkipsGarbage(t *testing.T) { + var got []string + var bad int + err := ReadLines(strings.NewReader("nope\n{\"id\":\"a\",\"time\":1,\"event\":\"open\",\"topic\":\"t\"}\n"), func(ev Event) error { + got = append(got, ev.Event) + return nil + }, func(error) { bad++ }) + if err != nil { + t.Fatal(err) + } + if bad != 1 || len(got) != 1 || got[0] != "open" { + t.Fatalf("bad=%d got=%v", bad, got) + } +} diff --git a/internal/ntfy/group.go b/internal/ntfy/group.go new file mode 100644 index 0000000..b5a1361 --- /dev/null +++ b/internal/ntfy/group.go @@ -0,0 +1,52 @@ +package ntfy + +import ( + "net/url" + "strings" + + "lazyntfy/internal/config" +) + +// Group is a set of topics that share an origin and credentials and can +// therefore share one JSON stream. +type Group struct { + Origin string + Names []string + Auth config.Auth +} + +// URL is the /json subscription endpoint for this group. +func (g Group) URL(since string) string { + parts := make([]string, len(g.Names)) + for i, name := range g.Names { + parts[i] = url.PathEscape(name) + } + endpoint := g.Origin + "/" + strings.Join(parts, ",") + "/json" + if since == "" { + return endpoint + } + q := url.Values{} + q.Set("since", since) + return endpoint + "?" + q.Encode() +} + +// PublishURL is the POST endpoint for one topic. +func PublishURL(origin, name string) string { + return origin + "/" + url.PathEscape(name) +} + +// GroupSubs buckets subscriptions that can share a connection. +func GroupSubs(subs []config.Sub) []Group { + var out []Group + index := map[string]int{} + for _, sub := range subs { + key := sub.Origin + "\n" + sub.Auth.Token + "\n" + sub.Auth.User + "\n" + sub.Auth.Password + if i, ok := index[key]; ok { + out[i].Names = append(out[i].Names, sub.Name) + continue + } + index[key] = len(out) + out = append(out, Group{Origin: sub.Origin, Names: []string{sub.Name}, Auth: sub.Auth}) + } + return out +} diff --git a/internal/ntfy/group_test.go b/internal/ntfy/group_test.go new file mode 100644 index 0000000..54cd9b0 --- /dev/null +++ b/internal/ntfy/group_test.go @@ -0,0 +1,22 @@ +package ntfy + +import ( + "testing" + + "lazyntfy/internal/config" +) + +func TestGroupSubs(t *testing.T) { + subs := []config.Sub{ + {Origin: "https://h", Name: "a", Auth: config.Auth{Token: "t"}}, + {Origin: "https://h", Name: "b", Auth: config.Auth{Token: "t"}}, + {Origin: "https://h", Name: "c", Auth: config.Auth{Token: "other"}}, + } + groups := GroupSubs(subs) + if len(groups) != 2 || len(groups[0].Names) != 2 { + t.Fatalf("%+v", groups) + } + if groups[0].URL("all") != "https://h/a,b/json?since=all" { + t.Fatalf("url %s", groups[0].URL("all")) + } +} diff --git a/internal/ntfy/manager.go b/internal/ntfy/manager.go new file mode 100644 index 0000000..f962b5f --- /dev/null +++ b/internal/ntfy/manager.go @@ -0,0 +1,224 @@ +package ntfy + +import ( + "context" + "errors" + "strconv" + "strings" + "sync" + "time" + + "lazyntfy/internal/config" +) + +// Notice is a stream lifecycle update or a decoded event. +type Notice struct { + Event *Event + Topics []string + Status string + Up bool + UpSet bool +} + +// Manager keeps one reconnecting JSON stream per topic group. +type Manager struct { + client *Client + out chan Notice + reload chan struct{} + mu sync.Mutex + ctx context.Context + subs []config.Sub + since string + cursors map[string]int64 + backoff time.Duration + maxBackoff time.Duration +} + +// NewManager starts with no subscriptions until Start is called. +func NewManager(client *Client) *Manager { + if client == nil { + client = NewClient() + } + return &Manager{ + client: client, + out: make(chan Notice, 256), + reload: make(chan struct{}, 1), + cursors: map[string]int64{}, + backoff: time.Second, + maxBackoff: 30 * time.Second, + } +} + +// SetBackoff overrides reconnect delays. Tests use a short interval. +func (m *Manager) SetBackoff(initial, max time.Duration) { + m.backoff = initial + m.maxBackoff = max +} + +// Prime records already-seen newest times per origin/topic so the first stream +// does not replay history the client restored from disk. Keys use the same +// origin + "/" + topic format the manager tracks internally. +func (m *Manager) Prime(cursors map[string]int64) { + m.mu.Lock() + defer m.mu.Unlock() + for key, ts := range cursors { + if ts > m.cursors[key] { + m.cursors[key] = ts + } + } +} + +// Notices is the stream of events and status lines. It is never closed. +func (m *Manager) Notices() <-chan Notice { return m.out } + +// Start subscribes until ctx is cancelled. Call it once. +func (m *Manager) Start(ctx context.Context, subs []config.Sub, since string) { + m.mu.Lock() + m.ctx = ctx + m.subs = append([]config.Sub(nil), subs...) + m.since = since + m.mu.Unlock() + go m.run() +} + +// Reload drops the current connections and subscribes again. +func (m *Manager) Reload() { + select { + case m.reload <- struct{}{}: + default: + } +} + +// Add appends a topic for this session and reconnects. +func (m *Manager) Add(sub config.Sub) { + m.mu.Lock() + m.subs = append(m.subs, sub) + m.mu.Unlock() + m.Reload() +} + +func (m *Manager) run() { + ctx := m.ctx + for { + if ctx.Err() != nil { + return + } + m.mu.Lock() + subs := append([]config.Sub(nil), m.subs...) + m.mu.Unlock() + child, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup + if len(subs) == 0 { + m.emit(child, Notice{Status: "no topics"}) + } + for _, group := range GroupSubs(subs) { + wg.Add(1) + go func(g Group) { + defer wg.Done() + m.supervise(child, g) + }(group) + } + stopped := make(chan struct{}) + go func() { + wg.Wait() + close(stopped) + }() + select { + case <-ctx.Done(): + cancel() + <-stopped + return + case <-m.reload: + cancel() + <-stopped + } + } +} + +func (m *Manager) supervise(ctx context.Context, g Group) { + delay := m.backoff + for { + if ctx.Err() != nil { + return + } + err := m.consume(ctx, g) + if ctx.Err() != nil || errors.Is(err, context.Canceled) { + return + } + status := "disconnected, retrying" + if err != nil { + status = err.Error() + } + m.emit(ctx, Notice{Topics: g.Names, Status: status, Up: false, UpSet: true}) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if delay < m.maxBackoff { + delay *= 2 + if delay > m.maxBackoff { + delay = m.maxBackoff + } + } + } +} + +func (m *Manager) consume(ctx context.Context, g Group) error { + resp, meta, err := m.client.OpenStream(ctx, g.URL(m.sinceFor(g)), g.Auth) + if err != nil { + return err + } + defer resp.Body.Close() + m.emit(ctx, Notice{Topics: g.Names, Status: "connected", Up: true, UpSet: true}) + if meta.Truncated { + m.emit(ctx, Notice{Topics: g.Names, Status: "cache truncated to the newest messages", Up: true, UpSet: true}) + } + return ReadLines(resp.Body, func(ev Event) error { + if ctx.Err() != nil { + return ctx.Err() + } + m.note(g.Origin, ev) + copyEv := ev + m.emit(ctx, Notice{Event: ©Ev, Topics: []string{ev.Topic}}) + return nil + }, func(err error) { + m.emit(ctx, Notice{Status: "skipped malformed event"}) + }) +} + +func (m *Manager) sinceFor(g Group) string { + m.mu.Lock() + defer m.mu.Unlock() + var max int64 + for _, name := range g.Names { + if ts := m.cursors[g.Origin+"/"+name]; ts > max { + max = ts + } + } + if max == 0 { + return m.since + } + return strconv.FormatInt(max, 10) +} + +func (m *Manager) note(origin string, ev Event) { + if ev.Time == 0 || ev.Topic == "" || strings.Contains(ev.Topic, ",") { + return + } + key := origin + "/" + ev.Topic + m.mu.Lock() + if ev.Time > m.cursors[key] { + m.cursors[key] = ev.Time + } + m.mu.Unlock() +} + +func (m *Manager) emit(ctx context.Context, n Notice) { + select { + case <-ctx.Done(): + case m.out <- n: + } +} diff --git a/internal/ntfy/manager_test.go b/internal/ntfy/manager_test.go new file mode 100644 index 0000000..50c2dfb --- /dev/null +++ b/internal/ntfy/manager_test.go @@ -0,0 +1,101 @@ +package ntfy + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "lazyntfy/internal/config" +) + +func TestPrimeAvoidsReplayingRestoredHistory(t *testing.T) { + gotSince := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSince <- r.URL.Query().Get("since") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + })) + defer srv.Close() + + mgr := NewManager(NewClient()) + mgr.Prime(map[string]int64{srv.URL + "/alerts": 1234}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mgr.Start(ctx, []config.Sub{{ + Origin: srv.URL, + Name: "alerts", + Auth: config.Auth{Token: "tk_test"}, + }}, "all") + + select { + case since := <-gotSince: + if since != "1234" { + t.Fatalf("first since %q", since) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for the first request") + } +} + +func TestManagerReconnectsWithCursor(t *testing.T) { + var hits atomic.Int32 + secondSince := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := hits.Add(1) + if r.Header.Get("Authorization") != "Bearer tk_test" { + t.Errorf("missing auth") + } + if n == 1 { + if r.URL.Query().Get("since") != "all" { + t.Errorf("first since %q", r.URL.Query().Get("since")) + } + _, _ = io.WriteString(w, "{\"id\":\"m1\",\"time\":100,\"event\":\"message\",\"topic\":\"alerts\",\"message\":\"hi\"}\n") + return + } + secondSince <- r.URL.Query().Get("since") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() + })) + defer srv.Close() + + mgr := NewManager(NewClient()) + mgr.SetBackoff(5*time.Millisecond, 20*time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mgr.Start(ctx, []config.Sub{{ + Origin: srv.URL, + Name: "alerts", + Auth: config.Auth{Token: "tk_test"}, + }}, "all") + + deadline := time.After(3 * time.Second) + sawMessage := false + for !sawMessage { + select { + case n := <-mgr.Notices(): + if n.Event != nil && n.Event.ID == "m1" { + sawMessage = true + } + case <-deadline: + t.Fatal("timed out waiting for the first message") + } + } + select { + case since := <-secondSince: + if since != "100" { + t.Fatalf("reconnect since %q", since) + } + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for reconnect, hits=%d", hits.Load()) + } +} diff --git a/internal/ntfy/scan.go b/internal/ntfy/scan.go new file mode 100644 index 0000000..31b715e --- /dev/null +++ b/internal/ntfy/scan.go @@ -0,0 +1,13 @@ +package ntfy + +import ( + "bufio" + "io" +) + +func newScanner(r io.Reader) *bufio.Scanner { + sc := bufio.NewScanner(r) + buf := make([]byte, 64*1024) + sc.Buffer(buf, 10*1024*1024) + return sc +}