package handler import ( "encoding/json" "net/http" "os" "path/filepath" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // writeError is the JSON envelope used by admin-write endpoints to // surface zddc.ValidateFile field errors back to the caller. type writeError struct { Errors []zddc.FieldError `json:"errors"` } // projectCreateRequest is the wire shape for POST /.profile/projects. // // All fields except parent and name are optional. The ACL/admins/title // fields are bundled into a starter .zddc only if at least one is supplied; // otherwise the new directory is left empty and inherits ACL from its // ancestors. type projectCreateRequest struct { Parent string `json:"parent"` Name string `json:"name"` Title string `json:"title,omitempty"` ACL *zddc.ACLRules `json:"acl,omitempty"` Admins []string `json:"admins,omitempty"` } // projectCreateResponse is the success payload. type projectCreateResponse struct { Path string `json:"path"` URL string `json:"url"` } // serveProfileProjectsCreate handles POST /.profile/projects. // // Authorization is delegated to CanEditZddc on the prospective new // directory: the caller must have authority that would let them write a // .zddc at that location (super-admin via root admins, or a strict-ancestor // admin grant). Non-authorized callers receive 404 to keep this endpoint's // existence hidden alongside the rest of the admin surface. func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) { // Admin gate first so non-admins (AND un-elevated admins) see 404 // regardless of method, matching the rest of /.profile/'s existence- // leakage policy. p := PrincipalFromContext(r) if !hasAnyAdminScope(cfg.Root, p) { http.NotFound(w, r) return } if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } defer r.Body.Close() dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() var req projectCreateRequest if err := dec.Decode(&req); err != nil { http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest) return } if err := zddc.ValidateProjectName(req.Name); err != nil { writeFieldError(w, http.StatusBadRequest, "name", err.Error()) return } parentAbs, err := resolvePath(cfg.Root, req.Parent) if err != nil { http.NotFound(w, r) return } if pi, err := os.Stat(parentAbs); err != nil || !pi.IsDir() { http.Error(w, "Parent directory not found", http.StatusBadRequest) return } newDir := filepath.Join(parentAbs, req.Name) if !zddc.CanEditZddc(cfg.Root, newDir, p) { http.NotFound(w, r) return } if _, err := os.Stat(newDir); err == nil { http.Error(w, "Conflict: directory already exists", http.StatusConflict) return } // If the body supplies any .zddc fields, validate them BEFORE we mkdir // so a validation failure leaves no on-disk trace. wantsZddc := req.Title != "" || (req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0)) || len(req.Admins) > 0 var zf zddc.ZddcFile if wantsZddc { zf.Title = req.Title if req.ACL != nil { zf.ACL = *req.ACL } zf.Admins = req.Admins if errs := zddc.ValidateFile(zf); len(errs) > 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(writeError{Errors: errs}) return } } if err := os.MkdirAll(newDir, 0o755); err != nil { http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError) return } if wantsZddc { if err := zddc.WriteFile(newDir, zf); err != nil { // Best-effort rollback: remove the empty dir we just created. _ = os.Remove(newDir) http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError) return } } urlPath := urlPathOf(cfg.Root, newDir) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(projectCreateResponse{ Path: urlPath, URL: urlPath + "/", }) } // writeFieldError emits a single-error writeError JSON body — used when // validation fails for a top-level scalar field like `name`. func writeFieldError(w http.ResponseWriter, status int, field, message string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{Field: field, Message: message}}}) }