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>
121 lines
4 KiB
Go
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)
|
|
}
|
|
}
|