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 }