refactor(zddc-server): demote routing-shape redirects from 301 to 302

301 Moved Permanently is cached by browsers effectively forever — when
we changed /<project> 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 /<project>/<sub>/.../.archive/
        path collapses to /<project>/.archive/)
      reviewing/<tracking> 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-10 14:37:02 -05:00
parent 9a98901683
commit e2c4700d32
5 changed files with 17 additions and 17 deletions

View file

@ -679,7 +679,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// and addressed at exactly one depth — /<project>/.archive/... — even
// though offline-built HTML files reference siblings via
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
// (/<project>/<sub>/.../.archive/...) gets a 301 to the project-rooted
// (/<project>/<sub>/.../.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/<tracking>) 301s to the slash form.
// Depth-3 no-slash (reviewing/<tracking>) 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
// <project>/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)

View file

@ -286,7 +286,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
}
}
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 301'd
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
// to the canonical /<project>/.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/<party>/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/<party>/mdl slash → 302 in-dir 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 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, ""},

View file

@ -18,7 +18,7 @@ import (
// ServeArchive handles requests under a project's .archive virtual path.
//
// The dispatcher canonicalizes every .archive request to /<project>/.archive/...
// before reaching here (any deeper /<project>/sub/.../archive/... gets a 301
// before reaching here (any deeper /<project>/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/.
//

View file

@ -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
}

View file

@ -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 /<project> (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:
//