266 lines
10 KiB
Go
266 lines
10 KiB
Go
// Package adminapi implements the management API: projects, API keys and the
|
|
// system endpoints. It owns the translation between storage and the wire
|
|
// format, so the store never constructs api.Error values and the api package
|
|
// never learns that SQLite exists.
|
|
//
|
|
// Every route in here is authenticated. Authorisation is expressed as
|
|
// per-route middleware rather than as checks inside the handlers, so a new
|
|
// endpoint that forgets its guard is visible in the route table instead of
|
|
// being hidden three screens down in a handler body.
|
|
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/auth"
|
|
"github.com/iceBear67/simplepages/internal/config"
|
|
"github.com/iceBear67/simplepages/internal/deploy"
|
|
"github.com/iceBear67/simplepages/internal/httpx"
|
|
"github.com/iceBear67/simplepages/internal/site"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// Route patterns. They are spelled out here rather than reusing the builders in
|
|
// api/paths.go because those escape their arguments to produce a concrete URL,
|
|
// which is the opposite of what a wildcard pattern needs. TestPathsMatchRoutes
|
|
// asserts the two stay in agreement.
|
|
const (
|
|
patProjects = api.Version + "/projects"
|
|
patProject = api.Version + "/projects/{name}"
|
|
patProjectKeys = api.Version + "/projects/{name}/keys"
|
|
patKeys = api.Version + "/keys"
|
|
patKey = api.Version + "/keys/{key_id}"
|
|
patWhoAmI = api.Version + "/whoami"
|
|
patSystemInfo = api.Version + "/system/info"
|
|
patDeployments = patProject + "/deployments"
|
|
patDeployment = patDeployments + "/{id}"
|
|
patManifest = patDeployment + "/manifest"
|
|
patFinalize = patDeployment + "/finalize"
|
|
patActivate = patDeployment + "/activate"
|
|
patBlob = api.Version + "/blobs/{digest}"
|
|
patGC = api.Version + "/gc"
|
|
patFsck = api.Version + "/fsck"
|
|
patCatchAll = api.Version + "/"
|
|
)
|
|
|
|
// Hooks let later milestones react to changes without adminapi taking a
|
|
// dependency on the site registry, the webroot or the deployment service.
|
|
// Every hook is optional and runs after the database transaction has committed,
|
|
// so a hook failure can never leave the store and the caller disagreeing about
|
|
// whether the change happened.
|
|
type Hooks struct {
|
|
// ProjectChanged fires after a project is created or reconfigured.
|
|
ProjectChanged func(ctx context.Context, p *store.Project)
|
|
// ProjectDeleted fires after a project and its cascade are gone.
|
|
ProjectDeleted func(ctx context.Context, p *store.Project)
|
|
}
|
|
|
|
// Server holds the collaborators the management handlers need.
|
|
type Server struct {
|
|
DB *store.DB
|
|
Auth *auth.Middleware
|
|
Deploy *deploy.Service
|
|
Log *slog.Logger
|
|
|
|
// Limits are the server-wide ceilings a per-project setting may not exceed.
|
|
Limits config.Limits
|
|
|
|
// Resolver maps a project name to its row id for the ownership guard. When
|
|
// nil the database is consulted directly; the server substitutes the site
|
|
// registry so the hot deployment endpoints do not pay a query for it.
|
|
Resolver auth.ProjectResolver
|
|
|
|
// Sites is the serving registry, consulted to report what a project is
|
|
// currently serving without a query per project. Nil in tests that do not
|
|
// wire up a serving layer, in which case active_deployment is omitted.
|
|
Sites *site.Registry
|
|
|
|
// BaseURL is the public origin serving site content, without a trailing
|
|
// slash. Empty until the operator configures one, in which case responses
|
|
// simply omit the url field.
|
|
BaseURL string
|
|
|
|
// LinkMode reports how deployment trees are assembled. Filled in from the
|
|
// CAS in M2.
|
|
LinkMode string
|
|
|
|
Started time.Time
|
|
Hooks Hooks
|
|
}
|
|
|
|
// Register mounts the management routes on mux.
|
|
//
|
|
// It takes a mux rather than returning a handler so the caller can put the
|
|
// health probes on the same listener without them inheriting authentication.
|
|
func (s *Server) Register(mux *http.ServeMux) {
|
|
admin := auth.RequireAdmin(s.Log)
|
|
|
|
resolver := s.Resolver
|
|
if resolver == nil {
|
|
resolver = auth.ResolverFunc(s.resolveProject)
|
|
}
|
|
// owner admits admins and the project's own key. Note it reads the {name}
|
|
// wildcard, so it is only valid on patterns that declare one.
|
|
owner := auth.RequireProject("name", resolver, s.Log)
|
|
|
|
s.route(mux, "GET "+patWhoAmI, s.whoami)
|
|
s.route(mux, "GET "+patSystemInfo, s.systemInfo, admin)
|
|
|
|
s.route(mux, "POST "+patProjects, s.createProject, admin)
|
|
s.route(mux, "GET "+patProjects, s.listProjects, admin)
|
|
s.route(mux, "GET "+patProject, s.getProject, owner)
|
|
s.route(mux, "PATCH "+patProject, s.patchProject, admin)
|
|
s.route(mux, "DELETE "+patProject, s.deleteProject, admin)
|
|
|
|
s.route(mux, "POST "+patKeys, s.createAdminKey, admin)
|
|
s.route(mux, "GET "+patKeys, s.listKeys, admin)
|
|
s.route(mux, "DELETE "+patKey, s.revokeKey)
|
|
s.route(mux, "POST "+patProjectKeys, s.createProjectKey, admin)
|
|
s.route(mux, "GET "+patProjectKeys, s.listProjectKeys, owner)
|
|
|
|
s.route(mux, "POST "+patDeployments, s.createDeployment, owner)
|
|
s.route(mux, "GET "+patDeployments, s.listDeployments, owner)
|
|
s.route(mux, "GET "+patDeployment, s.getDeployment, owner)
|
|
s.route(mux, "DELETE "+patDeployment, s.deleteDeployment, owner)
|
|
s.route(mux, "POST "+patManifest, s.setManifest, owner)
|
|
s.route(mux, "POST "+patFinalize, s.finalize, owner)
|
|
s.route(mux, "POST "+patActivate, s.activate, owner)
|
|
|
|
s.route(mux, "POST "+patGC, s.gc, admin)
|
|
s.route(mux, "POST "+patFsck, s.fsck, admin)
|
|
// Blob uploads carry no project in the path and so have no owner to check
|
|
// against. See putBlob for why authentication alone is the right guard.
|
|
s.route(mux, "PUT "+patBlob, s.putBlob)
|
|
|
|
// A known path reached with an unregistered method must answer 405 and say
|
|
// what it does accept. ServeMux would do that by itself, but only when no
|
|
// pattern matches at all — and the catch-all below matches everything under
|
|
// the prefix, which would turn every method mismatch into a 404. These
|
|
// method-agnostic patterns are less specific than the ones above, so they
|
|
// only see requests the real routes rejected.
|
|
s.methods(mux, patProjects, http.MethodGet, http.MethodPost)
|
|
s.methods(mux, patProject, http.MethodGet, http.MethodPatch, http.MethodDelete)
|
|
s.methods(mux, patProjectKeys, http.MethodGet, http.MethodPost)
|
|
s.methods(mux, patKeys, http.MethodGet, http.MethodPost)
|
|
s.methods(mux, patKey, http.MethodDelete)
|
|
s.methods(mux, patWhoAmI, http.MethodGet)
|
|
s.methods(mux, patSystemInfo, http.MethodGet)
|
|
s.methods(mux, patDeployments, http.MethodGet, http.MethodPost)
|
|
s.methods(mux, patDeployment, http.MethodGet, http.MethodDelete)
|
|
s.methods(mux, patManifest, http.MethodPost)
|
|
s.methods(mux, patFinalize, http.MethodPost)
|
|
s.methods(mux, patActivate, http.MethodPost)
|
|
s.methods(mux, patBlob, http.MethodPut)
|
|
s.methods(mux, patGC, http.MethodPost)
|
|
s.methods(mux, patFsck, http.MethodPost)
|
|
|
|
// Unknown paths under the API prefix answer with the same envelope as every
|
|
// other failure instead of net/http's plain-text 404, so a client has one
|
|
// shape of error to parse. It sits behind authentication too: an
|
|
// unauthenticated caller learns nothing about which endpoints exist.
|
|
s.route(mux, patCatchAll, func(w http.ResponseWriter, r *http.Request) error {
|
|
return api.Errorf(api.CodeNotFound, "no such endpoint")
|
|
})
|
|
}
|
|
|
|
// methods registers the 405 fallback for a path that exists under other verbs.
|
|
func (s *Server) methods(mux *http.ServeMux, pattern string, allow ...string) {
|
|
list := strings.Join(allow, ", ")
|
|
s.route(mux, pattern, func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Allow", list)
|
|
return api.Errorf(api.CodeMethodNotAllowed, "method not allowed; this path accepts %s", list)
|
|
})
|
|
}
|
|
|
|
// handlerFunc is an http.HandlerFunc that may fail. Returning the error instead
|
|
// of writing it means a handler cannot accidentally answer twice, and the
|
|
// envelope is rendered in exactly one place.
|
|
type handlerFunc func(w http.ResponseWriter, r *http.Request) error
|
|
|
|
func (s *Server) wrap(h handlerFunc) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := h(w, r); err != nil {
|
|
httpx.WriteError(w, r, s.Log, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// route registers pattern behind authentication plus the given guards, which
|
|
// run outermost first.
|
|
func (s *Server) route(mux *http.ServeMux, pattern string, h handlerFunc, guards ...func(http.Handler) http.Handler) {
|
|
var wrapped http.Handler = s.wrap(h)
|
|
for i := len(guards) - 1; i >= 0; i-- {
|
|
wrapped = guards[i](wrapped)
|
|
}
|
|
mux.Handle(pattern, s.Auth.Authenticate(wrapped))
|
|
}
|
|
|
|
// resolveProject is the fallback ProjectResolver used until the site registry
|
|
// exists.
|
|
func (s *Server) resolveProject(ctx context.Context, name string) (int64, error) {
|
|
p, err := s.DB.ProjectByName(ctx, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return p.ID, nil
|
|
}
|
|
|
|
// identity returns the caller. Authenticate guarantees one is present, so its
|
|
// absence is a routing bug rather than a client error.
|
|
func (s *Server) identity(r *http.Request) (*auth.Identity, error) {
|
|
id, ok := auth.IdentityFrom(r.Context())
|
|
if !ok {
|
|
return nil, api.Errorf(api.CodeInternal, "handler reached without authentication")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Server) maxJSON() int64 {
|
|
if s.Limits.MaxJSONBytes > 0 {
|
|
return s.Limits.MaxJSONBytes
|
|
}
|
|
return 1 << 20
|
|
}
|
|
|
|
// projectNames maps row ids to names for the key listings, which store an id
|
|
// but report a name. One query beats one lookup per key.
|
|
func (s *Server) projectNames(ctx context.Context) (map[int64]string, error) {
|
|
ps, err := s.DB.AllProjects(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := make(map[int64]string, len(ps))
|
|
for _, p := range ps {
|
|
m[p.ID] = p.Name
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// intQuery reads a bounded integer query parameter.
|
|
func intQuery(r *http.Request, name string, def, min, max int) (int, error) {
|
|
raw := r.URL.Query().Get(name)
|
|
if raw == "" {
|
|
return def, nil
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, api.Errorf(api.CodeBadRequest, "%s must be an integer", name)
|
|
}
|
|
if v < min || v > max {
|
|
return 0, api.Errorf(api.CodeBadRequest, "%s must be between %d and %d", name, min, max)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// noStore marks a response that must not be written to any cache. Used for the
|
|
// one response in the API that carries a credential.
|
|
func noStore(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
}
|