package kiosk import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/fetchguard" ) // DefaultRegistryURL is the registry an application uses when its config // has no plugins.registry_url. const DefaultRegistryURL = "https://summercms.io" // registryURLKey is the config key naming the registry origin: the file // config/plugins.yaml, key registry_url, or SUMMER_PLUGINS__REGISTRY_URL. const registryURLKey = "plugins.registry_url" // Limits of every registry call: a registry answer is small JSON, so a // larger body or a slow server is an error, not something to wait for. const ( registryMaxBytes = 4 << 20 registryTimeout = 10 * time.Second ) var ( // ErrRegistryDisabled is returned by NewClient for an empty base URL: // plugins.registry_url is set to "" and registry calls are off. ErrRegistryDisabled = errors.New("the plugin registry is disabled (plugins.registry_url is empty)") // ErrNotFound is wrapped by the errors of a Client call the registry // answered with 404: no plugin has that ID. ErrNotFound = errors.New("not in the registry") ) // RegistryURL returns the registry origin configured in cfg: the // DefaultRegistryURL when plugins.registry_url is absent (or cfg is nil), // the configured value without a trailing slash otherwise. An empty // result means registry calls are disabled. func RegistryURL(cfg *compass.Config) string { if cfg == nil || !cfg.Has(registryURLKey) { return DefaultRegistryURL } return strings.TrimRight(strings.TrimSpace(cfg.String(registryURLKey)), "/") } // Client reads the registry API v1 of one registry origin. Every request // goes through a fetchguard client in TrustedMode (the origin is operator // configuration) with a 4 MiB body cap, a 10 second timeout and no // redirects, so the base URL must be the final origin. type Client struct { base string http *fetchguard.Client } // NewClient builds a Client for the registry at baseURL, an http or https // origin without user info, query or fragment, for example // https://summercms.io. An empty baseURL returns ErrRegistryDisabled. cfg // may be nil. func NewClient(baseURL string, cfg *compass.Config) (*Client, error) { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" { return nil, ErrRegistryDisabled } u, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("kiosk: registry URL %q: %w", baseURL, err) } switch { case u.Scheme != "http" && u.Scheme != "https": return nil, fmt.Errorf("kiosk: registry URL %q must start with https:// or http://", baseURL) case u.Host == "": return nil, fmt.Errorf("kiosk: registry URL %q has no host", baseURL) case u.User != nil: return nil, fmt.Errorf("kiosk: registry URL must not carry user info") case u.RawQuery != "" || u.Fragment != "" || u.ForceQuery: return nil, fmt.Errorf("kiosk: registry URL %q must not have a query or fragment", baseURL) } hc, err := fetchguard.NewClient(fetchguard.Policy{ Mode: fetchguard.TrustedMode, MaxBytes: registryMaxBytes, Timeout: registryTimeout, }, cfg) if err != nil { return nil, fmt.Errorf("kiosk: registry client: %w", err) } return &Client{base: baseURL, http: hc}, nil } // BaseURL returns the registry origin the client calls. func (c *Client) BaseURL() string { return c.base } // List returns the registry's plugins matching the search q and the // category (both optional). func (c *Client) List(ctx context.Context, q, category string) (ListResponse, error) { query := url.Values{} if q != "" { query.Set("q", q) } if category != "" { query.Set("category", category) } path := "/plugins" if len(query) > 0 { path += "?" + query.Encode() } var resp ListResponse err := c.get(ctx, path, "", &resp) return resp, err } // Plugin returns one plugin with its README and the requirements of its // latest release. An unknown ID gives an error wrapping ErrNotFound. func (c *Client) Plugin(ctx context.Context, id string) (RegistryPluginDetail, error) { var resp DetailResponse err := c.get(ctx, "/plugins/"+url.PathEscape(id), id, &resp) return resp.Data, err } // Versions returns every release of a plugin, newest first. An unknown ID // gives an error wrapping ErrNotFound. func (c *Client) Versions(ctx context.Context, id string) ([]RegistryVersion, error) { var resp VersionsResponse err := c.get(ctx, "/plugins/"+url.PathEscape(id)+"/versions", id, &resp) return resp.Data, err } func (c *Client) get(ctx context.Context, path, id string, out any) error { header := http.Header{} header.Set("Accept", "application/json") res, err := c.http.Get(ctx, c.base+RegistryAPIPath+path, header) if err != nil { return fmt.Errorf("registry %s: %w", c.base, err) } switch { case res.StatusCode == http.StatusNotFound: if id != "" { return fmt.Errorf("%s is %w at %s", id, ErrNotFound, c.base) } return fmt.Errorf("registry %s: %w", c.base, ErrNotFound) case res.StatusCode >= 300 && res.StatusCode < 400: return fmt.Errorf("registry %s answered with a redirect to %q; set plugins.registry_url to the final origin", c.base, res.Header.Get("Location")) case res.StatusCode < 200 || res.StatusCode >= 300: var e ErrorResponse if json.Unmarshal(res.Body, &e) == nil && e.Error.Code != "" { return fmt.Errorf("registry %s: HTTP %d %s: %s", c.base, res.StatusCode, e.Error.Code, e.Error.Message) } return fmt.Errorf("registry %s: HTTP %d", c.base, res.StatusCode) } if err := json.Unmarshal(res.Body, out); err != nil { return fmt.Errorf("registry %s: decode %s: %w", c.base, path, err) } return nil }