package handler import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "regexp" "strings" "gopkg.in/yaml.v3" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // Accept Transmittal — the doc-controller's "file a counterparty // upload into the immutable received archive" step. Right-click on a // single transmittal folder under archive//incoming/ in the // browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the // body below. // // Authorisation model — same primitives as Plan Review, no exceptions: // // - ActionWrite on incoming// (move source). // document_controller has rwcd on incoming/ via the cascade defaults. // - ActionCreate on received// (move destination, WORM zone). // document_controller has `cr` here via worm: [document_controller]. // // Operation: // // 1. Parse URL — must be a direct child of archive//incoming/. // 2. Validate the transmittal folder name via ParseTransmittalFolder // (date, tracking, status, title). Reject if not well-formed. // 3. Validate every file in the folder via ParseFilename. Each file's // parsed tracking must match the folder's tracking. Reject on any // non-conformance — client should cancel and tell sender to fix. // 4. ACL pre-flight (source write, destination create). // 5. mkdir received/ (parent of the destination) if missing. // 6. If received// does NOT exist → os.Rename the whole // folder (atomic, fast). // If received// DOES exist (re-submission of the same // tracking) → per-file move. Refuse if any child filename already // exists at the destination — WORM forbids overwrite. // 7. Optional Plan Review chain: when the body's setup_plan_review // flag is true, the same handler dispatches through Plan Review's // three-stage flow against the new received// URL. The // ACL gates re-run there (idempotent against the same principal), // which is correct: both authorities are required by design. // // The accept itself does NOT write received//.zddc — the // cascade's worm: [document_controller] inheritance is enough. If // Plan Review is chained, IT writes the .zddc with planned dates. // Filesystem mtime on the moved folder records when the accept // happened; the audit log records who. const opAcceptTransmittal = "accept-transmittal" // incomingURLPattern matches //incoming///. var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/incoming/([^/]+)/([^/]+)/?$`) type acceptRequest struct { ReceivedDate string `yaml:"received_date"` SetupPlanReview bool `yaml:"setup_plan_review"` ReviewLead string `yaml:"review_lead"` Approver string `yaml:"approver"` PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` PlanResponseDate string `yaml:"plan_response_date"` } type acceptResponse struct { Tracking string `json:"tracking"` IncomingPath string `json:"incoming_path"` ReceivedPath string `json:"received_path"` MovedFiles int `json:"moved_files"` Merged bool `json:"merged"` PlanReview *planReviewResponse `json:"plan_review,omitempty"` } func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) { cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" m := incomingURLPattern.FindStringSubmatch(cleanURL) if m == nil { http.Error(w, "Bad Request — accept-transmittal must POST to //incoming///", http.StatusBadRequest) return } project, party, transmittalFolder := m[1], m[2], m[3] // Filing requires the party to be registered (ssr/.yaml). if !zddc.PartyRegistered(filepath.Join(cfg.Root, project), "ssr", party) { http.Error(w, "Conflict — unknown party \""+party+"\"; register it in ssr/ first", http.StatusConflict) return } date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder) if !ok { http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_ () - )", http.StatusBadRequest) return } _ = date // available for audit; mtime carries the actual accept time body, ok2 := readBodyCapped(cfg, w, r) if !ok2 { return } var req acceptRequest if len(body) > 0 { if err := yaml.Unmarshal(body, &req); err != nil { http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest) return } } if req.SetupPlanReview { if req.ReviewLead == "" || req.Approver == "" || req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" { http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest) return } } incomingAbs := filepath.Join(cfg.Root, project, "incoming", party, transmittalFolder) receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking) receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" // Source must exist as a directory. srcInfo, err := os.Stat(incomingAbs) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError) } return } if !srcInfo.IsDir() { http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest) return } // Validate every file in the folder before any side-effect. entries, err := os.ReadDir(incomingAbs) if err != nil { http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError) return } var fileNames []string var violations []string for _, e := range entries { name := e.Name() if strings.HasPrefix(name, ".") { continue // skip dotfiles silently (e.g. .zddc dropped by counterparty) } if e.IsDir() { violations = append(violations, name+": nested directories are not permitted in a transmittal folder") continue } parsed := zddc.ParseFilename(name) if !parsed.Valid { violations = append(violations, name+": does not conform to ZDDC filename grammar") continue } if parsed.TrackingNumber != tracking { violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking)) continue } fileNames = append(fileNames, name) } if len(violations) > 0 { http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict) return } if len(fileNames) == 0 { http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict) return } // ACL pre-flight: source needs Write (rename out), destination needs Create. if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) { return } if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) { return } email := EmailFromContext(r) if email == "" { http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden) return } // Ensure received/'s parent exists (received/ itself materialises via // the rename or the per-file moves below). receivedParent := filepath.Dir(receivedAbs) if err := os.MkdirAll(receivedParent, 0o755); err != nil { auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError) return } merged := false if _, err := os.Stat(receivedAbs); err == nil { // Re-submission of an already-accepted tracking → merge per-file. // Refuse any filename collision; WORM forbids overwriting. merged = true for _, name := range fileNames { dst := filepath.Join(receivedAbs, name) if _, statErr := os.Stat(dst); statErr == nil { http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict) return } else if !errors.Is(statErr, os.ErrNotExist) { http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError) return } } for _, name := range fileNames { src := filepath.Join(incomingAbs, name) dst := filepath.Join(receivedAbs, name) if err := os.Rename(src, dst); err != nil { auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError) return } } // Best-effort: remove the now-empty incoming folder. Leaves it in // place if non-empty (e.g. operator left ad-hoc notes alongside // the conformant files); audit log captures the success either way. _ = os.Remove(incomingAbs) } else if errors.Is(err, os.ErrNotExist) { // Fresh acceptance → atomic folder rename. if err := os.Rename(incomingAbs, receivedAbs); err != nil { auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError) return } } else { http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError) return } resp := acceptResponse{ Tracking: tracking, IncomingPath: cleanURL, ReceivedPath: receivedURL, MovedFiles: len(fileNames), Merged: merged, } // Optional Plan Review chain. Invokes executePlanReview directly // against the freshly-created received/<tracking>/ path. The ACL // gates re-run there — the invoker still needs ActionAdmin on the // workflow roots and `c` on received/<tracking>/, both of which // they had a moment ago for the move itself. A chained failure does // NOT roll back the move: the canonical record is sealed, and the // user can re-trigger Plan Review later from the received/<tracking>/ // folder context menu. if req.SetupPlanReview { planReq := planReviewRequest{ ReviewLead: req.ReviewLead, Approver: req.Approver, PlanReviewCompleteDate: req.PlanReviewCompleteDate, PlanResponseDate: req.PlanResponseDate, } prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq) if status != http.StatusOK { auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg)) http.Error(w, "Chained plan-review: "+msg, status) return } resp.PlanReview = prResp } w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil) }