// Miniflux API client for fluxrec. // // Typed over exactly what fluxrec needs: GET /v1/entries with the filter // subset used by export (starred/status/changed_after) and serve // (after_entry_id). Auth is the X-Auth-Token header; credentials come from // the environment (MINIFLUX_* with MFLUX_* fallback, so existing mflux // exports work). Read-only by construction: there are no mutation methods. package miniflux import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "time" ) // Env var names, primary then fallback (matches mflux's exports). var ( urlVars = [2]string{"MINIFLUX_URL", "MFLUX_URL"} tokenVars = [2]string{"MINIFLUX_TOKEN", "MFLUX_TOKEN"} ) // Config is resolved client configuration. type Config struct { BaseURL string Token string } // ConfigFromEnv resolves MINIFLUX_URL/MINIFLUX_TOKEN, falling back to // MFLUX_URL/MFLUX_TOKEN. Errors name both variables so a missing export is // obvious from the message alone. func ConfigFromEnv() (Config, error) { baseURL := firstEnv(urlVars) if baseURL == "" { return Config{}, fmt.Errorf("miniflux URL not set: export %s (or %s)", urlVars[0], urlVars[1]) } token := firstEnv(tokenVars) if token == "" { return Config{}, fmt.Errorf("miniflux token not set: export %s (or %s)", tokenVars[0], tokenVars[1]) } return Config{BaseURL: baseURL, Token: token}, nil } func firstEnv(names [2]string) string { for _, n := range names { if v := os.Getenv(n); v != "" { return v } } return "" } // Client is a read-only Miniflux API client. type Client struct { baseURL string token string httpClient *http.Client } // NewClient builds a Client with a sane default HTTP timeout. func NewClient(cfg Config) *Client { return &Client{ baseURL: cfg.BaseURL, token: cfg.Token, httpClient: &http.Client{Timeout: 60 * time.Second}, } } // NewClientWithHTTP is NewClient with a caller-provided http.Client // (tests point it at httptest servers). func NewClientWithHTTP(cfg Config, hc *http.Client) *Client { return &Client{baseURL: cfg.BaseURL, token: cfg.Token, httpClient: hc} } // Feed is the subset of a Miniflux feed fluxrec needs. type Feed struct { ID int64 `json:"id"` Title string `json:"title"` } // Entry is the subset of a Miniflux entry fluxrec needs. JSON field names // match the Miniflux API verbatim so decoding is a straight mirror. type Entry struct { ID int64 `json:"id"` URL string `json:"url"` Title string `json:"title"` Content string `json:"content"` Status string `json:"status"` // "read" | "unread" | "removed" Starred bool `json:"starred"` PublishedAt time.Time `json:"published_at"` ChangedAt time.Time `json:"changed_at"` Feed Feed `json:"feed"` } // Filters selects which entries GET /v1/entries returns. Zero values mean // "no filter" for every field; Starred is a *bool so unset vs. false stays // distinguishable (we only ever filter starred=true, but keep the type // honest). type Filters struct { Starred *bool // only starred entries when true Status string // "read" | "unread"; "" = any ChangedAfter int64 // unix timestamp; incremental export cursor PublishedAfter int64 // unix timestamp; only entries published after this AfterEntryID int64 // serve-lane poll cursor Direction string // "asc" | "desc"; "" = server default Limit int // page size; 0 = server default Offset int // paging offset } // EntrySet is one page of GET /v1/entries results. Total is the full // matching count across pages, which offsets paging loops can stop against. type EntrySet struct { Total int `json:"total"` Entries []Entry `json:"entries"` } // Entries calls GET /v1/entries with the given filters. func (c *Client) Entries(f Filters) (*EntrySet, error) { body, err := c.fetchEntries(f) if err != nil { return nil, err } var set EntrySet if err := json.Unmarshal(body, &set); err != nil { return nil, fmt.Errorf("decoding /v1/entries response: %w", err) } return &set, nil } // RawEntries returns the /v1/entries response body verbatim. Exists for // fixture capture (`export --dump-raw`); the typed path stays Entries. func (c *Client) RawEntries(f Filters) ([]byte, error) { return c.fetchEntries(f) } // fetchEntries issues the GET and returns the body on HTTP 200. func (c *Client) fetchEntries(f Filters) ([]byte, error) { u := entriesURL(c.baseURL, f) req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) } req.Header.Set("X-Auth-Token", c.token) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("GET %s: %w", u, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return nil, fmt.Errorf("GET %s: status %d: %s", u, resp.StatusCode, body) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response from %s: %w", u, err) } return body, nil } // entriesURL builds /v1/entries?... honoring only the non-zero filters. func entriesURL(base string, f Filters) string { q := url.Values{} if f.Starred != nil { q.Set("starred", strconv.FormatBool(*f.Starred)) } if f.Status != "" { q.Set("status", f.Status) } if f.ChangedAfter > 0 { q.Set("changed_after", strconv.FormatInt(f.ChangedAfter, 10)) } if f.PublishedAfter > 0 { q.Set("published_after", strconv.FormatInt(f.PublishedAfter, 10)) } if f.AfterEntryID > 0 { q.Set("after_entry_id", strconv.FormatInt(f.AfterEntryID, 10)) } if f.Direction != "" { q.Set("direction", f.Direction) } if f.Limit > 0 { q.Set("limit", strconv.Itoa(f.Limit)) } if f.Offset > 0 { q.Set("offset", strconv.Itoa(f.Offset)) } u := base + "/v1/entries" if len(q) > 0 { u += "?" + q.Encode() } return u }