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
|
||||
}
|
||||
Reference in New Issue
Block a user