package handler import ( "encoding/json" "net/http" "os" "path/filepath" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "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 the cascade's ActionCreate verb at the prospective // parent directory — the same `c` permission used everywhere else. // Super-admins pass via the decider's IsActiveAdmin bypass; explicitly // granted principals (e.g., `*@example.com: c` on the root .zddc) pass // via the normal ACL path. Non-authorized callers receive 404 to keep // this endpoint's existence hidden alongside the rest of /.profile. // // The new project's .zddc is seeded with the creator's email in // `admins:` (unless the request body supplied an explicit list). That // makes them subtree admin of their own project from birth — they can // manage deeper .zddc files, define roles, set per-stage ACLs. The // strict-ancestor rule still applies to //.zddc itself; that // stays editable only by ancestor admins (root super-admins). func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) { p := PrincipalFromContext(r) 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 } // Cascade-driven authorization: ActionCreate at the parent directory. // 404 (not 403) on deny to match the rest of /.profile/'s existence- // hiding policy. parentChain, err := zddc.EffectivePolicy(cfg.Root, parentAbs) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } parentRel, _ := filepath.Rel(cfg.Root, parentAbs) parentURL := "/" if parentRel != "." && parentRel != "" { parentURL = "/" + filepath.ToSlash(parentRel) + "/" } allowed, _ := policy.AllowActionFromChainP(r.Context(), DeciderFromContext(r), parentChain, p, parentURL, policy.ActionCreate) if !allowed { http.NotFound(w, r) return } newDir := filepath.Join(parentAbs, req.Name) if _, err := os.Stat(newDir); err == nil { http.Error(w, "Conflict: directory already exists", http.StatusConflict) return } // Always seed a starter .zddc — the creator becomes subtree admin of // their new project. Caller can also pass title / ACL / extra // admins on top. admins := req.Admins if len(admins) == 0 && p.Email != "" { admins = []string{p.Email} } var zf zddc.ZddcFile zf.Title = req.Title if req.ACL != nil { zf.ACL = *req.ACL } zf.Admins = admins wantsZddc := len(zf.Admins) > 0 || zf.Title != "" || (req.ACL != nil && len(req.ACL.Permissions) > 0) if wantsZddc { 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}}}) }