package handler import ( "context" "encoding/json" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // IsReviewingPath classifies a URL as a reviewing-aggregator path and // extracts (project, tracking). The aggregator is a virtual view at: // // /reviewing/ → depth 0: list pending submittals // /reviewing// → depth 1: list received/ + staged/ // // Anything deeper than depth 1 returns ok=false; the depth-1 listing // emits canonical URLs (under archive/ and staging/) so navigation past // that point goes through the regular file-tree handlers, not back into // the virtual reviewing/ subtree. // // Trailing slash on either depth is required and tolerated. Match on // "reviewing" is case-insensitive. func IsReviewingPath(urlPath string) (project, tracking string, ok bool) { parts := strings.Split(strings.Trim(urlPath, "/"), "/") if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") { return "", "", false } switch len(parts) { case 2: return parts[0], "", true case 3: return parts[0], parts[2], true default: return "", "", false } } // pendingSubmittal is one row of the aggregator's view: a submittal in // archive//received/ that doesn't yet have a matching entry in // archive//issued/, optionally paired with an in-progress // response folder under staging/. type pendingSubmittal struct { tracking string // canonical tracking number, e.g. "123456-ST-SUB-0026" party string // party folder name, e.g. "Acme" receivedURL string // //archive//received// stagedURL string // //staging// or "" if no draft yet lastModified time.Time // newer of the two folders' mtimes } // computePending walks the project's archive/ and staging/ subtrees to // build the virtual reviewing-aggregator view. // // Algorithm: // // 1. Index staging// by tracking number. // 2. For each party under archive//: // a. Index archive//issued/ by tracking number. // b. For each archive//received/: // - skip folders that don't parse as transmittal folders. // - skip if tracking already in issued (response complete). // - emit a pendingSubmittal pointing at the canonical received // URL and (if found) the matching staging URL. // // ACL: per-party. The caller's email + decider are consulted on the // archive//received/ subtree before reading its contents — a // party the caller can't see at upstream is omitted entirely (no info // leak via tracking-number listing). // // Missing intermediate folders (archive/, party/issued/, staging/) are // not errors; they just produce empty intermediate sets. This matches // the lazy-instantiation pattern of the canonical project folders. func computePending(ctx context.Context, decider policy.Decider, fsRoot, project, email string) ([]pendingSubmittal, error) { projectAbs := filepath.Join(fsRoot, project) archiveAbs := filepath.Join(projectAbs, "archive") stagingAbs := filepath.Join(projectAbs, "staging") // Index staging by tracking → folder name. stagedByTracking := map[string]string{} if entries, err := os.ReadDir(stagingAbs); err == nil { for _, e := range entries { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { stagedByTracking[tracking] = e.Name() } } } parties, err := os.ReadDir(archiveAbs) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var result []pendingSubmittal for _, p := range parties { if !p.IsDir() || strings.HasPrefix(p.Name(), ".") { continue } party := p.Name() partyAbs := filepath.Join(archiveAbs, party) receivedAbs := filepath.Join(partyAbs, "received") // ACL: skip parties whose received/ subtree the caller can't read. // Filtering at the party level is cheaper than per-entry and matches // fs.ListDirectory's omit-denied-subdirs convention. chain, err := zddc.EffectivePolicy(fsRoot, receivedAbs) if err != nil { continue } receivedURLPrefix := "/" + project + "/archive/" + party + "/received/" if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed { continue } // Index this party's issued/ trackings (no ACL filter — issued/ // is WORM-readable to anyone with party access by design, and // we just need the set membership for matching). issuedTrackings := map[string]bool{} if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil { for _, e := range entries { if !e.IsDir() { continue } if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { issuedTrackings[tracking] = true } } } receivedEntries, err := os.ReadDir(receivedAbs) if err != nil { continue } for _, e := range receivedEntries { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()) if !ok { continue } if issuedTrackings[tracking] { continue // response complete; not pending } info, err := e.Info() if err != nil { continue } modTime := info.ModTime() sub := pendingSubmittal{ tracking: tracking, party: party, receivedURL: receivedURLPrefix + url.PathEscape(e.Name()) + "/", lastModified: modTime, } if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft { sub.stagedURL = "/" + project + "/staging/" + url.PathEscape(stagedFolder) + "/" if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil { if stagedInfo.ModTime().After(modTime) { sub.lastModified = stagedInfo.ModTime() } } } result = append(result, sub) } } sort.Slice(result, func(i, j int) bool { return result[i].tracking < result[j].tracking }) return result, nil } // ServeReviewing emits the aggregator JSON listing for either depth 0 // (project's full pending list) or depth 1 (one submittal's // received/ + staged/ pair). The HTML branch is handled separately by // the apps subsystem (mdedit served at the URL); only requests that // accept JSON reach here. func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request, project, tracking string) { pending, err := computePending(r.Context(), DeciderFromContext(r), cfg.Root, project, EmailFromContext(r)) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } var entries []listing.FileInfo switch tracking { case "": // Depth 0: list pending submittals as virtual / folders. // The URLs stay under reviewing/ so the user can drill into a // per-submittal view. urlPrefix := "/" + project + "/reviewing/" for _, s := range pending { entries = append(entries, listing.FileInfo{ Name: s.tracking + "/", URL: urlPrefix + url.PathEscape(s.tracking) + "/", ModTime: s.lastModified, IsDir: true, Virtual: true, }) } default: // Depth 1: find the matching pending entry; emit received/ + // staged/ pointing at canonical archive/staging URLs. Clients // using the polyfill follow these URLs out of the virtual // subtree into the real file paths underneath. var match *pendingSubmittal for i := range pending { if pending[i].tracking == tracking { match = &pending[i] break } } if match == nil { http.Error(w, "Not Found", http.StatusNotFound) return } entries = append(entries, listing.FileInfo{ Name: "received/", URL: match.receivedURL, ModTime: match.lastModified, IsDir: true, Virtual: true, }) if match.stagedURL != "" { entries = append(entries, listing.FileInfo{ Name: "staged/", URL: match.stagedURL, ModTime: match.lastModified, IsDir: true, Virtual: true, }) } } if entries == nil { entries = []listing.FileInfo{} } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") // virtual; recompute every time w.Header().Set("X-ZDDC-Source", "reviewing-aggregator") _ = json.NewEncoder(w).Encode(entries) }