- kiosk.Client reads the registry API v1 over fetchguard (trusted mode, 4 MiB, 10 s, no redirects); kiosk.RegistryURL reads plugins.registry_url (default https://summercms.io, empty disables) - build.PlanInstall/ApplyInstall pick the newest release the effective framework satisfies (or a pin), validate registry strings, go get it and append it to summer.yaml - summer.yaml edits (plugin:add and plugin:install) keep comments and blank lines - TestInstallSmoke/module installs from a file:// GOPROXY and builds
162 lines
5.6 KiB
Go
162 lines
5.6 KiB
Go
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
|
|
}
|