ZDDC/zddc/internal/handler/archivehandler.go
ZDDC f56eb7d0f9 fix(zddc-server): per-revision .archive entries + global index with ACL filter
The .archive virtual directory now emits both <tracking>.html (highest
base rev) and <tracking>_<rev>.html (each specific base rev) so HTML
documents can deep-link to a known revision and have it resolve to the
first chronologically received copy. Modifier files (<rev>+C1 etc.) stay
reachable via the resolver but aren't surfaced in the listing.

.archive at any folder depth serves the same global index — the depth
exists so offline HTML can use ../.archive/<tracking>.html and let the
browser resolve it before the request reaches the server. The earlier
attempt at scoping listings to the contextPath subtree was wrong; gating
is purely by ACL: contextPath gates the listing endpoint, and each
entry's resolved file gets its own per-target ACL check (404 on denial,
not 403, so cross-subtree existence isn't disclosed).

Adds the first tests for the previously untested archive package, plus
end-to-end ACL coverage for the handler (cascade direction, default-deny
once any .zddc exists, anonymous denied under allow:[\"*@…\"], stable
Location across contextPaths).

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

121 lines
4 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/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 server treats every .archive request the same regardless of
// the contextPath it arrived under: the same global index is consulted, and
// access is gated only by the cascading .zddc ACL.
//
// contextPath: the URL path leading up to (but not including) .archive
// - used to gate the listing endpoint (caller must have ACL access to the
// directory the .archive virtual entry sits in — otherwise just knowing
// the folder exists would leak)
// - used as the URL prefix for the entries returned in the listing
//
// 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)
// 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 !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if filename == "" {
serveArchiveListing(cfg, idx, w, r, contextPath, email)
return
}
target, ok := archive.Resolve(idx, 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 !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
http.Redirect(w, r, "/"+target, http.StatusFound)
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
allEntries := idx.AllEntries()
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 := zddc.AllowedWithChain(chain, email)
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)
}
}