feat(ntfy): add the HTTP client, stream manager, and account discovery
Speak the ntfy JSON API directly: subscribe, publish, group topics that share a server and credentials, reconnect with backoff and a since cursor, and list account subscriptions, reservations, and ACL grants.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user