ZDDC/zddc/internal/handler/archivehandler.go
ZDDC 9fce18cd45 feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:

* fix(archive): nested-party + folder-type cascade
  transmittalIsUnderVisibleParty short-circuited on the first matched
  party segment, only checking the immediately-next segment for a
  folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
  toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
  any-segment party match. Eight new Playwright cases pin the contract
  in tests/archive-cascade.spec.js.

* refactor(zddc-server): scope .archive index by project
  archive.Index now buckets by top-level segment
  (.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
  take a project parameter; handler extracts it from contextPath's first
  segment. /.archive/ at root returns 404 — stable refs must be
  project-rooted. Within-project (tracking, rev) collisions emit a WARN
  with both paths. Cross-project tracking-number duplicates no longer
  collide.

* perf(zddc-server): lazy-load expensive bits of the profile page
  serveProfilePage now ships a minimal shell: Email, EmailHeader,
  IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
  editable scaffolds populate client-side via /.profile/access. Subtree-
  admin scaffolds live in <template id="tmpl-subtree-admin">; pure
  non-admins receive no live admin form. ScanZddcFiles now memoized,
  invalidated on .zddc events by the watcher and writer helpers.

* feat: lockstep release + redesigned releases page
  sh build.sh --release [version|alpha|beta] is the canonical lockstep
  cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
  version. zddc-server binaries now committed under website/releases/
  with the same cascade chain as HTML tools (no more Codeberg release-
  asset publication). zddc/release.sh deprecated (kept as a guard);
  shared/publish-codeberg-release.sh removed.

  Releases page redesigned as an action-first install guide: hero +
  version dropdown that rewires every download link, channel chips for
  always-visible alpha/beta access (state-aware labels: "tracks stable"
  vs "active dev"), Path A (zddc-server with platform auto-detect from
  UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
  narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.

  Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
  resolves at the end of every build. Bootstrap-friendly: zddc-server
  artifact checks skip until the first lockstep cut anchors the chain.

Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.

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

142 lines
4.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/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)
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 !zddc.AllowedWithChain(chain, email) {
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 !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
http.Redirect(w, r, "/"+target, http.StatusFound)
}
// 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) {
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 := 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)
}
}