ZDDC/zddc/internal/handler/reviewinghandler.go
ZDDC e7f6334daa chore: retire mdedit tool — markdown editor lives in browse now
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
2026-05-13 10:34:31 -05:00

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 (browse served at the URL — its markdown editor plugin
// renders responses); 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)
}