Four user-reported items:
1. landing: remove the standalone-tool strip from the site picker.
Per user, it was awkward — links pointing at zddc.varasys.io
releases from inside a deployment is a layering confusion. The
nav.tool-strip block in landing/template.html and its CSS are
gone.
2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
app for the virtual-MDL case where the on-disk folder doesn't
exist yet. Previously fell through to 404 because the dispatcher
only routed virtual mdl/ via the IsDir branch — the IsNotExist
branch was missing the equivalent check. Now both shapes (with
and without trailing slash) hit RecognizeTableRequest's default-
MDL fallback and ServeTable serves the embedded tables.html.
3. browse: re-layout the markdown editor to mirror mdedit's layout.
Was: sidebar on right with TOC top + front-matter bottom.
Now: sidebar on LEFT with YAML front matter top + Outline bottom,
content on RIGHT with an informational header (file title +
save controls + status + source) above the Toast UI editor.
New horizontal resizer between the front-matter and outline
sections inside the sidebar (drag the row boundary; arrow keys
step by 24 px). Browse test selectors updated.
4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
user can preview files inside virtual reviewing/<tracking>/
received/ and staged/ folders. IsReviewingPath now returns a
sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
depth-2 branch proxies the underlying real folder's listing,
emitting folder entries with virtual reviewing/ URLs (so
navigation stays in the aggregator) and file entries with
canonical archive/ or staging/ URLs (so byte fetches resolve
directly). ACL is enforced against the real path; depth-1
received/ + staged/ URLs are now virtual too (was canonical),
so the user smoothly descends into the depth-2 listing.
Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
424 lines
14 KiB
Go
424 lines
14 KiB
Go
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:
|
|
//
|
|
// <project>/reviewing/ → depth 0: pending submittals
|
|
// <project>/reviewing/<tracking>/ → depth 1: received/ + staged/
|
|
// <project>/reviewing/<tracking>/<side>/[...] → 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/<party>/received/ that doesn't yet have a matching entry in
|
|
// archive/<party>/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 // /<project>/archive/<party>/received/<folder>/
|
|
stagedURL string // /<project>/staging/<folder>/ 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/<folder>/ by tracking number.
|
|
// 2. For each party under archive/<party>/:
|
|
// a. Index archive/<party>/issued/ by tracking number.
|
|
// b. For each archive/<party>/received/<folder>:
|
|
// - 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/<party>/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
|
|
// <project>/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
|
|
// <tracking>/ folders.
|
|
// 1 (tracking, side="") → list received/ + staged/ virtual folders.
|
|
// ≥2 (tracking, sidePath) → proxy the listing of the real folder
|
|
// under archive/<party>/received/<folder>/...
|
|
// or staging/<folder>/... 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 <tracking>/ 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)
|
|
}
|