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