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/.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. //.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) } }