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: entry plus a base // acl.permissions grant (document_controller→rwcd, project_team→rwc, // guest→r). An explicit ACL permission for the same key wins. DocumentControllers []string `json:"document_controllers,omitempty"` ProjectTeam []string `json:"project_team,omitempty"` Guests []string `json:"guests,omitempty"` } // projectRoleGrants maps each role group to its name + base verb grant, in a // stable order. The single source of truth for the create flow's role seeding. var projectRoleGrants = []struct { name string verbs string pick func(projectCreateRequest) []string }{ {"document_controller", "rwcd", func(r projectCreateRequest) []string { return r.DocumentControllers }}, {"project_team", "rwc", func(r projectCreateRequest) []string { return r.ProjectTeam }}, {"guest", "r", 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 → named roles + conventional base verb grants. Each // non-empty member list defines a role; an explicit acl.permissions // entry for the same key (passed via the advanced ACL field) wins. for _, g := range projectRoleGrants { members := dedupeStrings(g.pick(req)) if len(members) == 0 { continue } if zf.Roles == nil { zf.Roles = map[string]zddc.Role{} } zf.Roles[g.name] = zddc.Role{Members: members} if zf.ACL.Permissions == nil { zf.ACL.Permissions = map[string]string{} } if _, ok := zf.ACL.Permissions[g.name]; !ok { zf.ACL.Permissions[g.name] = g.verbs } } // 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}}}) }