diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 207f9fb..f914966 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -960,6 +960,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } } + // Project root (depth-1 dir, no trailing slash) gets a synthetic + // landing page with the four lifecycle-stage cards + MDL + // instructions. With trailing slash, the project falls through to + // the regular browse listing. + if !strings.HasSuffix(urlPath, "/") && + (r.Method == http.MethodGet || r.Method == http.MethodHead) && + handler.IsProjectRootURL(urlPath) { + project := strings.TrimPrefix(urlPath, "/") + handler.ServeProjectLanding(cfg, w, r, project) + 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 981a59a..6a2aba8 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -441,7 +441,8 @@ func TestDispatchSlashRouting(t *testing.T) { {"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, ""}, + // Project root no-slash → synthetic landing page (handler.ServeProjectLanding). + {"project root no-slash → landing", "/Project", http.StatusOK, true, ""}, } for _, tc := range cases { diff --git a/zddc/internal/handler/projecthandler.go b/zddc/internal/handler/projecthandler.go new file mode 100644 index 0000000..e28dcc4 --- /dev/null +++ b/zddc/internal/handler/projecthandler.go @@ -0,0 +1,283 @@ +package handler + +import ( + "html/template" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// IsProjectRootURL reports whether urlPath names a project root — +// exactly one path segment, no trailing slash. Used by the dispatcher +// to route / (with no trailing slash) to ServeProjectLanding +// instead of 301'ing to the slash form. +// +// Examples: +// +// "/Project-1" → true +// "/Project-1/" → false (trailing slash → directory listing) +// "/Project-1/x" → false (deeper) +// "/" → false (deployment root, served by landing tool) +// "" → false +func IsProjectRootURL(urlPath string) bool { + if urlPath == "" || urlPath == "/" { + return false + } + if strings.HasSuffix(urlPath, "/") { + return false + } + trimmed := strings.TrimPrefix(urlPath, "/") + return !strings.Contains(trimmed, "/") +} + +// projectLandingTmpl is the inline template for / (no slash). +// It's a simple navigation page — four canonical-stage cards, a link +// to the full file browser, and instructions for editing the MDL, +// listing any parties already present in archive/. +var projectLandingTmpl = template.Must(template.New("projectLanding").Parse(` + + + + +{{.Project}} — ZDDC + + + +
+
+ + {{.Project}} +
+ ZDDC project workspace +
+ +
+

{{.Project}} — project workspace

+

Pick a lifecycle stage, or browse all files.

+ + + +

Browse all files →

+ +

Master Deliverables List (MDL)

+

Each counterparty in the archive has an MDL — an editable table + of expected deliverables. The default columns mirror the ZDDC + tracking-number components (originator, phase, + project, area, discipline, + type, sequence, suffix) plus + title, plannedRevision, + plannedDate, status, and owner.

+ +

To edit the MDL for any party:

+
    +
  1. Open the project archive: /{{.ProjectURL}}/archive/
  2. +
  3. Click into a party's folder (e.g. PartyA)
  4. +
  5. Click mdl inside the party folder
  6. +
+ + {{if .Parties}} +

Direct links — parties currently in archive/:

+ + {{else}} +

No party folders yet. The MDL view auto-renders at + any archive/<party>/mdl/ URL, even when the folder + doesn't exist on disk — so you can start editing an MDL before any + transmittals have been exchanged.

+ {{end}} + +

To customize the columns or schema for a specific party, drop a + table.yaml and form.yaml into + archive/<party>/mdl/. Operator-supplied files + override the embedded defaults entirely.

+
+ +`)) + +// projectLandingData is the template input. +type projectLandingData struct { + Project string + ProjectURL string // url-escaped Project, for use in href values + ArchiveSeg string // on-disk casing of "archive" (Archive vs archive) + Parties []partyEntry +} + +type partyEntry struct { + DisplayName string // on-disk name (e.g. "PartyA") + URLName string // url-escaped variant +} + +// ServeProjectLanding renders the project root navigation page at +// / (no trailing slash). Lists the four canonical lifecycle +// stages as cards, links into the full browse view, and provides +// instructions for editing the per-party MDL with direct links to any +// parties already present under archive/. +// +// ACL: the dispatcher already gates this entry by the project's +// .zddc cascade before calling here. No additional check needed. +func ServeProjectLanding(cfg config.Config, w http.ResponseWriter, r *http.Request, project string) { + projectAbs := filepath.Join(cfg.Root, project) + + // On-disk casing of archive/ — preserve in URL hrefs so links + // don't bounce through the URL-canonicalisation layer. + archiveSeg, _ := zddc.ResolveCanonical(projectAbs, "archive") + if archiveSeg == "" { + archiveSeg = "archive" + } + + // Enumerate parties under archive//. Failures here are + // non-fatal — the page just renders without the direct-link list. + var parties []partyEntry + if archiveSeg != "" { + archiveAbs := filepath.Join(projectAbs, archiveSeg) + if entries, err := os.ReadDir(archiveAbs); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + parties = append(parties, partyEntry{ + DisplayName: name, + URLName: url.PathEscape(name), + }) + } + sort.Slice(parties, func(i, j int) bool { + return parties[i].DisplayName < parties[j].DisplayName + }) + } + } + + data := projectLandingData{ + Project: project, + ProjectURL: url.PathEscape(project), + ArchiveSeg: url.PathEscape(archiveSeg), + Parties: parties, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") // recomputes party list per hit + if err := projectLandingTmpl.Execute(w, data); err != nil { + // Headers already flushed; nothing to do beyond log. + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/zddc/internal/handler/projecthandler_test.go b/zddc/internal/handler/projecthandler_test.go new file mode 100644 index 0000000..f90c0cd --- /dev/null +++ b/zddc/internal/handler/projecthandler_test.go @@ -0,0 +1,119 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" +) + +func TestIsProjectRootURL(t *testing.T) { + cases := map[string]bool{ + "/Project-1": true, + "/Project_2": true, + "/Project-1/": false, // trailing slash + "/Project-1/x": false, // deeper + "/": false, // deployment root + "": false, + } + for path, want := range cases { + if got := IsProjectRootURL(path); got != want { + t.Errorf("IsProjectRootURL(%q) = %v, want %v", path, got, want) + } + } +} + +func TestServeProjectLanding(t *testing.T) { + root := t.TempDir() + // Need .zddc on disk for the resolver to be happy at the root. + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + // Project with two parties under archive/ (PascalCase to exercise + // the case-insensitive archive resolver) and one orphaned dot-prefixed + // dir that should be filtered out of the party list. + for _, sub := range []string{ + "Project-1/Archive/PartyA", + "Project-1/Archive/PartyB", + "Project-1/Archive/.hidden", + } { + if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + cfg := config.Config{Root: root} + + t.Run("renders project name + stage cards", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project-1", nil) + rec := httptest.NewRecorder() + ServeProjectLanding(cfg, rec, req, "Project-1") + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + body := rec.Body.String() + + // Page identifies the project and includes the four stages. + for _, want := range []string{ + "Project-1", + "

Archive

", + "

Working

", + "

Staging

", + "

Reviewing

", + "Master Deliverables List", + `href="/Project-1/working"`, + `href="/Project-1/archive/"`, + } { + if !strings.Contains(body, want) { + t.Errorf("body missing %q", want) + } + } + }) + + t.Run("lists existing parties as direct MDL links", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project-1", nil) + rec := httptest.NewRecorder() + ServeProjectLanding(cfg, rec, req, "Project-1") + body := rec.Body.String() + + // Both parties surfaced; on-disk casing preserved in the URL. + if !strings.Contains(body, `href="/Project-1/Archive/PartyA/mdl/"`) { + t.Errorf("body missing PartyA MDL link") + } + if !strings.Contains(body, `href="/Project-1/Archive/PartyB/mdl/"`) { + t.Errorf("body missing PartyB MDL link") + } + // Dot-prefixed entries filtered out. + if strings.Contains(body, ".hidden") { + t.Errorf("body should not list .hidden directory") + } + }) + + t.Run("no parties yet → falls back to generic instruction", func(t *testing.T) { + bare := t.TempDir() + if err := os.WriteFile(filepath.Join(bare, ".zddc"), + []byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bare, "Fresh"), 0o755); err != nil { + t.Fatal(err) + } + bareCfg := config.Config{Root: bare} + + req := httptest.NewRequest(http.MethodGet, "/Fresh", nil) + rec := httptest.NewRecorder() + ServeProjectLanding(bareCfg, rec, req, "Fresh") + body := rec.Body.String() + + // Falls through to the "no party folders yet" copy. + if !strings.Contains(body, "No party folders yet") { + t.Errorf("body missing fresh-project fallback copy") + } + }) +}