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>
160 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|