Files
lazyntfy/internal/ntfy/account.go
T
ahp cd19d4c665 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.
2026-10-11 12:04:53 +03:30

152 lines
3.8 KiB
Go

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
}