// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report // lifecycle endpoints that don't fit the generic form / file API shapes. // // Two endpoints live here: // // POST //ssr/form.html → "+ Add row" / SSR create // POST //ssr/.yaml with X-ZDDC-Op: ssr-rename // X-ZDDC-Destination: //ssr/.yaml // // Both target the project-level SSR aggregator, which is a virtual view // (see zddc/virtualviews.go). Internally they materialize / rename real // party folders under /archive/. // // The generic file API only does file-level moves; renaming a party // folder means renaming a directory and bringing every row inside with // it. Rather than weaken serveFileMove's IsDir-source guard (which keeps // callers from accidentally moving entire subtrees), this file // implements ssr-rename as a tightly-scoped op that only fires for the // SSR virtual URL shape. package handler import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "gopkg.in/yaml.v3" ) // opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched // from serveFilePost in fileapi.go. const opSSRRename = "ssr-rename" // serveFormCreateSSR materializes a new party folder under // /archive// and writes archive//ssr.yaml from the // submitted form body. The `name` field doubles as the party folder // name and is stripped from the YAML before write (path-derived). // // ACL gate: ActionCreate at /archive// — typically // satisfied by document_controller's rwc grant on archive/ in the // project-level cascade, OR by a deployment that grants `c` to a wider // audience. // // Auto-own .zddc is seeded for the new party folder via // WriteAutoOwnZddc, the same machinery the generic mkdir uses. // Authenticated email is required (401 otherwise) so the auto-own // grant always names a real principal. func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } if req.Project == "" { http.Error(w, "internal: SSR create missing project", http.StatusInternalServerError) return } data, err := decodeRequestData(r) if err != nil { http.Error(w, "request body: "+err.Error(), http.StatusBadRequest) return } spec, err := loadFormSpec(cfg.Root, req.SpecPath) if err != nil { http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) return } if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 { writeValidationErrors(w, errs) return } dataMap, ok := data.(map[string]interface{}) if !ok { http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest) return } nameRaw, _ := dataMap["name"].(string) name := strings.TrimSpace(nameRaw) if !zddc.ValidPartyName(name) { writeValidationErrors(w, []jsonschema.Error{{ Path: "/name", Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`, }}) return } partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name) if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { http.Error(w, "Not Found", http.StatusNotFound) return } partyURL := "/" + req.Project + "/archive/" + name + "/" rowURL := "/" + req.Project + "/ssr/" + name + ".yaml" // ACL gate: create at /archive//. authorizeAction walks // up to the closest existing ancestor for the chain — typically // /archive/, where document_controller carries rwc per the // project-level cascade. if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) { return } // Refuse to clobber an existing party folder. The SSR view shows // any folder under archive/*/; if one with this name exists, the // user should edit that row instead of creating a duplicate. if info, err := os.Stat(partyAbs); err == nil { if info.IsDir() { http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict) return } http.Error(w, "Conflict — a file exists at this path", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Materialize canonical ancestors (/archive/) with auto-own // seeding before creating the party folder itself. Mirrors what the // generic file-API mkdir does at fileapi.go:629-634. yamlAbs := filepath.Join(partyAbs, "ssr.yaml") if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil { slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err) } if err := os.MkdirAll(partyAbs, 0o755); err != nil { auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Auto-own .zddc on the new party folder. archive/*/ is declared // auto_own in defaults.zddc.yaml, so the unfenced creator grant // fires here exactly as it would for a manual mkdir. if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) { var werr error if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) { werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email) } else { werr = zddc.WriteAutoOwnZddc(partyAbs, email) } if werr != nil { slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr) } } // Drop the path-derived `name` field — it's the folder name, not // row data. The dispatcher re-injects it on read. delete(dataMap, "name") yamlBytes, err := yaml.Marshal(dataMap) if err != nil { auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) return } if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil { auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err) http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Location", rowURL) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "ssr-create") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil) } // serveSSRRename renames a party folder by rewriting an SSR row URL. // // Wire-form: POST //ssr/.yaml // // X-ZDDC-Op: ssr-rename // X-ZDDC-Destination: //ssr/.yaml // // Both source and destination must resolve as SSR-row virtual URLs in // the same project. The destination party name must be a valid party // folder name, must differ from the source, and must not already exist. // // ACL: ActionWrite at /archive// AND ActionCreate at // /archive//. Both gates evaluate against their canonical // archive paths so non-owners can't rename someone else's party. // // On success: os.Rename moves archive// → archive// (an // atomic directory rename on the same filesystem); every row inside // follows. No denormalized `party:` field rewrite is needed — the // MDL/RSK schemas don't carry one. func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path) if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow { http.Error(w, "Bad Request — ssr-rename source must be //ssr/.yaml", http.StatusBadRequest) return } dstHeader := r.Header.Get(headerDestination) if dstHeader == "" { http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest) return } if dec, err := url.PathUnescape(dstHeader); err == nil { dstHeader = dec } dst := zddc.ResolveVirtualView(cfg.Root, dstHeader) if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow { http.Error(w, "Bad Request — ssr-rename destination must be //ssr/.yaml", http.StatusBadRequest) return } if dst.Project != src.Project { http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest) return } if dst.Party == src.Party { http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest) return } // Source party folder must exist. srcArchive := src.PartyArchive if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() { if err != nil && !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Error(w, "Not Found", http.StatusNotFound) return } // Destination must not exist. dstArchive := dst.PartyArchive if _, err := os.Stat(dstArchive); err == nil { http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // ACL: write on src archive, create on dst archive. URLs include // the trailing slash convention used elsewhere for directory ops. srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/" dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/" if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) { return } if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) { return } // Optional If-Match against the source ssr.yaml etag. if !checkIfMatch(w, r, src.CanonicalAbs) { return } if err := os.Rename(srcArchive, dstArchive); err != nil { auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml" w.Header().Set("Location", newURL) w.Header().Set("X-ZDDC-Destination", newURL) w.Header().Set("X-ZDDC-Source", "ssr-rename") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) resp, _ := json.Marshal(map[string]string{"location": newURL}) _, _ = w.Write(resp) auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil) }