Files
2026-08-15 07:13:00 +00:00

378 lines
11 KiB
Go

package adminapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/pathutil"
"github.com/iceBear67/simplepages/internal/store"
)
// Caps on the deployment metadata a CI job may attach. Generous enough for a
// commit sha, a branch, a run URL and an actor; small enough that the column
// cannot become a place to store things.
const (
maxMetaEntries = 32
maxMetaKeyLen = 64
maxMetaValueLen = 512
)
// createDeployment handles POST /api/v1/projects/{name}/deployments.
func (s *Server) createDeployment(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
// The body is optional: a deployment that carries no metadata is a bare POST,
// which is what `curl -X POST` and any shell-driven CI job send.
var req api.CreateDeploymentRequest
if r.ContentLength != 0 {
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
}
if err := checkMeta(req.Meta); err != nil {
return err
}
id, err := s.identity(r)
if err != nil {
return err
}
dep, err := s.Deploy.Create(r.Context(), p, id.KeyID, req.Meta)
if err != nil {
return err
}
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
w.Header().Set("Location", api.PathDeployment(p.Name, dep.PublicID))
httpx.WriteJSON(w, http.StatusCreated, deploymentOf(p, dep))
return nil
}
// setManifest handles POST .../deployments/{id}/manifest.
//
// The body is walked one entry at a time rather than unmarshalled whole: a
// 50,000-file manifest would otherwise be resident twice over, once as raw JSON
// and once as structs, for no benefit — nothing here needs to see the entries
// together.
func (s *Server) setManifest(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
r.Body = http.MaxBytesReader(w, r.Body, s.maxManifest())
var (
files []store.FileRow
paths = pathutil.NewSet(0)
unique = make(map[cas.Digest]struct{})
totalBytes int64
)
err = decodeManifest(json.NewDecoder(r.Body), func(f api.FileEntry) error {
if err := paths.Add(f.Path); err != nil {
return api.Errorf(api.CodeInvalidPath, "%s", err)
}
digest, err := cas.ParseDigest(f.Digest)
if err != nil {
return api.Errorf(api.CodeBadRequest, "%s: %s", f.Path, err)
}
if f.Size < 0 {
return api.Errorf(api.CodeBadRequest, "%s: size must not be negative", f.Path)
}
if f.Size > p.MaxFileBytes {
return api.Errorf(api.CodeLimitExceeded,
"%s is %d bytes; this project allows at most %d per file", f.Path, f.Size, p.MaxFileBytes)
}
if len(files) >= p.MaxFiles {
return api.Errorf(api.CodeLimitExceeded,
"a deployment of this project may contain at most %d files", p.MaxFiles)
}
totalBytes += f.Size
if totalBytes > p.MaxTotalBytes {
return api.Errorf(api.CodeLimitExceeded,
"a deployment of this project may total at most %d bytes", p.MaxTotalBytes)
}
unique[digest] = struct{}{}
files = append(files, store.FileRow{Path: f.Path, Digest: digest, Size: f.Size})
return nil
})
if err != nil {
return err
}
if len(files) == 0 {
return api.Errorf(api.CodeBadRequest, "a manifest must list at least one file")
}
missing, missingBytes, err := s.Deploy.SetManifest(r.Context(), dep, files)
if err != nil {
return err
}
out := api.ManifestResponse{
Missing: make([]string, 0, len(missing)),
MissingBytes: missingBytes,
Have: len(unique) - len(missing),
FileCount: len(files),
TotalBytes: totalBytes,
}
for _, d := range missing {
out.Missing = append(out.Missing, d.String())
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// finalize handles POST .../deployments/{id}/finalize.
func (s *Server) finalize(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := httpx.NoBody(r); err != nil {
return err
}
dep, err = s.Deploy.Finalize(r.Context(), p, dep)
if err != nil {
return err
}
httpx.WriteJSON(w, http.StatusOK, deploymentOf(p, dep))
return nil
}
// activate handles POST .../deployments/{id}/activate.
//
// This is also the rollback endpoint: activating an older ready deployment is
// the same operation, and costs the same single pointer store.
func (s *Server) activate(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := httpx.NoBody(r); err != nil {
return err
}
dep, err = s.Deploy.Activate(r.Context(), p, dep)
if err != nil {
return err
}
out := deploymentOf(p, dep)
if s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// listDeployments handles GET .../deployments.
func (s *Server) listDeployments(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
limit, err := intQuery(r, "limit", 100, 1, 500)
if err != nil {
return err
}
state, err := parseState(r.URL.Query().Get("state"))
if err != nil {
return err
}
deps, next, err := s.DB.ListDeployments(r.Context(), p.ID, state, limit, r.URL.Query().Get("cursor"))
if err != nil {
return err
}
out := api.DeploymentList{Deployments: make([]api.Deployment, 0, len(deps)), NextCursor: next}
for _, dep := range deps {
out.Deployments = append(out.Deployments, deploymentOf(p, dep))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// getDeployment handles GET .../deployments/{id}, with ?files=true adding the
// manifest.
//
// The manifest is opt-in because it can be fifty thousand entries: a listing
// that carried it by default would make `pages deployment list` unusable on a
// large site for information nobody asked for.
func (s *Server) getDeployment(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
out := deploymentOf(p, dep)
if r.URL.Query().Get("files") == "true" {
files, err := s.DB.DeploymentFiles(r.Context(), dep.ID)
if err != nil {
return err
}
out.Files = make([]api.FileEntry, 0, len(files))
for _, f := range files {
out.Files = append(out.Files, api.FileEntry{
Path: f.Path, Digest: f.Digest.String(), Size: f.Size,
})
}
}
if dep.Active && s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// deleteDeployment handles DELETE .../deployments/{id}. Deleting the active one
// is a 409: the client is expected to activate something else first, so that
// the project is never left with nothing to serve by accident.
func (s *Server) deleteDeployment(w http.ResponseWriter, r *http.Request) error {
if err := httpx.NoBody(r); err != nil {
return err
}
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := s.Deploy.Delete(r.Context(), p, dep); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// parseState validates the ?state= filter. The empty string means no filter;
// anything else must be a state that exists, so a typo is a 400 rather than a
// silently empty list.
func parseState(raw string) (store.State, error) {
switch raw {
case "":
return "", nil
case api.StatePending, api.StateUploading, api.StateReady, api.StateFailed, api.StateDeleting:
return store.State(raw), nil
}
return "", api.Errorf(api.CodeBadRequest, "unknown deployment state %q", raw)
}
// deployment resolves both the {name} and {id} wildcards.
//
// The deployment is looked up within the project, never on its own: a
// project-scoped caller that guessed another project's deployment id gets the
// same "no such deployment" as one that guessed a nonexistent one.
func (s *Server) deployment(r *http.Request) (*store.Project, *store.Deployment, error) {
p, err := s.project(r)
if err != nil {
return nil, nil, err
}
dep, err := s.DB.DeploymentByPublicID(r.Context(), p.ID, r.PathValue("id"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil, api.Errorf(api.CodeNotFound, "no such deployment")
}
return nil, nil, err
}
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
return p, dep, nil
}
// decodeManifest streams {"files":[...]} and calls onFile for each entry.
func decodeManifest(dec *json.Decoder, onFile func(api.FileEntry) error) error {
if err := expectDelim(dec, '{', "manifest must be a JSON object"); err != nil {
return err
}
seen := false
for dec.More() {
tok, err := dec.Token()
if err != nil {
return badJSON(err)
}
key, _ := tok.(string)
if key != "files" {
// Unknown keys are skipped rather than rejected: a newer CLI may send
// a field this server predates, and the body is bounded anyway.
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return badJSON(err)
}
continue
}
seen = true
if err := expectDelim(dec, '[', "files must be an array"); err != nil {
return err
}
for dec.More() {
var f api.FileEntry
if err := dec.Decode(&f); err != nil {
return badJSON(err)
}
if err := onFile(f); err != nil {
return err
}
}
if _, err := dec.Token(); err != nil { // closing ]
return badJSON(err)
}
}
if _, err := dec.Token(); err != nil { // closing }
return badJSON(err)
}
if !seen {
return api.Errorf(api.CodeBadRequest, "manifest is missing the files array")
}
return nil
}
func expectDelim(dec *json.Decoder, want json.Delim, msg string) error {
tok, err := dec.Token()
if err != nil {
return badJSON(err)
}
if d, ok := tok.(json.Delim); !ok || d != want {
return api.Errorf(api.CodeBadRequest, "%s", msg)
}
return nil
}
// badJSON turns a decoder failure into a client error, keeping the body-size
// rejection distinguishable from a syntax one.
func badJSON(err error) error {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return api.Errorf(api.CodePayloadTooLarge, "manifest exceeds %d bytes", maxErr.Limit)
}
var syn *json.SyntaxError
if errors.As(err, &syn) {
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
}
return api.Errorf(api.CodeBadRequest, "malformed manifest")
}
func checkMeta(meta map[string]string) error {
if len(meta) > maxMetaEntries {
return api.Errorf(api.CodeBadRequest, "meta may hold at most %d entries", maxMetaEntries)
}
for k, v := range meta {
if k == "" {
return api.Errorf(api.CodeBadRequest, "meta keys must not be empty")
}
if err := checkText("meta key", k, maxMetaKeyLen); err != nil {
return err
}
if err := checkText("meta value of "+k, v, maxMetaValueLen); err != nil {
return err
}
}
return nil
}
func (s *Server) maxManifest() int64 {
if s.Limits.MaxManifestBytes > 0 {
return s.Limits.MaxManifestBytes
}
return 64 << 20
}