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, sidePath). The aggregator is a virtual // view at: // // /reviewing/ → depth 0: pending submittals // /reviewing// → depth 1: received/ + staged/ // /reviewing///[...] → depth ≥ 2: real folder // contents (received or // staged), proxied from // the canonical archive // or staging path so the // user can preview files // in the browse pane // without leaving the // reviewing view. // // sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's // "received[/rest...]" or "staged[/rest...]" — the slash-separated // remainder after the tracking segment. // // Match on "reviewing" is case-insensitive. func IsReviewingPath(urlPath string) (project, tracking, sidePath 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: // parts[3] is the side; remainder joins back as the sub-path // within the real folder. side := strings.ToLower(parts[3]) if side != "received" && side != "staged" { return "", "", "", false } rest := strings.Join(parts[4:], "/") if rest == "" { return parts[0], parts[2], side, true } return parts[0], parts[2], side + "/" + rest, true } } // 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) // Resolve the canonical folder names to whatever case is present // on disk (deployments may use Archive/ Received/ Issued/ Staging/ // PascalCase). Empty string means no case variant exists — treated // as missing (empty contribution to the join). archiveOnDisk, _ := zddc.ResolveCanonical(projectAbs, "archive") stagingOnDisk, _ := zddc.ResolveCanonical(projectAbs, "staging") // Index staging by tracking → folder name. stagedByTracking := map[string]string{} var stagingAbs string if stagingOnDisk != "" { stagingAbs = filepath.Join(projectAbs, stagingOnDisk) 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() } } } } if archiveOnDisk == "" { return nil, nil } archiveAbs := filepath.Join(projectAbs, archiveOnDisk) 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) // Per-party canonical folder resolution (Received/ vs received/). receivedSeg, _ := zddc.ResolveCanonical(partyAbs, "received") issuedSeg, _ := zddc.ResolveCanonical(partyAbs, "issued") if receivedSeg == "" { continue // party with no received/ at all → nothing to review } receivedAbs := filepath.Join(partyAbs, receivedSeg) // 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 } // URL prefix preserves the on-disk casing so links resolve // directly against the canonicalisation done by the URL // dispatcher (no additional case-fold round-trip needed). receivedURLPrefix := "/" + project + "/" + archiveOnDisk + "/" + party + "/" + receivedSeg + "/" 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 issuedSeg != "" { if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); 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 + "/" + stagingOnDisk + "/" + 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 any depth under // /reviewing/. The HTML branch is handled separately by the // apps subsystem (mdedit served at the URL); only requests that accept // JSON reach here. // // Depths: // // 0 (tracking="") → list pending submittals as virtual // / folders. // 1 (tracking, side="") → list received/ + staged/ virtual folders. // ≥2 (tracking, sidePath) → proxy the listing of the real folder // under archive//received//... // or staging//... so the user can // preview files without leaving the // reviewing view. Folder entries keep // virtual reviewing/ URLs (navigation // stays in the aggregator). File entries // use canonical URLs so byte fetches // resolve directly against the real path. func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request, project, tracking, sidePath 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 { case tracking == "": // Depth 0: list pending submittals as virtual / folders. 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 pending entry for this tracking number. 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 } if sidePath == "" { // Depth 1: emit received/ + staged/ virtual folder pointers. // URLs stay under reviewing/ so navigation into them remains // in the aggregator (handled by the depth ≥2 branch). urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/" entries = append(entries, listing.FileInfo{ Name: "received/", URL: urlPrefix + "received/", ModTime: match.lastModified, IsDir: true, Virtual: true, }) if match.stagedURL != "" { entries = append(entries, listing.FileInfo{ Name: "staged/", URL: urlPrefix + "staged/", ModTime: match.lastModified, IsDir: true, Virtual: true, }) } } else { // Depth ≥2: proxy the real folder's listing. sidePath is // "received[/rest]" or "staged[/rest]" — split off the // leading side, append remainder to the canonical base. side := sidePath rest := "" if i := strings.IndexByte(sidePath, '/'); i >= 0 { side, rest = sidePath[:i], sidePath[i+1:] } var realURL string switch side { case "received": realURL = match.receivedURL case "staged": if match.stagedURL == "" { http.Error(w, "Not Found", http.StatusNotFound) return } realURL = match.stagedURL default: http.Error(w, "Not Found", http.StatusNotFound) return } if rest != "" { realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/" } // Translate the real URL back to a filesystem path so we // can list it. The URL still encodes percent-escapes; // PathUnescape them before joining. realRel := strings.TrimPrefix(realURL, "/") realRel = strings.TrimSuffix(realRel, "/") realRelDecoded, decodeErr := url.PathUnescape(realRel) if decodeErr != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded)) if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) { http.Error(w, "Not Found", http.StatusNotFound) return } // ACL on the underlying real path; do not proxy what the // caller can't read directly. chain, err := zddc.EffectivePolicy(cfg.Root, realAbs) if err == nil { if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, EmailFromContext(r), realURL); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } } diskEntries, err := os.ReadDir(realAbs) if err != nil { if os.IsNotExist(err) { http.Error(w, "Not Found", http.StatusNotFound) return } http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Build the virtual URL prefix (for folder entries) and // the canonical URL prefix (for file entries). virtualPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/" + side + "/" if rest != "" { virtualPrefix += rest + "/" } canonicalPrefix := realURL // already ends with "/" for _, e := range diskEntries { name := e.Name() if strings.HasPrefix(name, ".") { continue } info, err := e.Info() if err != nil { continue } fi := listing.FileInfo{ Name: name, ModTime: info.ModTime(), } if e.IsDir() { fi.Name += "/" fi.IsDir = true fi.URL = virtualPrefix + url.PathEscape(name) + "/" fi.Virtual = true } else { fi.Size = info.Size() // File URL points at the canonical real path so // fetches (preview, download) hit the right bytes // directly — no proxying through the aggregator. fi.URL = canonicalPrefix + url.PathEscape(name) } entries = append(entries, fi) } sort.Slice(entries, func(i, j int) bool { // Folders first, then files; both alphabetical. if entries[i].IsDir != entries[j].IsDir { return entries[i].IsDir } return entries[i].Name < entries[j].Name }) } } 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) }