Files
pages/internal/adminapi/server.go
T
2026-08-15 07:13:00 +00:00

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")
}