From e2c4700d32e1a9b3d41ceb5e8009c541009ffce1 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sun, 10 May 2026 14:37:02 -0500 Subject: [PATCH] refactor(zddc-server): demote routing-shape redirects from 301 to 302 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 301 Moved Permanently is cached by browsers effectively forever — when we changed / no-slash from "redirect to slash form" to "serve project landing" earlier today, anyone who had visited the URL under the prior behavior got stuck on the cached 301 indefinitely. No server-side fix is possible after the fact; only a manual cache clear in each user's browser releases the binding. Demote every routing-shape redirect to 302 Found, which browsers do not cache by default. Five sites: - handler/directory.go: no-trailing-slash → slash on directory URLs - main.go (4 sites): .archive/ canonicalization (deep ///.../.archive/ path collapses to //.archive/) reviewing/ no-slash → slash reviewing/ default-app fallback to slash form generic IsDir + no-slash + no-default-tool fallback 301 → 302 trades "permanent semantics in the protocol" for "we can change our mind later without trapping users on old behavior." For these routes — all of which are convention-driven shapes the server owns — the latter is what we want. Test updates: five httptest assertions switch from http.StatusMovedPermanently → http.StatusFound, plus five comment strings ("301" → "302"). Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 14 +++++++------- zddc/cmd/zddc-server/main_test.go | 14 +++++++------- zddc/internal/handler/archivehandler.go | 2 +- zddc/internal/handler/directory.go | 2 +- zddc/internal/handler/projecthandler.go | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index d5a34f1..29407ac 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -679,7 +679,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // and addressed at exactly one depth — //.archive/... — even // though offline-built HTML files reference siblings via // "../.archive/.html" from arbitrary depths. Any deeper form - // (///.../.archive/...) gets a 301 to the project-rooted + // (///.../.archive/...) gets a 302 to the project-rooted // canonical so anchored links and bookmarks normalize to a single // stable URL per tracking number. The redirect target preserves the // path tail after .archive/ verbatim and the query string; browsers @@ -721,7 +721,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } - http.Redirect(w, r, target, http.StatusMovedPermanently) + http.Redirect(w, r, target, http.StatusFound) return } handler.ServeArchive(cfg, idx, w, r, project, filename) @@ -849,14 +849,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // - HTML, with slash → browse.html (via ServeDirectory). // browse fetches JSON which routes back // through here to ServeReviewing. - // Depth-3 no-slash (reviewing/) 301s to the slash form. + // Depth-3 no-slash (reviewing/) 302s to the slash form. // Depth-2 no-slash (reviewing) falls through to the canonical- // folder block below where DefaultAppAt routes to mdedit. if r.Method == http.MethodGet || r.Method == http.MethodHead { if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok { if !strings.HasSuffix(urlPath, "/") { if tracking != "" { - http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) + http.Redirect(w, r, urlPath+"/", http.StatusFound) return } // Depth-2 no-slash falls through to canonical-folder block. @@ -885,7 +885,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // /working/ → ServeDirectory → fs.ListDirectory // returns 200 + [] for the empty case // - // reviewing/ has no default app, so the no-slash form 301s + // reviewing/ has no default app, so the no-slash form 302s // to the slash form. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) { @@ -902,7 +902,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } // No default app (reviewing/) — redirect to slash form. - http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) + http.Redirect(w, r, urlPath+"/", http.StatusFound) return } handler.ServeDirectory(cfg, w, r) @@ -977,7 +977,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } if !strings.HasSuffix(urlPath, "/") { - http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) + http.Redirect(w, r, urlPath+"/", http.StatusFound) return } handler.ServeDirectory(cfg, w, r) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 6a2aba8..8566ea1 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -286,7 +286,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } } -// TestDispatchArchiveRedirect: any ///.../.archive/... is 301'd +// TestDispatchArchiveRedirect: any ///.../.archive/... is 302'd // to the canonical //.archive/... so all tracking-number references // converge on a single stable URL per (project, tracking) regardless of the // folder a relative "../.archive/..." link was resolved from. @@ -318,28 +318,28 @@ func TestDispatchArchiveRedirect(t *testing.T) { "deep two segments", "/ProjectA/Working/.archive/100.html", "", - http.StatusMovedPermanently, + http.StatusFound, "/ProjectA/.archive/100.html", }, { "deep three segments", "/ProjectA/sub/sub2/.archive/100.html", "", - http.StatusMovedPermanently, + http.StatusFound, "/ProjectA/.archive/100.html", }, { "deep with trailing slash (listing)", "/ProjectA/Working/.archive/", "", - http.StatusMovedPermanently, + http.StatusFound, "/ProjectA/.archive/", }, { "deep with query string preserved", "/ProjectA/Working/.archive/100.html", "v=42", - http.StatusMovedPermanently, + http.StatusFound, "/ProjectA/.archive/100.html?v=42", }, { @@ -379,7 +379,7 @@ func TestDispatchSlashRouting(t *testing.T) { // 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. + // through to the trailing-slash redirect (302). // // Exception: a directory that is the rows-dir of a registered table // (declared via parent .zddc tables:) — including the default-MDL @@ -439,7 +439,7 @@ func TestDispatchSlashRouting(t *testing.T) { {"archive//mdl slash → 302 in-dir 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 no-slash → 302 to slash", "/Project/scratch", http.StatusFound, false, ""}, {"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""}, // Project root no-slash → synthetic landing page (handler.ServeProjectLanding). {"project root no-slash → landing", "/Project", http.StatusOK, true, ""}, diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 1bd5576..5d5f041 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -18,7 +18,7 @@ import ( // ServeArchive handles requests under a project's .archive virtual path. // // The dispatcher canonicalizes every .archive request to //.archive/... -// before reaching here (any deeper //sub/.../archive/... gets a 301 +// before reaching here (any deeper //sub/.../archive/... gets a 302 // to the project-rooted form), so this handler only ever sees one shape: // project = first URL segment, filename = whatever follows .archive/. // diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index de2d38b..5c3f4f4 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -42,7 +42,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) { func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { urlPath := r.URL.Path if !strings.HasSuffix(urlPath, "/") { - http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) + http.Redirect(w, r, urlPath+"/", http.StatusFound) return } diff --git a/zddc/internal/handler/projecthandler.go b/zddc/internal/handler/projecthandler.go index 82c2768..4a1b728 100644 --- a/zddc/internal/handler/projecthandler.go +++ b/zddc/internal/handler/projecthandler.go @@ -7,7 +7,7 @@ import ( // IsProjectRootURL reports whether urlPath names a project root — // exactly one path segment, no trailing slash. Used by the dispatcher // to route / (no trailing slash) to the landing tool's -// project-workspace mode rather than the historical 301-to-slash. +// project-workspace mode rather than the historical redirect-to-slash. // // Examples: //