diff --git a/zddc/README.md b/zddc/README.md index 591943a..29b2255 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -1425,8 +1425,16 @@ log is emitted with both paths so the conflict can be diagnosed and corrected. | `GET /Project/sub/sub/.archive/TRK-001.html` | Same as the top-level Project listing — depth within a project doesn't change scope | | `GET /.archive/...` | **404** — root has no project segment | -All successful responses are `302 Found` redirects to the actual file URL. ACL -is enforced on both the `.archive` context directory and the resolved target file. +Successful `.html` responses **serve the resolved file's bytes inline** at the +`.archive/` URL — no `Location` redirect. The per-transmittal URL is hidden on +purpose: external links of the form `.archive/.html#section` keep +tracking the latest revision. A redirect would expose the snapshot URL and any +forwarded link would pin to that snapshot instead of "latest." Cache-Control is +`no-cache` so each load revalidates against the on-disk file's +`Last-Modified`/`ETag`; when a new revision lands the resolver picks it and the +browser refetches. ACL is enforced on both the `.archive` context directory and +the resolved target file (per-target denial returns 404, not 403, to avoid +disclosing that the tracking number exists in a hidden subtree). ### Why "earliest" transmittal? diff --git a/zddc/internal/archive/resolver.go b/zddc/internal/archive/resolver.go index 1199109..0860f7b 100644 --- a/zddc/internal/archive/resolver.go +++ b/zddc/internal/archive/resolver.go @@ -5,7 +5,10 @@ import ( ) // Resolve parses the .archive request filename and returns the server-relative -// redirect target URL (no leading slash) within the named project. +// path (no leading slash) of the resolved file within the named project. The +// caller serves that file in place — the .archive URL is intentionally stable +// across revisions so external links like .archive/.html#section +// keep tracking the latest copy without exposing the per-transmittal URL. // // Project is the top-level segment of the .archive contextPath // (//.../.archive/). An empty project — i.e. a request diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 95a3973..2b77f6e 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -80,7 +80,20 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, return } - http.Redirect(w, r, "/"+target, http.StatusFound) + // Serve the resolved file in place — DO NOT redirect. The .archive/ + // URL is meant to be a stable forward-able link (people share + // `.archive/.html#section` and expect that to keep tracking + // the latest revision). A redirect would expose the specific + // transmittal-folder URL, and any anchor/hash bookmarked from the + // browser bar would pin to that snapshot instead of "the latest." + // + // Cache-Control no-cache forces a 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) } // projectFromContextPath returns the first non-empty segment of the diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go index ec5d8eb..5762924 100644 --- a/zddc/internal/handler/archivehandler_test.go +++ b/zddc/internal/handler/archivehandler_test.go @@ -32,12 +32,15 @@ func archiveTestRoot(t *testing.T) (string, *archive.Index) { t.Helper() root := t.TempDir() + // Write each fixture file's relative path as its content so the + // in-place .archive serve can be verified body-side (the resolver + // no longer issues a redirect — see archivehandler.go). mk := func(rel string) { path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } @@ -319,8 +322,8 @@ func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { // Alice in /ProjectA can resolve all of ProjectA's entries. for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} { rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn) - if rec.Code != http.StatusFound { - t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 200; body = %s", fn, rec.Code, rec.Body.String()) } } @@ -332,8 +335,8 @@ func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { // Bob has no denies — he can pull 200.html from /ProjectB. rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html") - if rec.Code != http.StatusFound { - t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 302", rec.Code) + if rec.Code != http.StatusOK { + t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 200", rec.Code) } } @@ -362,9 +365,9 @@ func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { wantStatus int why string }{ - {"bob@example.com", "/ProjectA", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"}, - {"bob@example.com", "/ProjectB", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"}, - {"alice@example.com", "/ProjectA", "100.html", http.StatusFound, "alice rescued by ProjectA allow"}, + {"bob@example.com", "/ProjectA", "100.html", http.StatusOK, "bob allowed at root → reaches ProjectA target"}, + {"bob@example.com", "/ProjectB", "200.html", http.StatusOK, "bob allowed at root → reaches ProjectB target"}, + {"alice@example.com", "/ProjectA", "100.html", http.StatusOK, "alice rescued by ProjectA allow"}, {"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"}, // mallory denied everywhere; the contextPath gate fires first. {"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"}, @@ -379,32 +382,41 @@ func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { } } -// Resolved redirect Location header is the absolute path to the actual file -// under cfg.Root. From any depth within the same project, the resolver -// returns the same target — `/ProjectA/.archive/100.html` and -// `/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/100.html` 302 to the same -// file because both look up project ProjectA. -func TestServeArchive_ResolveLocationStableAcrossDepthWithinProject(t *testing.T) { +// .archive serves the resolved file in place — the URL never changes. +// From any depth within the same project the resolver picks the same +// target file, so the bytes returned to the caller must be identical +// across context paths (the per-revision file URL is intentionally +// hidden so external links remain stable). +func TestServeArchive_ServedBytesStableAcrossDepthWithinProject(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: allow: ["*"] `) cfg := archiveCfg(root) - wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A" - for _, ctx := range []string{ + wantBodyPrefix := "ProjectA/2025-01-01_T1 (IFR) - Title/100_A" + var firstBody string + for i, ctx := range []string{ "/ProjectA", "/ProjectA/2025-01-01_T1 (IFR) - Title", "/ProjectA/2025-02-01_T2 (RTN) - Comments", } { rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") - if rec.Code != http.StatusFound { + if rec.Code != http.StatusOK { t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String()) continue } - loc := rec.Header().Get("Location") - if !strings.HasPrefix(loc, wantLocPrefix) { - t.Errorf("ctx=%s Location=%q, want prefix %q", ctx, loc, wantLocPrefix) + if loc := rec.Header().Get("Location"); loc != "" { + t.Errorf("ctx=%s unexpected Location=%q (.archive must serve in place)", ctx, loc) + } + body := rec.Body.String() + if !strings.HasPrefix(body, wantBodyPrefix) { + t.Errorf("ctx=%s body=%q, want prefix %q", ctx, body, wantBodyPrefix) + } + if i == 0 { + firstBody = body + } else if body != firstBody { + t.Errorf("ctx=%s body differs from first contextPath (resolver should pick the same target regardless of depth)", ctx) } } } @@ -418,7 +430,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } @@ -436,25 +448,34 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { const email = "alice@example.com" recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html") - if recA.Code != http.StatusFound { + if recA.Code != http.StatusOK { t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String()) } - locA := recA.Header().Get("Location") - if !strings.HasPrefix(locA, "/ProjectA/") { - t.Errorf("ProjectA Location=%q, want /ProjectA/ prefix", locA) + bodyA := recA.Body.String() + if !strings.HasPrefix(bodyA, "ProjectA/") { + t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA) } recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html") - if recB.Code != http.StatusFound { + if recB.Code != http.StatusOK { t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String()) } - locB := recB.Header().Get("Location") - if !strings.HasPrefix(locB, "/ProjectB/") { - t.Errorf("ProjectB Location=%q, want /ProjectB/ prefix", locB) + bodyB := recB.Body.String() + if !strings.HasPrefix(bodyB, "ProjectB/") { + t.Errorf("ProjectB body=%q, want a ProjectB/ file's content", bodyB) } - if locA == locB { - t.Errorf("cross-project leak: same Location for both projects: %q", locA) + if bodyA == bodyB { + t.Errorf("cross-project leak: same body served for both projects: %q", bodyA) + } + + // URL must NOT have been rewritten — neither response carries a + // Location header. Stable .archive/ links are the whole point. + if loc := recA.Header().Get("Location"); loc != "" { + t.Errorf("ProjectA: unexpected Location header %q (.archive must serve in place)", loc) + } + if loc := recB.Header().Get("Location"); loc != "" { + t.Errorf("ProjectB: unexpected Location header %q (.archive must serve in place)", loc) } // Listing each project shows only its own.