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