170 lines
4.8 KiB
Go
170 lines
4.8 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/httpx"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// createProject handles POST /api/v1/projects.
|
|
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) error {
|
|
var req api.CreateProjectRequest
|
|
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
|
return err
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if err := checkProjectName(name); err != nil {
|
|
return err
|
|
}
|
|
|
|
p := store.DefaultProject(name)
|
|
s.clampDefaults(p)
|
|
if err := s.applyPatch(p, req.Patch); err != nil {
|
|
return err
|
|
}
|
|
if err := s.DB.CreateProject(r.Context(), p); err != nil {
|
|
if errors.Is(err, store.ErrExists) {
|
|
return api.Errorf(api.CodeProjectExists, "project %q already exists", name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
httpx.LogAttr(r.Context(), "project", name)
|
|
if s.Hooks.ProjectChanged != nil {
|
|
s.Hooks.ProjectChanged(r.Context(), p)
|
|
}
|
|
w.Header().Set("Location", api.PathProject(name))
|
|
httpx.WriteJSON(w, http.StatusCreated, s.projectOf(p))
|
|
return nil
|
|
}
|
|
|
|
// listProjects handles GET /api/v1/projects.
|
|
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) error {
|
|
limit, err := intQuery(r, "limit", 100, 1, 500)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ps, next, err := s.DB.ListProjects(r.Context(), limit, r.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := api.ProjectList{Projects: make([]api.Project, 0, len(ps)), NextCursor: next}
|
|
for _, p := range ps {
|
|
out.Projects = append(out.Projects, s.projectOf(p))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// getProject handles GET /api/v1/projects/{name}.
|
|
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) error {
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, s.projectOf(p))
|
|
return nil
|
|
}
|
|
|
|
// patchProject handles PATCH /api/v1/projects/{name}.
|
|
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) error {
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var patch api.ProjectPatch
|
|
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &patch); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply to a copy so a rejected field cannot leave the caller looking at a
|
|
// partly-updated project in the error response.
|
|
updated := *p
|
|
if err := s.applyPatch(&updated, &patch); err != nil {
|
|
return err
|
|
}
|
|
if err := s.DB.UpdateProject(r.Context(), &updated); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return api.Errorf(api.CodeNotFound, "no such project")
|
|
}
|
|
return err
|
|
}
|
|
|
|
if s.Hooks.ProjectChanged != nil {
|
|
s.Hooks.ProjectChanged(r.Context(), &updated)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, s.projectOf(&updated))
|
|
return nil
|
|
}
|
|
|
|
// deleteProject handles DELETE /api/v1/projects/{name}.
|
|
//
|
|
// The cascade takes the project's keys, deployments and manifest rows with it;
|
|
// blob refcounts fall as the manifest rows go, so the content is reclaimed by
|
|
// the next GC pass rather than synchronously here.
|
|
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) error {
|
|
if err := httpx.NoBody(r); err != nil {
|
|
return err
|
|
}
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.DB.DeleteProject(r.Context(), p.ID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return api.Errorf(api.CodeNotFound, "no such project")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// The project's keys went with it. Nothing else invalidates the auth cache,
|
|
// so without this a deleted project's key would keep working for up to the
|
|
// cache TTL.
|
|
s.Auth.V.Invalidate()
|
|
if s.Hooks.ProjectDeleted != nil {
|
|
s.Hooks.ProjectDeleted(r.Context(), p)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
// project loads the project named by the {name} wildcard.
|
|
//
|
|
// The ownership guard already resolved the same name; looking it up again costs
|
|
// one indexed read on a cold path and keeps the guard free of any obligation to
|
|
// hand state to the handler.
|
|
func (s *Server) project(r *http.Request) (*store.Project, error) {
|
|
name := r.PathValue("name")
|
|
if err := checkProjectName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := s.DB.ProjectByName(r.Context(), name)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, api.Errorf(api.CodeNotFound, "no such project")
|
|
}
|
|
return nil, err
|
|
}
|
|
httpx.LogAttr(r.Context(), "project", p.Name)
|
|
return p, nil
|
|
}
|
|
|
|
// clampDefaults lowers the schema's per-project defaults to the server-wide
|
|
// ceilings, so a new project on a server with tightened limits does not start
|
|
// out already over them.
|
|
func (s *Server) clampDefaults(p *store.Project) {
|
|
if n := s.Limits.MaxManifestFiles; n > 0 && p.MaxFiles > n {
|
|
p.MaxFiles = n
|
|
}
|
|
if n := s.Limits.MaxFileBytes; n > 0 && p.MaxFileBytes > n {
|
|
p.MaxFileBytes = n
|
|
}
|
|
if p.MaxTotalBytes < p.MaxFileBytes {
|
|
p.MaxTotalBytes = p.MaxFileBytes
|
|
}
|
|
}
|