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>
142 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|