package handler import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "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"` // Role groups: member email lists for the conventional project roles. // Each non-empty list becomes a roles: MEMBERSHIP entry. Verbs are // NOT set here — the embedded defaults grant each role its per-folder // permissions (read across the project; create in the workspaces; WORM // archive; rwc on mdl/rsk for the team). The "Guests" UI field maps to // the read-only `observer` role used by those defaults. DocumentControllers []string `json:"document_controllers,omitempty"` ProjectTeam []string `json:"project_team,omitempty"` Guests []string `json:"guests,omitempty"` } // projectRoleGroups maps each create-dialog member list to the canonical role // it populates. Membership only — verbs live in the embedded defaults, which // reference these exact role names. Stable order for deterministic output. var projectRoleGroups = []struct { role string pick func(projectCreateRequest) []string }{ {"document_controller", func(r projectCreateRequest) []string { return r.DocumentControllers }}, {"project_team", func(r projectCreateRequest) []string { return r.ProjectTeam }}, {"observer", func(r projectCreateRequest) []string { return r.Guests }}, } // dedupeStrings trims, drops empties, and removes duplicates (first-wins), // preserving order. func dedupeStrings(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) for _, s := range in { s = strings.TrimSpace(s) if s == "" || seen[s] { continue } seen[s] = true out = append(out, s) } return out } // 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 administers their new project // and is RECORDED as its creator (created_by, audit). Caller can also // pass title / ACL / role groups / extra admins on top. var zf zddc.ZddcFile zf.Title = req.Title zf.CreatedBy = p.Email if req.ACL != nil { zf.ACL = *req.ACL } // Creator is always an admin (deduped, first), then any extra admins. zf.Admins = dedupeStrings(append([]string{p.Email}, req.Admins...)) // Role groups → role MEMBERSHIP at the project root. No verbs are written // here: the embedded defaults already grant document_controller / // project_team / observer their per-folder permissions, and membership // unions across the cascade — so naming members here is enough. (An // operator can still add explicit acl.permissions via the advanced field.) for _, g := range projectRoleGroups { members := dedupeStrings(g.pick(req)) if len(members) == 0 { continue } if zf.Roles == nil { zf.Roles = map[string]zddc.Role{} } zf.Roles[g.role] = zddc.Role{Members: members} } // We always record the creator, so a .zddc is essentially always // written; the guard only skips the rare anonymous-creator case with // no other content. wantsZddc := zf.CreatedBy != "" || len(zf.Admins) > 0 || zf.Title != "" || len(zf.Roles) > 0 || len(zf.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}}}) }