// Package apprise is a built-in connector for Apprise — the notification // library that speaks 100+ services (email, Discord, Telegram, ntfy, Pushover, // Slack, Matrix, SMS gateways, desktop toasts, …) behind one URL grammar such as // mailto://, tgram:// or discord://. // // The library itself is Python; what this connector talks to is apprise-api // (github.com/caronc/apprise-api), the small Django service that wraps it in // HTTP and is normally run as a container beside DriverVault. So the plugin // carries no notification protocols of its own: it hands a message to an Apprise // endpoint the operator runs and lets Apprise fan it out. // // The endpoints used, all of them documented in that project's README: // // GET /status server health and which modes are enabled // GET /details every notification service this build supports // GET /json/urls/{key} the URLs and tags stored under a config key // POST /notify/{key} send using the URLs stored under that key (stateful) // POST /notify send using URLs supplied in the request (stateless) // // A JSON body and an Accept: application/json header go out on every call, which // is what makes apprise-api answer in JSON rather than HTML or plain text. // // There are two ways to address targets, and the choice is the configKey field: // - Stateful — the operator has stored a set of URLs on the Apprise server // under a key (POST /add/{key}, or the server's own web form) and refers to // them by that key, optionally narrowed by a tag expression. Recipients are // then edited on the Apprise side without touching DriverVault, and no // credentials for the downstream services are ever held here. // - Stateless — the URLs travel with each request, taken from the urls config // field. Simpler for a single destination, but those URLs embed tokens and // passwords, so the field is marked secret and stored the way every other // plugin secret is. // // Scope & limitations: // - Send and read. The config-writing endpoints (/add, /del) are deliberately // not implemented: the Apprise config is the operator's, DriverVault posts to // it rather than owning it, and a connector that can delete a notification // config is a wider blast radius than one that can only send through it. // - Attachments by URL only. apprise-api takes uploads as multipart; the // "attach" parameter here passes remote URLs for the Apprise server to fetch, // which needs APPRISE_ATTACH_SIZE to be non-zero on that server. // - Stored URLs are read back privacy-masked (privacy=1 is forced on // /json/urls), so a target listing shows mailto://user:****@host and // downstream credentials never enter a DriverVault response. // - No authentication is part of the Apprise API — the project says so by // design and expects network isolation instead. When the instance is // published through a reverse proxy that adds HTTP Basic auth, the // username/password fields below are sent; left blank, nothing is. package apprise import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "sync" "time" "drivervault/apiserver/internal/plugins" ) // API paths on the Apprise server. const ( pathStatus = "/status" pathDetails = "/details" pathJSONURLs = "/json/urls/" pathNotify = "/notify" ) // configKeyPattern is the key grammar apprise-api's routes accept // ([\w_-]{1,128}). Checking it here turns a typo into a clear error instead of a // 404 page from Django. var configKeyPattern = regexp.MustCompile(`^[\w-]{1,128}$`) // Notification types Apprise accepts, which colour the message on the services // that render one. const ( typeInfo = "info" typeSuccess = "success" typeWarning = "warning" typeFailure = "failure" ) var notifyTypes = map[string]bool{typeInfo: true, typeSuccess: true, typeWarning: true, typeFailure: true} // Body formats Apprise accepts. It converts between them per service, so a // markdown body still arrives readable on a service that only takes text. const ( formatText = "text" formatMarkdown = "markdown" formatHTML = "html" ) var bodyFormats = map[string]bool{formatText: true, formatMarkdown: true, formatHTML: true} // tagAll is Apprise's reserved tag: it matches every URL in a config regardless // of the tags that URL carries. const tagAll = "all" // Request timeout bounds. A notification is only answered once every target // service has been tried, so a widely-fanned-out config legitimately takes // seconds. const ( defaultTimeout = 15 * time.Second minTimeout = 1 * time.Second maxTimeout = 120 * time.Second ) // maxBody caps what is read from a response. /details is the large one — a few // hundred KiB of service templates on a full build. const maxBody = 8 << 20 // Apprise status codes that mean something specific to us: 204 is an empty or // unknown config key, 424 is "at least one notification failed", and 417 is // /status reporting a problem with the server itself. Each deserves better than // a generic HTTP error. const ( statusNoContent = http.StatusNoContent // 204 statusFailedDependency = http.StatusFailedDependency // 424 statusExpectationFail = http.StatusExpectationFailed // 417 ) func init() { plugins.Register("apprise", func() plugins.Plugin { return &Plugin{} }) } // Plugin is the Apprise connector. type Plugin struct { mu sync.Mutex // guards the config below; Invoke may run concurrently baseURL string configKey string urls string tag string format string username string password string client *http.Client } // Descriptor returns the plugin's static metadata for the admin panel. func (p *Plugin) Descriptor() plugins.Descriptor { return plugins.Descriptor{ Name: "apprise", Provider: "Apprise (apprise-api notification gateway)", Version: "1.0.0", Kind: plugins.KindBuiltin, Category: plugins.CategoryNotifications, AuthType: plugins.AuthBasic, Capabilities: []plugins.Capability{ {ID: "notify", Method: "POST", Endpoint: pathNotify + "/{key}", Description: "Send a notification through Apprise — to the URLs stored under the config key, or to URLs supplied with the call."}, {ID: "targets", Method: "GET", Endpoint: pathJSONURLs + "{key}", Description: "List the notification targets stored under a config key, with their tags. URLs come back privacy-masked."}, {ID: "services", Method: "GET", Endpoint: pathDetails, Description: "List the notification services this Apprise build supports, with the URL schemas each accepts."}, {ID: "status", Method: "GET", Endpoint: pathStatus, Description: "Report the Apprise server's own health and which of its modes (stateful config, attachments) are enabled."}, }, ConfigFields: []plugins.ConfigField{ // Unlike the vehicle and charger connectors, this one has no per-user // cascade to fill in a blank: the Apprise endpoint is infrastructure the // operator runs, not an account a driver owns. So the address is // genuinely required, and enabling without one fails immediately rather // than at the first notification nobody sees. {Key: "baseUrl", Label: "Apprise API base URL", Type: "text", Required: true, Help: "Address of your apprise-api instance, for example http://apprise:8000 or https://apprise.example.com. A path is kept, so an instance published under /apprise works too."}, {Key: "configKey", Label: "Config key", Type: "text", Help: "Key of a URL set stored on the Apprise server (its own default is \"apprise\"). Recipients are then managed there, and changing them needs no change here. Leave blank to send to the URLs below instead."}, {Key: "urls", Label: "Apprise URLs", Type: "password", Secret: true, Help: "Used when no config key is set: one or more Apprise URLs, comma separated — for example mailto://user:pass@gmail.com, tgram://bottoken/chatid, ntfy://topic. Secret, because these carry the credentials of the services they address."}, {Key: "tag", Label: "Tag expression", Type: "text", Default: tagAll, Help: "Which of the stored URLs to notify. \"" + tagAll + "\" is every one of them; \"a, b\" means tag a OR tag b, and \"a b\" means both. Applies to the config key only."}, {Key: "format", Label: "Message format", Type: "select", Default: formatText, Help: "How a message body is written. Apprise converts it to whatever each service actually accepts.", Options: []plugins.SelectOption{ {Value: formatText, Label: "Plain text"}, {Value: formatMarkdown, Label: "Markdown"}, {Value: formatHTML, Label: "HTML"}, }}, {Key: "username", Label: "HTTP username", Type: "text", Help: "Only for an instance published behind a reverse proxy that adds HTTP Basic auth. The Apprise API itself requires no authentication — leave blank for a directly reachable one."}, {Key: "password", Label: "HTTP password", Type: "password", Secret: true, Help: "Password for the Basic-auth username above."}, {Key: "timeout", Label: "Request timeout (seconds)", Type: "number", Default: "15", Help: "How long to wait for the Apprise server. A notification is only answered once every target service has been tried, so raise this for a config that fans out widely."}, }, } } // Init applies resolved config. It performs no network I/O — every call opens // its own request. func (p *Plugin) Init(_ context.Context, config map[string]string) error { base, err := normalizeBaseURL(config["baseUrl"]) if err != nil { return err } key := strings.TrimSpace(config["configKey"]) if key != "" && !configKeyPattern.MatchString(key) { return keyError(key) } p.mu.Lock() defer p.mu.Unlock() p.baseURL = base p.configKey = key p.urls = strings.TrimSpace(config["urls"]) p.tag = strings.TrimSpace(config["tag"]) if p.tag == "" { p.tag = tagAll } p.format = normalizeFormat(config["format"], formatText) p.username = strings.TrimSpace(config["username"]) p.password = config["password"] p.client = &http.Client{Timeout: parseTimeout(config["timeout"])} return nil } // HealthCheck probes the Apprise server and, when a config key is set, checks // that the key actually resolves to targets. A reachable server whose config // holds nothing to notify is degraded rather than down: the half we address // works, and the missing half is the operator's Apprise config. func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { start := time.Now() st, err := p.status(ctx) if err != nil { return plugins.Health{Status: plugins.StatusDown, LatencyMs: time.Since(start).Milliseconds(), Detail: shorten(err.Error())} } if !st.OK { return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), Detail: "Apprise reachable but reporting a problem with itself: " + strings.Join(st.Details, ", ")} } key, urls := p.target() switch { case key == "" && urls == "": return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), Detail: "Apprise reachable, but nothing to notify — set a config key, or the URLs to send to (unless this server supplies its own through APPRISE_STATELESS_URLS)"} case key == "": return plugins.Health{Status: plugins.StatusOK, LatencyMs: time.Since(start).Milliseconds(), Detail: fmt.Sprintf("Apprise reachable; sending stateless to %d configured URL(s)", countURLs(urls))} case !st.StatefulEnabled: return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), Detail: fmt.Sprintf("config key %q is set, but this Apprise server runs with stateful config disabled — it can only send URLs supplied per request", key)} } res, err := p.targets(ctx, key, tagAll) if err != nil { return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), Detail: fmt.Sprintf("Apprise reachable, but reading config key %q failed: %s", key, shorten(err.Error()))} } if res.Count == 0 { return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), Detail: fmt.Sprintf("Apprise reachable, but config key %q holds no notification URLs", key)} } return plugins.Health{Status: plugins.StatusOK, LatencyMs: time.Since(start).Milliseconds(), Detail: fmt.Sprintf("Apprise reachable; config key %q holds %d target(s)", key, res.Count)} } // Invoke runs a named capability. func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { switch action { case "notify": var np NotifyParams if err := decodeParams(params, &np); err != nil { return nil, err } res, err := p.notify(ctx, np) if err != nil { return nil, err } return json.Marshal(res) case "targets": var tp targetParams if err := decodeParams(params, &tp); err != nil { return nil, err } key := strings.TrimSpace(tp.Key) if key == "" { key, _ = p.target() } if key == "" { return nil, errors.New("apprise: action \"targets\" needs a config key, either in the call or in the plugin config") } if !configKeyPattern.MatchString(key) { return nil, keyError(key) } res, err := p.targets(ctx, key, strings.TrimSpace(tp.Tag)) if err != nil { return nil, err } return json.Marshal(res) case "services": var sp servicesParams if err := decodeParams(params, &sp); err != nil { return nil, err } res, err := p.services(ctx, sp.All) if err != nil { return nil, err } return json.Marshal(res) case "status": st, err := p.status(ctx) if err != nil { return nil, err } return json.Marshal(st) default: return nil, fmt.Errorf("apprise: unknown action %q", action) } } // Shutdown has nothing to release: no session outlives a call. func (p *Plugin) Shutdown(context.Context) error { return nil } // ---- notify ------------------------------------------------------------------ // NotifyParams is what the "notify" action accepts. Everything but the body has // a configured default. type NotifyParams struct { Body string `json:"body"` Title string `json:"title"` Type string `json:"type"` // info | success | warning | failure Format string `json:"format"` // text | markdown | html // Tag narrows which of a config key's URLs are notified; it means nothing to // a call that carries its own URLs. Tag string `json:"tag"` // Key and URLs override the configured destination for one call. Key string `json:"key"` URLs string `json:"urls"` // Attach is one or more URLs for the Apprise server to fetch and attach. Attach stringList `json:"attach"` } // LogEntry is one line of the delivery log Apprise returns with a notification. type LogEntry struct { Level string `json:"level"` Time string `json:"time"` Message string `json:"message"` } // NotifyResult is what a delivered notification reports back. It deliberately // carries no URLs: the log is there to say what happened, not to whom. type NotifyResult struct { OK bool `json:"ok"` Mode string `json:"mode"` // "stateful" (a config key) | "stateless" (URLs sent with the call) Key string `json:"key,omitempty"` Tag string `json:"tag,omitempty"` // Targets is how many URLs a stateless call was given. Targets int `json:"targets,omitempty"` Type string `json:"type"` Format string `json:"format"` Log []LogEntry `json:"log,omitempty"` SentAt time.Time `json:"sentAt"` } // notify sends one notification. Which endpoint it posts to is the only // structural difference between the stateful and stateless paths. func (p *Plugin) notify(ctx context.Context, np NotifyParams) (NotifyResult, error) { body := strings.TrimSpace(np.Body) if body == "" { return NotifyResult{}, errors.New("apprise: action \"notify\" requires a body") } notifyType, err := normalizeType(np.Type) if err != nil { return NotifyResult{}, err } cfg := p.snapshot() format := normalizeFormat(np.Format, cfg.format) key := strings.TrimSpace(np.Key) urls := strings.TrimSpace(np.URLs) // A call that names either destination names it alone: honouring caller URLs // while falling back to the configured key (or the reverse) would send the // message somewhere the caller did not ask for. if key == "" && urls == "" { key, urls = cfg.configKey, cfg.urls } if key != "" && !configKeyPattern.MatchString(key) { return NotifyResult{}, keyError(key) } payload := map[string]any{ "body": body, "type": notifyType, "format": format, } if title := strings.TrimSpace(np.Title); title != "" { payload["title"] = title } if len(np.Attach) > 0 { payload["attach"] = []string(np.Attach) } res := NotifyResult{Type: notifyType, Format: format} path := pathNotify if key != "" { tag := strings.TrimSpace(np.Tag) if tag == "" { tag = cfg.tag } payload["tag"] = tag path = pathNotify + "/" + key res.Mode, res.Key, res.Tag = "stateful", key, tag } else { // An empty list is still sent: a server started with APPRISE_STATELESS_URLS // supplies its own, and its 400 says what is missing more precisely than a // guess made here would. if urls != "" { payload["urls"] = urls } res.Mode, res.Targets = "stateless", countURLs(urls) } status, respBody, err := p.do(ctx, http.MethodPost, path, nil, payload) if err != nil { return NotifyResult{}, err } var doc struct { Error string `json:"error"` Details json.RawMessage `json:"details"` } _ = json.Unmarshal(respBody, &doc) entries := parseLog(doc.Details) switch status { case http.StatusOK: res.OK = true res.Log = entries res.SentAt = time.Now().UTC() return res, nil case statusNoContent: return NotifyResult{}, fmt.Errorf("apprise: nothing was notified — config key %q holds no configuration on this Apprise server", key) case statusFailedDependency: return NotifyResult{}, fmt.Errorf("apprise: %s%s", messageOr(doc.Error, "one or more notifications could not be sent"), logSuffix(entries)) default: return NotifyResult{}, fmt.Errorf("apprise: notify failed (HTTP %d): %s%s", status, messageOr(doc.Error, shorten(string(respBody))), logSuffix(entries)) } } // parseLog reads the delivery log, which arrives as an array of // ["LEVEL","timestamp","message"] triples. A shape we do not recognise is // dropped rather than guessed at — the status code and error carry the outcome. func parseLog(raw json.RawMessage) []LogEntry { if len(raw) == 0 { return nil } var rows [][]string if err := json.Unmarshal(raw, &rows); err != nil { return nil } out := make([]LogEntry, 0, len(rows)) for _, r := range rows { if len(r) < 3 { continue } out = append(out, LogEntry{Level: r[0], Time: r[1], Message: r[2]}) } if len(out) == 0 { return nil } return out } // logSuffix appends the first few problem lines to an error, which is where // Apprise says which service refused and why. func logSuffix(entries []LogEntry) string { const limit = 3 msgs := make([]string, 0, limit) for _, e := range entries { switch strings.ToUpper(e.Level) { case "DEBUG", "INFO": continue } msgs = append(msgs, e.Message) if len(msgs) == limit { break } } if len(msgs) == 0 { return "" } return " — " + shorten(strings.Join(msgs, "; ")) } // ---- targets ----------------------------------------------------------------- type targetParams struct { Key string `json:"key"` Tag string `json:"tag"` } // Target is one notification URL stored under a config key. type Target struct { ID string `json:"id"` ServiceName string `json:"serviceName"` Enabled bool `json:"enabled"` // URL is privacy-masked by the Apprise server; the tokens and passwords in a // stored URL never reach here. URL string `json:"url"` Tags []string `json:"tags"` } // TargetsResult lists what a config key would notify. type TargetsResult struct { Key string `json:"key"` Tag string `json:"tag"` Tags []string `json:"tags"` Count int `json:"count"` Targets []Target `json:"targets"` } // targets reads the URLs stored under a config key. privacy=1 is not optional: // it is what keeps downstream credentials on the Apprise side of the wire. func (p *Plugin) targets(ctx context.Context, key, tag string) (TargetsResult, error) { if tag == "" { tag = tagAll } q := url.Values{"privacy": {"1"}, "tag": {tag}} status, body, err := p.do(ctx, http.MethodGet, pathJSONURLs+key, q, nil) if err != nil { return TargetsResult{}, err } // 204 is the documented answer for a key that holds no configuration: an // empty list, not a failure. if status == statusNoContent { return TargetsResult{Key: key, Tag: tag, Targets: []Target{}}, nil } if status != http.StatusOK { return TargetsResult{}, fmt.Errorf("apprise: reading config key %q failed (HTTP %d): %s", key, status, errorOrBody(body)) } var doc struct { Tags []string `json:"tags"` Error string `json:"error"` URLs []struct { ID string `json:"id"` ServiceName string `json:"service_name"` Enabled bool `json:"enabled"` URL string `json:"url"` Tags []string `json:"tags"` } `json:"urls"` } if err := json.Unmarshal(body, &doc); err != nil { return TargetsResult{}, fmt.Errorf("apprise: config key %q returned an unreadable response: %w", key, err) } if doc.Error != "" { return TargetsResult{}, fmt.Errorf("apprise: reading config key %q: %s", key, shorten(doc.Error)) } res := TargetsResult{Key: key, Tag: tag, Tags: doc.Tags, Targets: make([]Target, 0, len(doc.URLs))} for _, u := range doc.URLs { res.Targets = append(res.Targets, Target{ ID: u.ID, ServiceName: u.ServiceName, Enabled: u.Enabled, URL: u.URL, Tags: u.Tags, }) } res.Count = len(res.Targets) return res, nil } // ---- services ---------------------------------------------------------------- type servicesParams struct { // All includes the services this Apprise build has disabled — usually because // a Python dependency they need is not installed. All bool `json:"all"` } // Service is one notification service the Apprise build supports. type Service struct { Name string `json:"name"` Category string `json:"category,omitempty"` Protocols []string `json:"protocols,omitempty"` SecureProtocols []string `json:"secureProtocols,omitempty"` AttachmentSupport bool `json:"attachmentSupport"` ServiceURL string `json:"serviceUrl,omitempty"` SetupURL string `json:"setupUrl,omitempty"` // Enabled is reported only when disabled services were asked for. Enabled *bool `json:"enabled,omitempty"` } // ServicesResult is the catalogue of what this Apprise build can notify. type ServicesResult struct { Version string `json:"version"` Count int `json:"count"` Services []Service `json:"services"` } // services lists the supported notification services. The upstream payload // carries a full configuration template per service — hundreds of KiB — so only // the identifying fields are kept. func (p *Plugin) services(ctx context.Context, all bool) (ServicesResult, error) { var q url.Values if all { q = url.Values{"all": {"yes"}} } status, body, err := p.do(ctx, http.MethodGet, pathDetails, q, nil) if err != nil { return ServicesResult{}, err } if status != http.StatusOK { return ServicesResult{}, fmt.Errorf("apprise: listing services failed (HTTP %d): %s", status, errorOrBody(body)) } var doc struct { Version string `json:"version"` Schemas []struct { ServiceName string `json:"service_name"` ServiceURL string `json:"service_url"` SetupURL string `json:"setup_url"` Category string `json:"category"` AttachmentSupport bool `json:"attachment_support"` Protocols []string `json:"protocols"` SecureProtocols []string `json:"secure_protocols"` Enabled *bool `json:"enabled"` } `json:"schemas"` } if err := json.Unmarshal(body, &doc); err != nil { return ServicesResult{}, fmt.Errorf("apprise: the service catalogue was unreadable: %w", err) } res := ServicesResult{Version: doc.Version, Services: make([]Service, 0, len(doc.Schemas))} for _, s := range doc.Schemas { res.Services = append(res.Services, Service{ Name: s.ServiceName, Category: s.Category, Protocols: s.Protocols, SecureProtocols: s.SecureProtocols, AttachmentSupport: s.AttachmentSupport, ServiceURL: s.ServiceURL, SetupURL: s.SetupURL, Enabled: s.Enabled, }) } res.Count = len(res.Services) return res, nil } // ---- status ------------------------------------------------------------------ // Status is the Apprise server's own report on itself. type Status struct { OK bool `json:"ok"` // Details is Apprise's own code list: ["OK"], or markers such as // CONFIG_PERMISSION_ISSUE / ATTACH_PERMISSION_ISSUE / STORE_PERMISSION_ISSUE. Details []string `json:"details"` StatefulEnabled bool `json:"statefulEnabled"` ConfigLock bool `json:"configLock"` AttachLock bool `json:"attachLock"` MaxAttachments int `json:"maxAttachments"` AttachSize int `json:"attachSize"` PersistentStorage bool `json:"persistentStorage"` CanWriteConfig bool `json:"canWriteConfig"` CanWriteAttach bool `json:"canWriteAttach"` } // status probes /status. The endpoint answers 417 rather than 200 when Apprise // finds a problem with itself, and its body says which — so a non-200 there is // still a parsed answer, not a transport failure. func (p *Plugin) status(ctx context.Context) (Status, error) { code, body, err := p.do(ctx, http.MethodGet, pathStatus, nil, nil) if err != nil { return Status{}, err } if code >= http.StatusInternalServerError { return Status{}, fmt.Errorf("apprise: status check failed (HTTP %d): %s", code, errorOrBody(body)) } if code != http.StatusOK && code != statusExpectationFail { return Status{}, fmt.Errorf("apprise: %s did not answer as an Apprise API (HTTP %d): %s", pathStatus, code, errorOrBody(body)) } var doc struct { ConfigLock bool `json:"config_lock"` AttachLock bool `json:"attach_lock"` StatefulEnabled bool `json:"stateful_enabled"` MaxAttachments int `json:"max_attachments"` AttachSize int `json:"attach_size"` Status struct { PersistentStorage bool `json:"persistent_storage"` CanWriteConfig bool `json:"can_write_config"` CanWriteAttach bool `json:"can_write_attach"` Details []string `json:"details"` } `json:"status"` } if err := json.Unmarshal(body, &doc); err != nil { // Releases before the JSON health body — and any proxy that strips our // Accept header — answer in plain text: a comma-joined list of the same // codes. Read it that way rather than calling a healthy server unreadable. // Stateful mode is assumed on there, since the plain-text form does not say // and a wrong "disabled" would be a warning about nothing. details := splitDetails(string(body)) if len(details) == 0 { return Status{}, fmt.Errorf("apprise: %s returned an unreadable response: %s", pathStatus, shorten(string(body))) } return Status{OK: hasOK(details), Details: details, StatefulEnabled: true}, nil } st := Status{ Details: doc.Status.Details, StatefulEnabled: doc.StatefulEnabled, ConfigLock: doc.ConfigLock, AttachLock: doc.AttachLock, MaxAttachments: doc.MaxAttachments, AttachSize: doc.AttachSize, PersistentStorage: doc.Status.PersistentStorage, CanWriteConfig: doc.Status.CanWriteConfig, CanWriteAttach: doc.Status.CanWriteAttach, } if st.Details == nil { st.Details = []string{} } st.OK = hasOK(st.Details) return st, nil } // hasOK reports whether Apprise's detail list is the all-clear. func hasOK(details []string) bool { for _, d := range details { if strings.EqualFold(strings.TrimSpace(d), "OK") { return true } } return false } // splitDetails reads the plain-text form of the detail list. The codes are // SCREAMING_SNAKE and nothing else, so anything with spaces or markup in it is // an error page and must not be mistaken for a health line. func splitDetails(s string) []string { out := []string{} for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) if part == "" || strings.ContainsAny(part, " <>\n\t") { return nil } out = append(out, part) } return out } // ---- HTTP -------------------------------------------------------------------- // config is an immutable copy of the plugin's settings for one call. type config struct { baseURL string configKey string urls string tag string format string username string password string client *http.Client } // snapshot copies the config under the lock so a call is not affected by a // concurrent Init. func (p *Plugin) snapshot() config { p.mu.Lock() defer p.mu.Unlock() c := config{ baseURL: p.baseURL, configKey: p.configKey, urls: p.urls, tag: p.tag, format: p.format, username: p.username, password: p.password, client: p.client, } // An instance the manager never called Init on still answers with sane // defaults rather than a nil-pointer panic. if c.tag == "" { c.tag = tagAll } if c.format == "" { c.format = formatText } if c.client == nil { c.client = &http.Client{Timeout: defaultTimeout} } return c } // target reports the configured destination: a config key, a URL list, or // neither. func (p *Plugin) target() (key, urls string) { p.mu.Lock() defer p.mu.Unlock() return p.configKey, p.urls } // do issues one request to the Apprise server and returns its status and body. // The JSON content type and Accept header are what make apprise-api answer in // JSON on every endpoint; without them it renders HTML. func (p *Plugin) do(ctx context.Context, method, path string, query url.Values, payload any) (int, []byte, error) { cfg := p.snapshot() if cfg.baseURL == "" { return 0, nil, errors.New("apprise: no Apprise API base URL configured — set the address of your apprise-api instance") } target := cfg.baseURL + path if len(query) > 0 { target += "?" + query.Encode() } var body io.Reader if payload != nil { buf, err := json.Marshal(payload) if err != nil { return 0, nil, fmt.Errorf("apprise: encoding the request: %w", err) } body = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, target, body) if err != nil { return 0, nil, fmt.Errorf("apprise: %s %s: %w", method, path, err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } if cfg.username != "" || cfg.password != "" { req.SetBasicAuth(cfg.username, cfg.password) } resp, err := cfg.client.Do(req) if err != nil { return 0, nil, fmt.Errorf("apprise: %s %s: %w", method, path, err) } defer drain(resp) out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { return resp.StatusCode, nil, fmt.Errorf("apprise: reading the response to %s %s: %w", method, path, err) } return resp.StatusCode, out, nil } // drain closes a response body after discarding any remainder so the connection // can be reused. func drain(resp *http.Response) { if resp != nil && resp.Body != nil { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) _ = resp.Body.Close() } } // ---- config helpers ---------------------------------------------------------- // normalizeBaseURL validates the configured address and trims it to a base a // path can be appended to. A bare host is read as http, which is what an Apprise // container on an internal network almost always is. func normalizeBaseURL(raw string) (string, error) { s := strings.TrimSpace(raw) if s == "" { return "", nil } if !strings.Contains(s, "://") { s = "http://" + s } u, err := url.Parse(s) if err != nil { return "", fmt.Errorf("apprise: %q is not a usable base URL: %w", raw, err) } switch u.Scheme { case "http", "https": default: return "", fmt.Errorf("apprise: base URL scheme %q is not supported — use http or https", u.Scheme) } if u.Host == "" { return "", fmt.Errorf("apprise: base URL %q names no host", raw) } // Keep any path prefix (an instance published under /apprise), drop the query // and fragment, and normalise away the trailing slash so paths concatenate. return strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/"), nil } // keyError is the one message every rejected config key gets, so a typo reads // the same wherever it is caught. func keyError(key string) error { return fmt.Errorf("apprise: config key %q is not usable — Apprise accepts 1–128 letters, digits, underscores and dashes", key) } // normalizeType validates a notification type, defaulting to info. func normalizeType(v string) (string, error) { t := strings.ToLower(strings.TrimSpace(v)) if t == "" { return typeInfo, nil } if !notifyTypes[t] { return "", fmt.Errorf("apprise: notification type %q is not one of info, success, warning, failure", v) } return t, nil } // normalizeFormat validates a body format, falling back to def. func normalizeFormat(v, def string) string { f := strings.ToLower(strings.TrimSpace(v)) if bodyFormats[f] { return f } return def } // parseTimeout reads the request timeout in seconds, clamped to a range that // keeps a caller's request honest at one end and a wide fan-out possible at the // other. func parseTimeout(v string) time.Duration { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n <= 0 { return defaultTimeout } d := time.Duration(n) * time.Second return max(minTimeout, min(d, maxTimeout)) } // countURLs counts the entries in an Apprise URL list, which may be separated by // commas or whitespace. func countURLs(list string) int { n := 0 for _, f := range strings.FieldsFunc(list, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) { if strings.TrimSpace(f) != "" { n++ } } return n } // decodeParams reads an action's parameters, treating an absent body as the zero // value. func decodeParams(params json.RawMessage, into any) error { if len(params) == 0 || string(params) == "null" { return nil } if err := json.Unmarshal(params, into); err != nil { return fmt.Errorf("apprise: invalid params: %w", err) } return nil } // stringList decodes either one string or an array of them, because apprise-api // accepts both spellings for attachments. type stringList []string func (s *stringList) UnmarshalJSON(b []byte) error { var one string if json.Unmarshal(b, &one) == nil { if strings.TrimSpace(one) == "" { *s = nil return nil } *s = stringList{one} return nil } var many []string if err := json.Unmarshal(b, &many); err != nil { return errors.New("attach must be a URL or a list of URLs") } *s = many return nil } // errorOrBody prefers the upstream's own error message over its raw body. func errorOrBody(body []byte) string { var doc struct { Error string `json:"error"` } if json.Unmarshal(body, &doc) == nil && doc.Error != "" { return shorten(doc.Error) } return shorten(string(body)) } // messageOr returns msg, or fallback when msg is empty. func messageOr(msg, fallback string) string { if m := strings.TrimSpace(msg); m != "" { return shorten(m) } return fallback } // shorten trims long or multiline upstream messages for health details and // errors. func shorten(s string) string { s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) const limit = 200 if len(s) > limit { return s[:limit] + "…" } return s }