diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 20239a0..f8df54a 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -380,6 +380,13 @@ func TestDispatchSlashRouting(t *testing.T) { // 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. + // + // Exception: a directory that is the rows-dir of a registered table + // (declared via parent .zddc tables:) — including the default-MDL + // fallback at archive//mdl/ — redirects the trailing-slash + // form too, bouncing to /.table.html. Bare folder + // listings here would just be a row-of-yaml-files preview that the + // table view subsumes. root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": rwcda\n") @@ -415,22 +422,26 @@ func TestDispatchSlashRouting(t *testing.T) { path string wantStatus int wantNoRedirect bool + wantLoc string // checked when wantStatus is a redirect }{ - {"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}, + {"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, ""}, + // Trailing-slash form on a tables rows-dir bounces to the canonical + // .table.html URL so users land on the table view rather than a + // browse listing of the row-yaml files. + {"archive//mdl slash → 302 .table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl.table.html"}, + {"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 { @@ -446,6 +457,11 @@ func TestDispatchSlashRouting(t *testing.T) { t.Errorf("path=%q unexpected redirect to %q", tc.path, rec.Header().Get("Location")) } + 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) + } + } }) } } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 5954f27..de2d38b 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -84,6 +84,19 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { ServeFile(w, r, indexPath) return } + + // Tables redirect: when this directory is the rows directory of + // a registered table — i.e. the parent declares + // `tables: { : ... }` with a valid spec, OR the default-MDL + // fallback kicks in at archive//mdl/ — bounce HTML + // requests to the canonical /.table.html URL so + // users land on the table view instead of a bare folder listing. + // JSON requests fall through unchanged so the table client can + // still enumerate row files. + if redirect := tableRowsRedirect(cfg.Root, urlPath); redirect != "" { + http.Redirect(w, r, redirect, http.StatusFound) + return + } } // Build base URL for listing entries diff --git a/zddc/internal/handler/directory_test.go b/zddc/internal/handler/directory_test.go index bfae56d..a116a31 100644 --- a/zddc/internal/handler/directory_test.go +++ b/zddc/internal/handler/directory_test.go @@ -10,6 +10,7 @@ import ( "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // TestServeDirectoryRootIsPublic asserts that the landing page (the root @@ -126,3 +127,113 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { } }) } + +// TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a +// directory that is the rows-dir of a registered table bounces to the +// canonical /.table.html URL. JSON GETs on the same URL +// fall through to the listing so the table client can still enumerate +// row files. +func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { + root := t.TempDir() + working := filepath.Join(root, "Working") + if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), + []byte(sampleTableSpec), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), + []byte(sampleRowFormSpec), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`acl: + permissions: + "*@example.com": rwcda +tables: + MDL: ./MDL.table.yaml +`), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + t.Run("HTML GET on rows-dir redirects to .table.html", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil) + req.Header.Set("Accept", "text/html") + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) + rec := httptest.NewRecorder() + ServeDirectory(cfg, rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) + } + if got, want := rec.Header().Get("Location"), "/Working/MDL.table.html"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } + }) + + t.Run("JSON GET on rows-dir falls through to listing", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) + rec := httptest.NewRecorder() + ServeDirectory(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + }) + + t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) { + // Sibling of the rows dir — same parent .zddc, but the dir name + // "Other" isn't declared as a table key. + if err := os.MkdirAll(filepath.Join(working, "Other"), 0o755); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil) + req.Header.Set("Accept", "text/html") + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) + rec := httptest.NewRecorder() + ServeDirectory(cfg, rec, req) + + if rec.Code == http.StatusFound { + t.Fatalf("got 302 to %q for non-table dir", rec.Header().Get("Location")) + } + }) +} + +// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback: +// archive//mdl/ with no operator .zddc declaration still redirects +// to /mdl.table.html (the table handler serves embedded defaults). +func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl") + if err := os.MkdirAll(mdlDir, 0o755); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/mdl/", nil) + req.Header.Set("Accept", "text/html") + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) + rec := httptest.NewRecorder() + ServeDirectory(cfg, rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) + } + if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl.table.html"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 0463387..ba72a9b 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -113,6 +113,37 @@ type TableRequest struct { Dir string } +// tableRowsRedirect reports the canonical .table.html URL to redirect +// to when (urlPath) names a directory that is the rows-dir of a +// registered table. Returns "" when no redirect should fire. +// +// Recognition reuses RecognizeTableRequest by synthesizing the +// equivalent /.table.html URL from urlPath and asking +// the table-recognizer whether it's a real, declared (or default-MDL) +// table. This keeps validation in one place — operator-declared tables +// require both a `tables:` entry AND an existing spec file. +func tableRowsRedirect(fsRoot, urlPath string) string { + // urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/". + trimmed := strings.TrimSuffix(urlPath, "/") + if trimmed == "" || trimmed == "/" { + return "" + } + slash := strings.LastIndex(trimmed, "/") + if slash < 0 { + return "" + } + parent := trimmed[:slash+1] // includes trailing slash + name := trimmed[slash+1:] + if name == "" { + return "" + } + synthesized := parent + name + ".table.html" + if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil { + return "" + } + return synthesized +} + // RecognizeTableRequest classifies r as a table-system request, or // returns nil if it falls through to other handlers. Discovery is // strictly .zddc-declarative — a *.table.html URL with no matching