ZDDC/zddc/internal/handler/archivehandler.go
ZDDC fe28a73f59 feat(archive): serve in-place instead of redirecting (preserves #anchor links)
Resolved `.archive/<tracking>.html` URLs now serve the target file's
bytes inline via http.ServeFile with Cache-Control: no-cache, replacing
the previous 302 redirect to the per-transmittal URL.

Why: external links like `.archive/<tracking>.html#section` are meant
to track the latest revision. A redirect exposes the snapshot URL — any
forwarded link then pins to that snapshot instead of "latest." Serving
in-place keeps the `.archive/` URL stable as the resolver's "current"
target moves over time.

Cache-Control: no-cache is intentional. Each load revalidates against
the on-disk file's Last-Modified/ETag, so when a new revision lands the
resolver picks it and the browser refetches transparently.

ACL is unchanged: enforced on both the `.archive` context directory and
the resolved target file (per-target denial returns 404, not 403, to
avoid disclosing that a tracking number exists in a hidden subtree).

archivehandler_test.go status expectations updated 302 → 200; fixture
bodies adjusted for body-content verification of the in-place serve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:29 -05:00

160 lines
5.8 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"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 .archive virtual path segment.
//
// .archive is exposed at every folder depth so HTML produced for offline use
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
// In a browser the relative link is resolved before the request reaches the
// server, so the contextPath the request arrives under is significant: its
// FIRST segment is the project, and the .archive listing/resolver is scoped
// to that project's bucket. This avoids cross-project collisions when the
// same tracking number is issued under multiple projects.
//
// contextPath: the URL path leading up to (but not including) .archive
// - first segment selects the project bucket
// - used to gate the listing endpoint via cascading .zddc ACL
// - used as the URL prefix for the entries returned in the listing
// - empty (root /.archive/) returns 404 — refs must be project-rooted
//
// filename: the part after .archive/ (empty for directory listing)
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
project := projectFromContextPath(contextPath)
if project == "" {
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
return
}
// ACL gate on the context directory: callers who can't reach the
// directory hosting this .archive shouldn't be able to query it either.
dirPath := strings.TrimPrefix(contextPath, "/")
dirPath = strings.TrimSuffix(dirPath, "/")
absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath))
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if filename == "" {
serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
return
}
target, ok := archive.Resolve(idx, project, filename)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Per-target ACL: the resolved file may live in a subtree the caller
// can't reach even though they could reach the contextPath. 404 (not
// 403) so the tracking number's mere existence isn't disclosed.
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 the resolved file in place — DO NOT redirect. The .archive/
// URL is meant to be a stable forward-able link (people share
// `.archive/<tracking>.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
// contextPath, which is the project bucket key for archive lookups. Returns
// "" for "/" or "" (root .archive — has no project).
func projectFromContextPath(contextPath string) string {
cleaned := strings.Trim(contextPath, "/")
if cleaned == "" {
return ""
}
if i := strings.IndexByte(cleaned, '/'); i >= 0 {
return cleaned[:i]
}
return cleaned
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
decider := DeciderFromContext(r)
ctx := r.Context()
allEntries := idx.AllEntries(project)
archiveBase := contextPath
if !strings.HasSuffix(archiveBase, "/") {
archiveBase += "/"
}
archiveBase += 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
}
var result []listing.FileInfo
for _, e := range allEntries {
if !allowed(e.TargetPath) {
continue
}
result = append(result, listing.FileInfo{
Name: e.URLName,
URL: archiveBase + e.URLName,
IsDir: false,
})
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(result); err != nil {
slog.Error("encoding archive listing", "err", err)
}
}