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

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