ZDDC/zddc/internal/handler/archivehandler.go
ZDDC e2c4700d32 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>
2026-05-10 14:37:02 -05:00

205 lines
7.7 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// 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 302
// to the project-rooted form), so this handler only ever sees one shape:
// project = first URL segment, filename = whatever follows .archive/.
//
// Permissions follow the FILE, not .archive itself. .archive is a virtual
// surface — it has no on-disk directory and no .zddc of its own. Two gates
// only:
//
// 1. Listing: returned entries are filtered by the per-target file's ACL
// chain. If the project bucket is empty (or doesn't exist in the index)
// the response is 404; if the user can read NO entries in a non-empty
// bucket the response is 403, so existence of an inaccessible project's
// archive does not leak.
//
// 2. Resolve: only the per-target file's ACL gates access. A user with
// no project-root permission but an explicit allow on one transmittal
// folder can fetch that file's tracking-number URL; conversely, a user
// with broad project access but a narrower deny on a specific subtree
// gets 404 (not 403) on its tracking numbers — existence must not leak.
//
// Listings serve the embedded `browse` SPA on Accept: text/html and the
// JSON entry array on Accept: application/json — same content negotiation
// as ServeDirectory, so the SPA's auto-detect path-fetch works at .archive
// URLs identically to real directories.
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, filename string) {
if project == "" {
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
return
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
if filename == "" {
serveArchiveListing(cfg, idx, w, r, project, email, decider)
return
}
target, ok := archive.Resolve(idx, project, filename)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Per-target ACL is the only gate. 404 (not 403) so the tracking
// number's mere existence isn't disclosed to a caller who can't
// actually read the resolved file.
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir)
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Serve in place — DO NOT redirect to the resolved file's real path.
// People share .archive/<tracking>.html#section URLs and expect the
// link to keep tracking the latest revision; redirecting would pin
// the bookmark to a specific transmittal-folder snapshot. The
// canonicalization redirect (/<project>/<sub>/.archive/X → /<project>/.archive/X)
// happens upstream in the dispatcher and is a different thing — it
// only collapses the .archive prefix, not the resolved bytes.
//
// Cache-Control: no-cache forces conditional revalidation each load —
// http.ServeFile sets Last-Modified/ETag from the on-disk file, so
// when the resolver picks a newer target the ETag changes and the
// browser refetches.
absFile := filepath.Join(cfg.Root, filepath.FromSlash(target))
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, absFile)
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, email string, decider policy.Decider) {
ctx := r.Context()
allEntries := idx.AllEntries(project)
if len(allEntries) == 0 {
// Project bucket missing or empty. 404 with no body distinction
// from "unknown project" — a caller probing for project names
// gets the same shape whether or not the project exists.
http.Error(w, "Not Found", http.StatusNotFound)
return
}
archiveBase := "/" + project + "/" + cfg.IndexPath + "/"
// ACL chains are folder-keyed and the listing typically hits the same
// few directories repeatedly (one per transmittal folder), so cache
// the allow/deny decision per directory rather than re-walking .zddc
// files for every entry.
aclCache := make(map[string]bool)
allowed := func(targetPath string) bool {
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath)))
if v, ok := aclCache[fileDir]; ok {
return v
}
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir)
if err != nil {
aclCache[fileDir] = false
return false
}
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
aclCache[fileDir] = v
return v
}
result := make([]listing.FileInfo, 0, len(allEntries))
for _, e := range allEntries {
if !allowed(e.TargetPath) {
continue
}
result = append(result, listing.FileInfo{
Name: e.URLName,
URL: archiveBase + e.URLName,
IsDir: false,
})
}
// Existence-leak guard: if the user can read no entries in a
// non-empty bucket, 403 — never confirm the project's archive
// exists to a caller with no permissions in it.
if len(result) == 0 {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Vary: Accept is critical because the same URL serves either the
// JSON listing or the embedded browse SPA depending on Accept;
// without it, browsers/CDNs may serve one Accept's body for the
// other Accept value and break the SPA's JSON auto-fetch.
w.Header().Set("Vary", "Accept")
if strings.Contains(r.Header.Get("Accept"), "application/json") {
body, err := json.Marshal(result)
if err != nil {
slog.Error("encoding archive listing", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
return
}
// HTML: serve the embedded `browse` SPA. The SPA auto-detects the
// server-mode listing by re-fetching this same URL with
// Accept: application/json — that path lands in the JSON branch
// above and renders the archive entries as a sortable, filterable
// flat list.
body := apps.EmbeddedBytes("browse")
if len(body) == 0 {
// Bootstrap state: a fresh build hasn't populated browse.html
// into the embed yet. Fall through to JSON for clients that
// will still parse it.
jsonBody, err := json.Marshal(result)
if err != nil {
slog.Error("encoding archive listing (no-embed fallback)", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(jsonBody)
return
}
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}