Compare commits

..

No commits in common. "f6dc9d557a44c5ee65327db623d96b5024c64d47" and "8cf1aa10028684f4b1cc19d3678165bc0d42fd35" have entirely different histories.

13 changed files with 353 additions and 526 deletions

View file

@ -476,57 +476,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// Check for .archive segment in the path. .archive is project-scoped // Check for .archive segment in the path
// and addressed at exactly one depth — /<project>/.archive/... — even
// though offline-built HTML files reference siblings via
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
// (/<project>/<sub>/.../.archive/...) gets a 301 to the project-rooted
// canonical so anchored links and bookmarks normalize to a single
// stable URL per tracking number. The redirect target preserves the
// path tail after .archive/ verbatim and the query string; browsers
// preserve the fragment automatically across redirects.
//
// .archive is read-only: only GET/HEAD reach the handler. Anything
// else (PUT/POST/DELETE) returns 405 here, before the file API would
// otherwise see the request. This avoids the 302→GET silent-method-
// downgrade trap and makes the contract explicit.
for i, seg := range segments { for i, seg := range segments {
if seg != cfg.IndexPath { if seg == cfg.IndexPath {
continue // contextPath is everything before .archive
} contextPath := "/" + strings.Join(segments[:i], "/")
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// segments[0] is the project; segments[i] is .archive. i==0
// means /.archive/... at the very root, with no project to
// scope by — 404 (a tracking-number reference must be project-
// rooted; cross-project tracking-number collisions otherwise
// silently pick a winner).
if i == 0 {
http.NotFound(w, r)
return
}
project := segments[0]
var filename string var filename string
if i+1 < len(segments) { if i+1 < len(segments) {
filename = strings.Join(segments[i+1:], "/") filename = strings.Join(segments[i+1:], "/")
} }
// Canonicalize anything below /<project>/.archive/. Building handler.ServeArchive(cfg, idx, w, r, contextPath, filename)
// the target by hand (rather than re-encoding) keeps any
// already-encoded characters in the original URL.RawPath
// trailing segments intact for the browser to follow.
if i > 1 {
target := "/" + project + "/" + cfg.IndexPath + "/" + filename
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
return return
} }
handler.ServeArchive(cfg, idx, w, r, project, filename)
return
} }
// Tables-system intercept: *.table.html is a virtual URL that the // Tables-system intercept: *.table.html is a virtual URL that the

View file

@ -285,138 +285,6 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
} }
} }
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 301'd
// to the canonical /<project>/.archive/... so all tracking-number references
// converge on a single stable URL per (project, tracking) regardless of the
// folder a relative "../.archive/..." link was resolved from.
func TestDispatchArchiveRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
query string
wantStatus int
wantLoc string
}{
{
"deep two segments",
"/ProjectA/Working/.archive/100.html",
"",
http.StatusMovedPermanently,
"/ProjectA/.archive/100.html",
},
{
"deep three segments",
"/ProjectA/sub/sub2/.archive/100.html",
"",
http.StatusMovedPermanently,
"/ProjectA/.archive/100.html",
},
{
"deep with trailing slash (listing)",
"/ProjectA/Working/.archive/",
"",
http.StatusMovedPermanently,
"/ProjectA/.archive/",
},
{
"deep with query string preserved",
"/ProjectA/Working/.archive/100.html",
"v=42",
http.StatusMovedPermanently,
"/ProjectA/.archive/100.html?v=42",
},
{
"already canonical (no redirect)",
"/ProjectA/.archive/100.html",
"",
// 100.html doesn't resolve in this index (no transmittal
// folders), so the handler 404s rather than redirecting.
http.StatusNotFound,
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rawURL := tc.path
if tc.query != "" {
rawURL += "?" + tc.query
}
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates
// anything (and so a 302 redirect can never silently downgrade a write
// to a GET on the canonical URL).
func TestDispatchArchiveMethodGate(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
mustMkdir(t, filepath.Join(root, "ProjectA"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
for _, path := range []string{
"/ProjectA/.archive/100.html",
"/ProjectA/Working/.archive/100.html",
} {
t.Run(method+" "+path, func(t *testing.T) {
req := httptest.NewRequest(method, path, strings.NewReader("body"))
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
}
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
}
})
}
}
}
func mustMkdir(t *testing.T, path string) { func mustMkdir(t *testing.T, path string) {
t.Helper() t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil { if err := os.MkdirAll(path, 0o755); err != nil {

View file

@ -2131,7 +2131,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>

View file

@ -896,7 +896,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>

View file

@ -1394,7 +1394,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -885,7 +885,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -1792,7 +1792,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -2192,7 +2192,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,9 +1,9 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-07 archive=v0.0.16
transmittal=v0.0.17-beta · 2026-05-07 transmittal=v0.0.16
classifier=v0.0.17-beta · 2026-05-07 classifier=v0.0.16
mdedit=v0.0.17-beta · 2026-05-07 mdedit=v0.0.16
landing=v0.0.17-beta · 2026-05-07 landing=v0.0.16
form=v0.0.17-beta · 2026-05-07 form=v0.0.16
tables=v0.0.17-beta · 2026-05-07 tables=v0.0.16
browse=v0.0.17-beta · 2026-05-07 browse=v0.0.16

View file

@ -7,7 +7,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
@ -15,45 +14,50 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// ServeArchive handles requests under a project's .archive virtual path. // ServeArchive handles requests under a .archive virtual path segment.
// //
// The dispatcher canonicalizes every .archive request to /<project>/.archive/... // .archive is exposed at every folder depth so HTML produced for offline use
// before reaching here (any deeper /<project>/sub/.../archive/... gets a 301 // can reference sibling tracking numbers via "../.archive/<tracking>.html".
// to the project-rooted form), so this handler only ever sees one shape: // In a browser the relative link is resolved before the request reaches the
// project = first URL segment, filename = whatever follows .archive/. // 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.
// //
// Permissions follow the FILE, not .archive itself. .archive is a virtual // contextPath: the URL path leading up to (but not including) .archive
// surface — it has no on-disk directory and no .zddc of its own. Two gates // - first segment selects the project bucket
// only: // - 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
// //
// 1. Listing: returned entries are filtered by the per-target file's ACL // filename: the part after .archive/ (empty for directory listing)
// chain. If the project bucket is empty (or doesn't exist in the index) func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
// the response is 404; if the user can read NO entries in a non-empty email := EmailFromContext(r)
// bucket the response is 403, so existence of an inaccessible project's decider := DeciderFromContext(r)
// archive does not leak. ctx := r.Context()
//
// 2. Resolve: only the per-target file's ACL gates access. A user with project := projectFromContextPath(contextPath)
// no project-root permission but an explicit allow on one transmittal
// folder can fetch that file's tracking-number URL; conversely, a user
// with broad project access but a narrower deny on a specific subtree
// gets 404 (not 403) on its tracking numbers — existence must not leak.
//
// Listings serve the embedded `browse` SPA on Accept: text/html and the
// JSON entry array on Accept: application/json — same content negotiation
// as ServeDirectory, so the SPA's auto-detect path-fetch works at .archive
// URLs identically to real directories.
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, filename string) {
if project == "" { if project == "" {
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound) http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
return return
} }
email := EmailFromContext(r) // ACL gate on the context directory: callers who can't reach the
decider := DeciderFromContext(r) // directory hosting this .archive shouldn't be able to query it either.
ctx := r.Context() 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 == "" { if filename == "" {
serveArchiveListing(cfg, idx, w, r, project, email, decider) serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
return return
} }
@ -63,11 +67,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
return return
} }
// Per-target ACL is the only gate. 404 (not 403) so the tracking // Per-target ACL: the resolved file may live in a subtree the caller
// number's mere existence isn't disclosed to a caller who can't // can't reach even though they could reach the contextPath. 404 (not
// actually read the resolved file. // 403) so the tracking number's mere existence isn't disclosed.
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target))) fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
if err != nil { if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err) slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
} }
@ -76,40 +80,50 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
return return
} }
// Serve in place — DO NOT redirect to the resolved file's real path. // Serve the resolved file in place — DO NOT redirect. The .archive/
// People share .archive/<tracking>.html#section URLs and expect the // URL is meant to be a stable forward-able link (people share
// link to keep tracking the latest revision; redirecting would pin // `.archive/<tracking>.html#section` and expect that to keep tracking
// the bookmark to a specific transmittal-folder snapshot. The // the latest revision). A redirect would expose the specific
// canonicalization redirect (/<project>/<sub>/.archive/X → /<project>/.archive/X) // transmittal-folder URL, and any anchor/hash bookmarked from the
// happens upstream in the dispatcher and is a different thing — it // browser bar would pin to that snapshot instead of "the latest."
// only collapses the .archive prefix, not the resolved bytes.
// //
// Cache-Control: no-cache forces conditional revalidation each load — // Cache-Control no-cache forces a conditional revalidation each
// http.ServeFile sets Last-Modified/ETag from the on-disk file, so // load — http.ServeFile sets Last-Modified/ETag from the on-disk
// when the resolver picks a newer target the ETag changes and the // file, so when the resolver picks a newer target the ETag changes
// browser refetches. // and the browser refetches.
absFile := filepath.Join(cfg.Root, filepath.FromSlash(target)) absFile := filepath.Join(cfg.Root, filepath.FromSlash(target))
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, absFile) http.ServeFile(w, r, absFile)
} }
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, email string, decider policy.Decider) { // 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() ctx := r.Context()
allEntries := idx.AllEntries(project) allEntries := idx.AllEntries(project)
if len(allEntries) == 0 { archiveBase := contextPath
// Project bucket missing or empty. 404 with no body distinction if !strings.HasSuffix(archiveBase, "/") {
// from "unknown project" — a caller probing for project names archiveBase += "/"
// gets the same shape whether or not the project exists.
http.Error(w, "Not Found", http.StatusNotFound)
return
} }
archiveBase += cfg.IndexPath + "/"
archiveBase := "/" + project + "/" + cfg.IndexPath + "/"
// ACL chains are folder-keyed and the listing typically hits the same // ACL chains are folder-keyed and the listing typically hits the same
// few directories repeatedly (one per transmittal folder), so cache // few directories repeatedly (one per transmittal folder), so cache the
// the allow/deny decision per directory rather than re-walking .zddc // allow/deny decision per directory rather than re-walking .zddc files
// files for every entry. // for every entry.
aclCache := make(map[string]bool) aclCache := make(map[string]bool)
allowed := func(targetPath string) bool { allowed := func(targetPath string) bool {
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath))) fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath)))
@ -126,7 +140,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
return v return v
} }
result := make([]listing.FileInfo, 0, len(allEntries)) var result []listing.FileInfo
for _, e := range allEntries { for _, e := range allEntries {
if !allowed(e.TargetPath) { if !allowed(e.TargetPath) {
continue continue
@ -138,68 +152,9 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
}) })
} }
// Existence-leak guard: if the user can read no entries in a
// non-empty bucket, 403 — never confirm the project's archive
// exists to a caller with no permissions in it.
if len(result) == 0 {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Vary: Accept is critical because the same URL serves either the
// JSON listing or the embedded browse SPA depending on Accept;
// without it, browsers/CDNs may serve one Accept's body for the
// other Accept value and break the SPA's JSON auto-fetch.
w.Header().Set("Vary", "Accept")
if strings.Contains(r.Header.Get("Accept"), "application/json") {
body, err := json.Marshal(result)
if err != nil {
slog.Error("encoding archive listing", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
return
}
// HTML: serve the embedded `browse` SPA. The SPA auto-detects the
// server-mode listing by re-fetching this same URL with
// Accept: application/json — that path lands in the JSON branch
// above and renders the archive entries as a sortable, filterable
// flat list.
body := apps.EmbeddedBytes("browse")
if len(body) == 0 {
// Bootstrap state: a fresh build hasn't populated browse.html
// into the embed yet. Fall through to JSON for clients that
// will still parse it.
jsonBody, err := json.Marshal(result)
if err != nil {
slog.Error("encoding archive listing (no-embed fallback)", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(jsonBody) if err := json.NewEncoder(w).Encode(result); err != nil {
return slog.Error("encoding archive listing", "err", err)
} }
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
} }

View file

@ -11,7 +11,6 @@ import (
"strings" "strings"
"testing" "testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
@ -77,46 +76,38 @@ func archiveCfg(root string) config.Config {
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"} return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"}
} }
// callArchive drives ServeArchive directly with (project, filename). The func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder {
// dispatcher is responsible for canonicalizing deeper /<project>/<sub>/
// .archive/... paths to this shape (see TestDispatchArchiveRedirect in
// the cmd package). Tests that want a specific Accept header set it on
// the recorder request before calling.
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
urlPath := "/" // Build a syntactically valid URL by escaping each segment of the
if project != "" { // contextPath and filename. The handler receives the decoded
urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/" // contextPath/filename arguments directly (as the dispatcher would have
} // decoded them); the URL itself just needs to parse for httptest.
urlPath := encodePath(contextPath) + "/" + cfg.IndexPath
if filename != "" { if filename != "" {
urlPath += url.PathEscape(filename) urlPath += "/" + url.PathEscape(filename)
} else {
urlPath += "/"
} }
req := httptest.NewRequest(http.MethodGet, urlPath, nil) req := httptest.NewRequest(http.MethodGet, urlPath, nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeArchive(cfg, idx, rec, req, project, filename) ServeArchive(cfg, idx, rec, req, contextPath, filename)
return rec return rec
} }
// callArchiveAccept is callArchive plus a custom Accept header — used to // encodePath URL-escapes each non-empty slash-separated segment of p so
// drive the listing's content-negotiation branches. // special characters like spaces and parens don't break NewRequest's URL
func callArchiveAccept(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename, accept string) *httptest.ResponseRecorder { // parser. A leading slash is preserved; an empty input becomes "/".
t.Helper() func encodePath(p string) string {
urlPath := "/" trimmed := strings.Trim(p, "/")
if project != "" { if trimmed == "" {
urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/" return ""
} }
if filename != "" { parts := strings.Split(trimmed, "/")
urlPath += url.PathEscape(filename) for i, s := range parts {
parts[i] = url.PathEscape(s)
} }
req := httptest.NewRequest(http.MethodGet, urlPath, nil) return "/" + strings.Join(parts, "/")
if accept != "" {
req.Header.Set("Accept", accept)
}
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
rec := httptest.NewRecorder()
ServeArchive(cfg, idx, rec, req, project, filename)
return rec
} }
func decodeListing(t *testing.T, body []byte) []listing.FileInfo { func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
@ -145,49 +136,36 @@ func contains(xs []string, x string) bool {
return false return false
} }
// Empty project (no first segment) is rejected at the handler. The // /.archive/ at the very root has no project segment to scope by, so it's a
// dispatcher already 404s /.archive/ before reaching here, but the handler // hard 404 — even for an admin. Stable references must include the project
// keeps a defense-in-depth guard so a future direct caller can't bypass. // directory; otherwise cross-project tracking-number collisions would silently
func TestServeArchive_EmptyProject404(t *testing.T) { // pick a winner.
func TestServeArchive_RootHasNoProjectScope404(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] allow: ["*"]
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "alice@example.com", "", "") for _, ctx := range []string{"/", ""} {
t.Run("ctx="+ctx, func(t *testing.T) {
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("listing with empty project: status %d, want 404", rec.Code) t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String())
} }
rec = callArchive(t, cfg, idx, "alice@example.com", "", "100.html") rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("resolve with empty project: status %d, want 404", rec.Code) t.Errorf("resolve at root: status %d, want 404", rec.Code)
}
})
} }
} }
// Unknown / empty project bucket returns 404 (not 403) — a probe for // .archive listings are scoped to the contextPath's first segment (the
// project names gets the same shape whether or not the project exists. // project). Each project sees only its own tracking numbers; cross-project
func TestServeArchive_UnknownProject404(t *testing.T) { // entries are invisible. Subdirectory contextPaths still resolve to the
root, idx := archiveTestRoot(t) // top-level project's bucket — a request from /ProjectA/sub/sub/.archive/
writeZddc(t, root, ".", `acl: // shows ProjectA's entries with that deeper URL prefix.
allow: ["*"]
`)
cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "")
if rec.Code != http.StatusNotFound {
t.Errorf("listing for unknown project: status %d, want 404; body=%s", rec.Code, rec.Body.String())
}
rec = callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "100.html")
if rec.Code != http.StatusNotFound {
t.Errorf("resolve in unknown project: status %d, want 404", rec.Code)
}
}
// Listing scoping: each project's bucket surfaces only its own entries,
// and entry URLs are always project-rooted (/<project>/.archive/...) —
// independent of any deeper request path the caller might have started
// from (the dispatcher canonicalizes those before reaching the handler).
func TestServeArchive_ListingScopedToProject(t *testing.T) { func TestServeArchive_ListingScopedToProject(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
@ -198,21 +176,28 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
project string contextPath string
urlPrefix string urlPrefix string
wantNames []string wantNames []string
denyNames []string denyNames []string
}{ }{
{ {
"ProjectA", "ProjectA top level",
"ProjectA", "/ProjectA",
"/ProjectA/.archive/", "/ProjectA/.archive/",
[]string{"100.html", "100_A.html", "100_~A.html"}, []string{"100.html", "100_A.html", "100_~A.html"},
[]string{"200.html", "200_0.html"}, []string{"200.html", "200_0.html"},
}, },
{ {
"ProjectB", "ProjectA deeper subpath",
"ProjectB", "/ProjectA/2025-01-01_T1 (IFR) - Title",
"/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/",
[]string{"100.html", "100_A.html", "100_~A.html"},
[]string{"200.html", "200_0.html"},
},
{
"ProjectB top level",
"/ProjectB",
"/ProjectB/.archive/", "/ProjectB/.archive/",
[]string{"200.html", "200_0.html"}, []string{"200.html", "200_0.html"},
[]string{"100.html", "100_A.html", "100_~A.html"}, []string{"100.html", "100_A.html", "100_~A.html"},
@ -221,7 +206,7 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json") rec := callArchive(t, cfg, idx, email, c.contextPath, "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
} }
@ -229,12 +214,12 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
gotNames := names(got) gotNames := names(got)
for _, want := range c.wantNames { for _, want := range c.wantNames {
if !contains(gotNames, want) { if !contains(gotNames, want) {
t.Errorf("missing %q in %s; got %v", want, c.project, gotNames) t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames)
} }
} }
for _, deny := range c.denyNames { for _, deny := range c.denyNames {
if contains(gotNames, deny) { if contains(gotNames, deny) {
t.Errorf("unexpected cross-project entry %q in %s; got %v", deny, c.project, gotNames) t.Errorf("unexpected cross-project entry %q at %s; got %v", deny, c.contextPath, gotNames)
} }
} }
for _, e := range got { for _, e := range got {
@ -246,34 +231,36 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
} }
} }
// Listing existence-leak guard: a user who can read no entries in a // Listing endpoint is gated by the contextPath ACL: callers who can't reach
// non-empty project bucket gets 403, NOT 200 with an empty list. The // the directory the .archive virtually sits in get 403 (the directory is
// project must not confirm its existence to a caller with no permissions. // known to exist; just not accessible).
func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) { func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
// Default-deny: only alice listed at any level. mallory is in no
// allow list anywhere → every per-target check returns deny → the
// filtered listing is empty → 403.
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"] allow: ["alice@example.com"]
`)
writeZddc(t, root, "ProjectA", `acl:
deny: ["mallory@example.com"]
allow: ["alice@example.com"]
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
rec := callArchiveAccept(t, cfg, idx, "mallory@example.com", "ProjectA", "", "application/json") rec := callArchive(t, cfg, idx, "mallory@example.com", "/ProjectA", "")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Errorf("mallory listing: status %d, want 403; body=%s", rec.Code, rec.Body.String()) t.Errorf("denied caller got status %d, want 403; body = %s", rec.Code, rec.Body.String())
} }
rec = callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json") rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("alice listing: status %d, want 200; body=%s", rec.Code, rec.Body.String()) t.Errorf("allowed caller got status %d, want 200; body = %s", rec.Code, rec.Body.String())
} }
} }
// Listing entries are filtered per-target by ACL: a caller denied at one // Listing entries are filtered per-target by ACL: a caller denied at a
// transmittal subtree but allowed at others sees the unblocked entries // subtree's transmittal directory sees no entries whose target lives there.
// (200 with the subset), not 403, because they have SOME read access // Excluding a user from a subdir requires an explicit deny there (the
// in the project. // cascade is "first explicit match wins, bottom-up", so a child allow list
// doesn't narrow a parent's allow:["*"]).
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
@ -281,13 +268,14 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
`) `)
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her // Deny alice on the transmittal folder where 100_~A+C1 lives, so her
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA // listing of /ProjectA/.archive/ drops that entry — but other ProjectA
// entries stay visible. // entries stay visible. (A blanket /ProjectA deny would 403 the
// listing entirely; that's covered by the previous test.)
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl: writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
deny: ["alice@example.com"] deny: ["alice@example.com"]
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
rec := callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json") rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
} }
@ -298,66 +286,138 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames) t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
} }
} }
// 100_~A+C1.html maps to a denied target — must not appear.
if contains(gotNames, "100_~A+C1.html") { // Bob has no per-target denials in either project.
t.Errorf("alice unexpectedly saw denied entry 100_~A+C1.html; got %v", gotNames) rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "")
if rec.Code != http.StatusOK {
t.Fatalf("bob ProjectB listing: status %d, want 200", rec.Code)
}
gotNames = names(decodeListing(t, rec.Body.Bytes()))
if !contains(gotNames, "200.html") {
t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames)
} }
} }
// Resolve: only the per-target ACL gates access. A caller denied on the // Direct redirect requests for a tracking number whose target the caller
// resolved file's directory gets 404 (not 403) — never confirm the // can't read return 404 (not 403, not 302) — the file's existence must not
// tracking number's existence. // leak across the ACL boundary. Cross-project tracking-number requests also
func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) { // 404 because each project's bucket is separate.
func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
// Both alice and mallory are root-allowed, but a deny on the
// transmittal folder kicks mallory out at the per-target chain
// ("first explicit match wins, bottom-up").
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["alice@example.com", "mallory@example.com"] allow: ["*"]
`) `)
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: writeZddc(t, root, "ProjectB", `acl:
deny: ["mallory@example.com"] deny: ["alice@example.com"]
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
// alice can resolve. // 200 doesn't even live in ProjectA, so the resolver itself returns 404
rec := callArchive(t, cfg, idx, "alice@example.com", "ProjectA", "100.html") // regardless of ACL — project scoping comes first.
if rec.Code != http.StatusOK { rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "200.html")
t.Errorf("alice resolve: status %d, want 200; body=%s", rec.Code, rec.Body.String()) if rec.Code != http.StatusNotFound {
t.Errorf("alice → /ProjectA/.archive/200.html: status %d, want 404 (cross-project)", rec.Code)
} }
// mallory is denied at the file's directory → 404 (existence-leak guard). // Alice in /ProjectA can resolve all of ProjectA's entries.
rec = callArchive(t, cfg, idx, "mallory@example.com", "ProjectA", "100.html") for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} {
if rec.Code != http.StatusNotFound { rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn)
t.Errorf("mallory resolve: status %d, want 404 (per-target deny); body=%s", rec.Code, rec.Body.String()) if rec.Code != http.StatusOK {
t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 200; body = %s", fn, rec.Code, rec.Body.String())
}
}
// Alice attempting ProjectB directly is denied at the contextPath ACL.
rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectB", "200.html")
if rec.Code != http.StatusForbidden {
t.Errorf("alice → /ProjectB/.archive/200.html: status %d, want 403 (denied at contextPath)", rec.Code)
}
// Bob has no denies — he can pull 200.html from /ProjectB.
rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html")
if rec.Code != http.StatusOK {
t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 200", rec.Code)
} }
} }
// Resolve is decoupled from project-root ACL: a user explicitly allowed // Cascade direction sanity check: a denial at the subtree wins over an
// at one transmittal folder but denied at the project root (and not in // allow at the parent, AND a target-level allow can rescue a user the
// any other allow list) can still fetch tracking numbers that resolve // parent didn't mention. Both directions must be exercised so future
// to that folder. .archive/ is a virtual surface — the file's own ACL // refactors of the per-target ACL helper can't silently break one.
// chain decides. func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
// Project root denies bob, but the transmittal folder under it // Root: deny default — only bob is on the list. ProjectA: explicitly
// allows him. The cascade is "first explicit match wins, bottom-up" // allow alice. So alice is rescued at ProjectA, mallory stays out
// — so the per-target chain at the file's directory hits the local // everywhere, bob stays in everywhere. Per-target ACL on resolved files
// allow first. // doesn't kick in here — both projects allow bob via the root rule.
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"]
`)
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
allow: ["bob@example.com"] allow: ["bob@example.com"]
`)
writeZddc(t, root, "ProjectA", `acl:
allow: ["alice@example.com"]
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "bob@example.com", "ProjectA", "100.html") cases := []struct {
email string
contextPath string
filename string
wantStatus int
why string
}{
{"bob@example.com", "/ProjectA", "100.html", http.StatusOK, "bob allowed at root → reaches ProjectA target"},
{"bob@example.com", "/ProjectB", "200.html", http.StatusOK, "bob allowed at root → reaches ProjectB target"},
{"alice@example.com", "/ProjectA", "100.html", http.StatusOK, "alice rescued by ProjectA allow"},
{"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"},
// mallory denied everywhere; the contextPath gate fires first.
{"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
}
for _, c := range cases {
t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) {
rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename)
if rec.Code != c.wantStatus {
t.Errorf("%s @ %s → %s: status %d, want %d (%s)", c.email, c.contextPath, c.filename, rec.Code, c.wantStatus, c.why)
}
})
}
}
// .archive serves the resolved file in place — the URL never changes.
// From any depth within the same project the resolver picks the same
// target file, so the bytes returned to the caller must be identical
// across context paths (the per-revision file URL is intentionally
// hidden so external links remain stable).
func TestServeArchive_ServedBytesStableAcrossDepthWithinProject(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
`)
cfg := archiveCfg(root)
wantBodyPrefix := "ProjectA/2025-01-01_T1 (IFR) - Title/100_A"
var firstBody string
for i, ctx := range []string{
"/ProjectA",
"/ProjectA/2025-01-01_T1 (IFR) - Title",
"/ProjectA/2025-02-01_T2 (RTN) - Comments",
} {
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("bob resolve: status %d, want 200 (per-target allow rescues him); body=%s", rec.Code, rec.Body.String()) t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
continue
} }
if loc := rec.Header().Get("Location"); loc != "" { if loc := rec.Header().Get("Location"); loc != "" {
t.Errorf("unexpected Location=%q (.archive must serve in place)", loc) t.Errorf("ctx=%s unexpected Location=%q (.archive must serve in place)", ctx, loc)
}
body := rec.Body.String()
if !strings.HasPrefix(body, wantBodyPrefix) {
t.Errorf("ctx=%s body=%q, want prefix %q", ctx, body, wantBodyPrefix)
}
if i == 0 {
firstBody = body
} else if body != firstBody {
t.Errorf("ctx=%s body differs from first contextPath (resolver should pick the same target regardless of depth)", ctx)
}
} }
} }
@ -387,7 +447,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
cfg := archiveCfg(root) cfg := archiveCfg(root)
const email = "alice@example.com" const email = "alice@example.com"
recA := callArchive(t, cfg, idx, email, "ProjectA", "123.html") recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html")
if recA.Code != http.StatusOK { if recA.Code != http.StatusOK {
t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String()) t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
} }
@ -396,7 +456,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA) t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA)
} }
recB := callArchive(t, cfg, idx, email, "ProjectB", "123.html") recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html")
if recB.Code != http.StatusOK { if recB.Code != http.StatusOK {
t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String()) t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
} }
@ -419,26 +479,59 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
} }
// Listing each project shows only its own. // Listing each project shows only its own.
for _, c := range []struct{ project, mustHave string }{ for _, c := range []struct{ ctx, mustHave, mustNot string }{
{"ProjectA", "ProjectA"}, {"/ProjectA", "ProjectA", "ProjectB"},
{"ProjectB", "ProjectB"}, {"/ProjectB", "ProjectB", "ProjectA"},
} { } {
rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json") rec := callArchive(t, cfg, idx, email, c.ctx, "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("listing %s: status %d", c.project, rec.Code) t.Fatalf("listing %s: status %d", c.ctx, rec.Code)
} }
got := decodeListing(t, rec.Body.Bytes()) got := decodeListing(t, rec.Body.Bytes())
for _, e := range got { for _, e := range got {
if !strings.Contains(e.URL, "/"+c.mustHave+"/") { if !strings.Contains(e.URL, "/"+c.mustHave+"/") {
t.Errorf("project=%s entry URL %q lacks /%s/ segment", c.project, e.URL, c.mustHave) t.Errorf("ctx=%s entry URL %q lacks /%s/ segment", c.ctx, e.URL, c.mustHave)
} }
} }
} }
} }
// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched
// caller is denied. Verify this applies to listing entries too — a target
// in a directory with a restrictive .zddc is not surfaced to outsiders even
// though the file exists.
func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) {
root, idx := archiveTestRoot(t)
// Root .zddc allows alice only. No "*" — so anyone else is default-denied.
writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"]
`)
cfg := archiveCfg(root)
// alice sees everything she's allowed to in ProjectA.
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
if rec.Code != http.StatusOK {
t.Fatalf("alice listing: status %d, want 200", rec.Code)
}
if len(decodeListing(t, rec.Body.Bytes())) == 0 {
t.Errorf("alice listing was empty, want entries")
}
// Charlie isn't on any list → default-deny → 403 even for the listing.
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "")
if rec.Code != http.StatusForbidden {
t.Errorf("charlie listing: status %d, want 403", rec.Code)
}
// Direct resolve: contextPath ACL fires first → 403.
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html")
if rec.Code != http.StatusForbidden {
t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code)
}
}
// Empty email never matches — even an `allow: ["*"]` policy denies it, // Empty email never matches — even an `allow: ["*"]` policy denies it,
// per the existing zddc package contract. .archive must honor it: the // which is the existing zddc package contract. .archive must honor it.
// listing 403s (empty filtered set) and resolves return 404.
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
@ -446,80 +539,30 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
rec := callArchiveAccept(t, cfg, idx, "", "ProjectA", "", "application/json") rec := callArchive(t, cfg, idx, "", "/ProjectA", "")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Errorf("anonymous listing: status %d, want 403", rec.Code) t.Errorf("anonymous listing: status %d, want 403", rec.Code)
} }
rec = callArchive(t, cfg, idx, "", "ProjectA", "100.html")
if rec.Code != http.StatusNotFound {
t.Errorf("anonymous resolve: status %d, want 404", rec.Code)
}
} }
// Listing content negotiation: Accept: application/json returns the // projectFromContextPath is the canonical place to derive the project key
// JSON entry array; Accept: text/html returns the embedded `browse` SPA // from the .archive contextPath. Pin the edge cases.
// bytes (tested by content-type and the embedded ETag header). func TestProjectFromContextPath(t *testing.T) {
// The same URL must serve both, with Vary: Accept set. cases := []struct {
func TestServeArchive_ListingContentNegotiation(t *testing.T) { ctx string
root, idx := archiveTestRoot(t) want string
writeZddc(t, root, ".", `acl: }{
allow: ["*"] {"/ProjectA", "ProjectA"},
`) {"/ProjectA/", "ProjectA"},
cfg := archiveCfg(root) {"/ProjectA/sub/sub", "ProjectA"},
const email = "alice@example.com" {"/", ""},
{"", ""},
// JSON branch. {"ProjectA/sub", "ProjectA"},
recJSON := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "application/json")
if recJSON.Code != http.StatusOK {
t.Fatalf("JSON listing: status %d, want 200; body=%s", recJSON.Code, recJSON.Body.String())
} }
if ct := recJSON.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { for _, c := range cases {
t.Errorf("JSON listing content-type=%q, want application/json", ct) got := projectFromContextPath(c.ctx)
} if got != c.want {
if vary := recJSON.Header().Get("Vary"); !strings.Contains(vary, "Accept") { t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want)
t.Errorf("JSON listing missing Vary: Accept (got %q)", vary)
}
_ = decodeListing(t, recJSON.Body.Bytes())
// HTML branch — falls back to JSON only if the embedded slot is
// empty, which won't be the case in a normal test run (the embed is
// populated at compile time). Verify either branch is sane.
recHTML := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "text/html")
if recHTML.Code != http.StatusOK {
t.Fatalf("HTML listing: status %d, want 200; body=%s", recHTML.Code, recHTML.Body.String())
}
ct := recHTML.Header().Get("Content-Type")
switch {
case strings.Contains(ct, "text/html"):
// Normal path: embedded browse bytes were served.
if etag := recHTML.Header().Get("ETag"); etag == "" || etag != `"`+apps.EmbeddedETag("browse")+`"` {
t.Errorf("HTML listing ETag=%q, want %q", etag, `"`+apps.EmbeddedETag("browse")+`"`)
}
if src := recHTML.Header().Get("X-ZDDC-Source"); src != "embedded:browse" {
t.Errorf("HTML listing X-ZDDC-Source=%q, want embedded:browse", src)
}
case strings.Contains(ct, "application/json"):
// Bootstrap path: embedded slot empty (e.g. fresh build before
// browse.html has been populated). JSON fallback is acceptable
// — confirm it parses as a listing.
_ = decodeListing(t, recHTML.Body.Bytes())
default:
t.Errorf("HTML listing unexpected content-type=%q", ct)
}
// Conditional GET: re-fetching with If-None-Match for the JSON ETag
// short-circuits to 304.
etagJSON := recJSON.Header().Get("ETag")
if etagJSON != "" {
req := httptest.NewRequest(http.MethodGet, "/ProjectA/.archive/", nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("If-None-Match", etagJSON)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
rec304 := httptest.NewRecorder()
ServeArchive(cfg, idx, rec304, req, "ProjectA", "")
if rec304.Code != http.StatusNotModified {
t.Errorf("conditional JSON GET: status %d, want 304", rec304.Code)
} }
} }
} }

View file

@ -741,7 +741,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="form-title">ZDDC Form</span> <span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -665,7 +665,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span> <span class="build-timestamp">v0.0.16</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">