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 }