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:
parent
9a98901683
commit
e2c4700d32
5 changed files with 17 additions and 17 deletions
|
|
@ -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
|
// and addressed at exactly one depth — /<project>/.archive/... — even
|
||||||
// though offline-built HTML files reference siblings via
|
// though offline-built HTML files reference siblings via
|
||||||
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
|
// "../.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
|
// canonical so anchored links and bookmarks normalize to a single
|
||||||
// stable URL per tracking number. The redirect target preserves the
|
// stable URL per tracking number. The redirect target preserves the
|
||||||
// path tail after .archive/ verbatim and the query string; browsers
|
// 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 != "" {
|
if r.URL.RawQuery != "" {
|
||||||
target += "?" + r.URL.RawQuery
|
target += "?" + r.URL.RawQuery
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeArchive(cfg, idx, w, r, project, filename)
|
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).
|
// - HTML, with slash → browse.html (via ServeDirectory).
|
||||||
// browse fetches JSON which routes back
|
// browse fetches JSON which routes back
|
||||||
// through here to ServeReviewing.
|
// 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-
|
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
||||||
// folder block below where DefaultAppAt routes to mdedit.
|
// folder block below where DefaultAppAt routes to mdedit.
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
|
if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
if tracking != "" {
|
if tracking != "" {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Depth-2 no-slash falls through to canonical-folder block.
|
// 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
|
// <project>/working/ → ServeDirectory → fs.ListDirectory
|
||||||
// returns 200 + [] for the empty case
|
// 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.
|
// to the slash form.
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
|
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.
|
// No default app (reviewing/) — redirect to slash form.
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeDirectory(cfg, w, r)
|
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, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeDirectory(cfg, w, r)
|
handler.ServeDirectory(cfg, w, r)
|
||||||
|
|
|
||||||
|
|
@ -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
|
// to the canonical /<project>/.archive/... so all tracking-number references
|
||||||
// converge on a single stable URL per (project, tracking) regardless of the
|
// converge on a single stable URL per (project, tracking) regardless of the
|
||||||
// folder a relative "../.archive/..." link was resolved from.
|
// folder a relative "../.archive/..." link was resolved from.
|
||||||
|
|
@ -318,28 +318,28 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
||||||
"deep two segments",
|
"deep two segments",
|
||||||
"/ProjectA/Working/.archive/100.html",
|
"/ProjectA/Working/.archive/100.html",
|
||||||
"",
|
"",
|
||||||
http.StatusMovedPermanently,
|
http.StatusFound,
|
||||||
"/ProjectA/.archive/100.html",
|
"/ProjectA/.archive/100.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"deep three segments",
|
"deep three segments",
|
||||||
"/ProjectA/sub/sub2/.archive/100.html",
|
"/ProjectA/sub/sub2/.archive/100.html",
|
||||||
"",
|
"",
|
||||||
http.StatusMovedPermanently,
|
http.StatusFound,
|
||||||
"/ProjectA/.archive/100.html",
|
"/ProjectA/.archive/100.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"deep with trailing slash (listing)",
|
"deep with trailing slash (listing)",
|
||||||
"/ProjectA/Working/.archive/",
|
"/ProjectA/Working/.archive/",
|
||||||
"",
|
"",
|
||||||
http.StatusMovedPermanently,
|
http.StatusFound,
|
||||||
"/ProjectA/.archive/",
|
"/ProjectA/.archive/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"deep with query string preserved",
|
"deep with query string preserved",
|
||||||
"/ProjectA/Working/.archive/100.html",
|
"/ProjectA/Working/.archive/100.html",
|
||||||
"v=42",
|
"v=42",
|
||||||
http.StatusMovedPermanently,
|
http.StatusFound,
|
||||||
"/ProjectA/.archive/100.html?v=42",
|
"/ProjectA/.archive/100.html?v=42",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -379,7 +379,7 @@ func TestDispatchSlashRouting(t *testing.T) {
|
||||||
// default tool for the directory (mdedit under working/, transmittal
|
// default tool for the directory (mdedit under working/, transmittal
|
||||||
// under staging/, archive under archive/, tables under
|
// under staging/, archive under archive/, tables under
|
||||||
// archive/<party>/mdl/). Without a default app, no-slash falls
|
// 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
|
// Exception: a directory that is the rows-dir of a registered table
|
||||||
// (declared via parent .zddc tables:) — including the default-MDL
|
// (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>/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 no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
||||||
{"archive/<party>/incoming slash → browse", "/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, ""},
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
||||||
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
|
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
|
||||||
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
|
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import (
|
||||||
// ServeArchive handles requests under a project's .archive virtual path.
|
// ServeArchive handles requests under a project's .archive virtual path.
|
||||||
//
|
//
|
||||||
// The dispatcher canonicalizes every .archive request to /<project>/.archive/...
|
// 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:
|
// to the project-rooted form), so this handler only ever sees one shape:
|
||||||
// project = first URL segment, filename = whatever follows .archive/.
|
// project = first URL segment, filename = whatever follows .archive/.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
|
||||||
func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
urlPath := r.URL.Path
|
urlPath := r.URL.Path
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
// IsProjectRootURL reports whether urlPath names a project root —
|
// IsProjectRootURL reports whether urlPath names a project root —
|
||||||
// exactly one path segment, no trailing slash. Used by the dispatcher
|
// exactly one path segment, no trailing slash. Used by the dispatcher
|
||||||
// to route /<project> (no trailing slash) to the landing tool's
|
// 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:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue