feat(server): redirect rows-dir URLs to canonical .table.html
When an HTML GET hits a directory that's the rows-dir of a registered
table — i.e. parent declares `tables: { <name>: ... }` with a valid
spec, OR the default-MDL fallback applies at archive/<party>/mdl/ —
ServeDirectory now 302s to <parent>/<name>.table.html so users land
on the table view instead of a bare browse listing of the row-yaml
files. JSON GETs on the same URL fall through unchanged so the table
client can still enumerate row files.
Detection reuses RecognizeTableRequest: synthesize the equivalent
.table.html URL from the directory request and let the existing
recognizer apply its operator-vs-default-vs-missing-spec rules. No
duplicated validation.
Updates main_test.go's TestDispatchSlashRouting to expect the new
behavior on archive/<party>/mdl/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba20e3e5ba
commit
0ad47561ed
4 changed files with 186 additions and 15 deletions
|
|
@ -380,6 +380,13 @@ func TestDispatchSlashRouting(t *testing.T) {
|
|||
// under staging/, archive under archive/, tables under
|
||||
// archive/<party>/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/<party>/mdl/ — redirects the trailing-slash
|
||||
// form too, bouncing to <parent>/<name>.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/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true},
|
||||
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true},
|
||||
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
|
||||
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
|
||||
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true},
|
||||
{"archive/<party>/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/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
|
||||
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
|
||||
{"archive/<party>/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/<party>/mdl slash → 302 .table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl.table.html"},
|
||||
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
||||
{"archive/<party>/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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: { <name>: ... }` with a valid spec, OR the default-MDL
|
||||
// fallback kicks in at archive/<party>/mdl/ — bounce HTML
|
||||
// requests to the canonical <parent>/<name>.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
|
||||
|
|
|
|||
|
|
@ -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 <parent>/<name>.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/<party>/mdl/ with no operator .zddc declaration still redirects
|
||||
// to <party>/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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <parent>/<name>.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
|
||||
|
|
|
|||
Loading…
Reference in a new issue