diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 46fdc03..0e470c8 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -529,23 +529,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } - // MDL convenience redirect: GET archive//mdl/ → mdl.table.html. - // The table app is the canonical view for the per-party Master - // Deliverables List. Direct navigation to the data folder lands on - // the grid editor; clients that want the raw row listing can still - // hit archive//mdl/ via the file API or with an explicit - // trailing-segment beyond mdl/. - if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 { - if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") { - target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html" - if r.URL.RawQuery != "" { - target += "?" + r.URL.RawQuery - } - http.Redirect(w, r, target, http.StatusFound) - return - } - } - // Tables-system intercept: *.table.html is a virtual URL that the // table handler renders inline, reading rows from a directory of // *.yaml files declared in the directory's .zddc tables: map. @@ -680,6 +663,37 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } + // URL convention: trailing slash → browse (handled by + // ServeDirectory, which serves browse.html for HTML requests + // and JSON for application/json). No trailing slash → the + // canonical default tool for this directory's context, if any + // (mdedit under working/, transmittal under staging/, archive + // under archive/, tables under archive//mdl/). When no + // default applies, fall back to the historical redirect-to- + // trailing-slash behaviour. + if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot { + switch apps.DefaultAppAt(cfg.Root, absPath) { + case "tables": + // Tables aren't an apps-subsystem app — the table + // handler responds to /.table.html. Serve + // the equivalent table view inline at the bare-mdl + // URL by routing through the handler with the + // canonical .table.html name appended. + if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil { + handler.ServeTable(cfg, tr, w, r) + return + } + case "archive", "transmittal", "mdedit": + if appsSrv != nil { + app := apps.DefaultAppAt(cfg.Root, absPath) + if apps.AppAvailableAt(cfg.Root, absPath, app) { + chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) + appsSrv.Serve(w, r, app, chain, absPath) + return + } + } + } + } if !strings.HasSuffix(urlPath, "/") { http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) return diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 7512a6e..5bc9666 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -373,11 +373,26 @@ func TestDispatchArchiveRedirect(t *testing.T) { } } -func TestDispatchMdlRedirect(t *testing.T) { +func TestDispatchSlashRouting(t *testing.T) { + // Convention: / → browse (directory view); → the canonical + // default tool for the directory (mdedit under working/, transmittal + // under staging/, archive under archive/, tables under + // archive//mdl/). Without a default app, no-slash falls + // through to the legacy 301-to-trailing-slash redirect. root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n permissions:\n \"*@example.com\": rwcda\n") - mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme")) + "acl:\n permissions:\n \"*\": rwcda\n") + for _, sub := range []string{ + "Project/working", + "Project/staging", + "Project/archive", + "Project/archive/Acme", + "Project/archive/Acme/incoming", + "Project/archive/Acme/mdl", + "Project/scratch", + } { + mustMkdir(t, filepath.Join(root, sub)) + } idx, err := archive.BuildIndex(root) if err != nil { @@ -389,58 +404,46 @@ func TestDispatchMdlRedirect(t *testing.T) { EmailHeader: "X-Auth-Request-Email", } ring := handler.NewLogRing(10) + appsSrv, err := setupApps(cfg) + if err != nil { + t.Fatalf("setupApps: %v", err) + } cases := []struct { - name string - path string - wantStatus int - wantLoc string + name string + path string + wantStatus int + wantNoRedirect bool }{ - { - "mdl trailing slash → table", - "/ProjectA/archive/Acme/mdl/", - http.StatusFound, - "/ProjectA/archive/Acme/mdl.table.html", - }, - { - "case-fold MDL trailing slash", - "/ProjectA/archive/Acme/MDL/", - http.StatusFound, - "/ProjectA/archive/Acme/mdl.table.html", - }, - { - "case-fold ARCHIVE", - "/ProjectA/Archive/Acme/mdl/", - http.StatusFound, - "/ProjectA/Archive/Acme/mdl.table.html", - }, - { - "deeper than party-level mdl is NOT redirected", - "/ProjectA/archive/Acme/incoming/mdl/", - // Falls through to static-file pipeline; no folder exists there → 404. - http.StatusNotFound, - "", - }, - { - "working/mdl is NOT redirected (not under archive)", - "/ProjectA/working/mdl/", - http.StatusNotFound, - "", - }, + {"working no-slash → mdedit", "/Project/working", http.StatusOK, true}, + {"working slash → browse", "/Project/working/", http.StatusOK, true}, + {"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true}, + {"staging slash → browse", "/Project/staging/", http.StatusOK, true}, + {"archive no-slash → archive", "/Project/archive", http.StatusOK, true}, + {"archive slash → browse", "/Project/archive/", http.StatusOK, true}, + {"archive/ no-slash → archive", "/Project/archive/Acme", http.StatusOK, true}, + {"archive/ slash → browse", "/Project/archive/Acme/", http.StatusOK, true}, + {"archive//mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true}, + {"archive//mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true}, + {"archive//incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true}, + {"archive//incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true}, + {"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false}, + {"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true}, + {"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tc.path, nil) rec := httptest.NewRecorder() - dispatch(cfg, idx, ring, nil, rec, req) + dispatch(cfg, idx, ring, appsSrv, rec, req) if rec.Code != tc.wantStatus { - t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String()) + t.Fatalf("path=%q status=%d, want %d; body=%s", + tc.path, rec.Code, tc.wantStatus, rec.Body.String()) } - if tc.wantLoc != "" { - if got := rec.Header().Get("Location"); got != tc.wantLoc { - t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc) - } + if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 { + t.Errorf("path=%q unexpected redirect to %q", + tc.path, rec.Header().Get("Location")) } }) } diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index 7267a77..e832a34 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -68,3 +68,56 @@ func inAncestorWithName(root, requestDir string, names ...string) bool { } return false } + +// DefaultAppAt returns the canonical default tool name for requestDir, +// or "" if no specific tool fits. Used by the dispatcher to decide +// which app to serve at a directory URL with no trailing slash — +// trailing-slash URLs serve the browse app for any directory. +// +// Rules (case-insensitive on canonical folder names): +// +// - /archive//mdl/... → "tables" +// - /archive/ → "archive" +// - /archive//... → "archive" +// - /staging/... → "transmittal" +// - /working/... → "mdedit" +// - any other directory → "" (no default) +// +// The mdl rule wins over the broader archive rule because the table +// editor is a more specific surface for browsing planned deliverables +// than the archive index. +// +// requestDir and root are absolute filesystem paths; requestDir must +// be under root (otherwise "" is returned). +func DefaultAppAt(root, requestDir string) string { + root = filepath.Clean(root) + requestDir = filepath.Clean(requestDir) + if requestDir == root { + return "" + } + rel, err := filepath.Rel(root, requestDir) + if err != nil || strings.HasPrefix(rel, "..") { + return "" + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 2 { + // Project root itself — no default tool. + return "" + } + // The project segment is parts[0]; canonical folder is parts[1]. + canonical := strings.ToLower(parts[1]) + switch canonical { + case "archive": + // Inside archive/. Check for the mdl sub-case at depth 4 + // (parts: project, archive, party, mdl). + if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") { + return "tables" + } + return "archive" + case "staging": + return "transmittal" + case "working": + return "mdedit" + } + return "" +} diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 5de4580..65a8d81 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -67,3 +67,49 @@ func TestAppAvailableAt(t *testing.T) { }) } } + +func TestDefaultAppAt(t *testing.T) { + root := "/srv/zddc" + cases := []struct { + dir string + want string + }{ + // At the deployment root itself, no default tool — landing handles + // the project picker via a separate path. + {root, ""}, + // Bare project root: no default. Trailing-slash URL serves browse; + // no-slash falls through to the redirect. + {root + "/Project-A", ""}, + // Canonical project-root folders. + {root + "/Project-A/working", "mdedit"}, + {root + "/Project-A/working/alice@example.com", "mdedit"}, + {root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"}, + {root + "/Project-A/staging", "transmittal"}, + {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"}, + // archive: at the archive root, party folders, and per-party + // subfolders (incoming/received/issued). + {root + "/Project-A/archive", "archive"}, + {root + "/Project-A/archive/Acme", "archive"}, + {root + "/Project-A/archive/Acme/incoming", "archive"}, + {root + "/Project-A/archive/Acme/issued", "archive"}, + {root + "/Project-A/archive/Acme/received", "archive"}, + // mdl wins over the broader archive rule. + {root + "/Project-A/archive/Acme/mdl", "tables"}, + {root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"}, + // reviewing/ is virtual — no default tool wired here yet. + {root + "/Project-A/reviewing", ""}, + // Random non-canonical folder names → no default. + {root + "/Project-A/scratch", ""}, + // Case-fold on canonical names. + {root + "/Project-A/Working", "mdedit"}, + {root + "/Project-A/STAGING", "transmittal"}, + {root + "/Project-A/Archive/Acme/MDL", "tables"}, + } + for _, tc := range cases { + t.Run(tc.dir, func(t *testing.T) { + if got := DefaultAppAt(root, tc.dir); got != tc.want { + t.Errorf("DefaultAppAt(%q) = %q, want %q", tc.dir, got, tc.want) + } + }) + } +}