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. // // contextPath: the URL path leading up to (but not including) .archive (e.g. "/Project-123") // 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 check on the context directory 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 == "" { // Directory listing: return all trackingNumber.html entries this user can access serveArchiveListing(cfg, idx, w, r, contextPath, email) return } // Single file resolve target, ok := archive.Resolve(idx, filename) if !ok { http.Error(w, "Not Found", http.StatusNotFound) return } // ACL check on the resolved file's directory (prevents info leakage) 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 } // 302 redirect to the real file path 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.AllTrackingEntries() archiveBase := contextPath + "/" + cfg.IndexPath + "/" var result []listing.FileInfo for _, item := range allEntries { if item.HighestPath == "" { continue } // ACL check on the resolved file's directory fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(item.HighestPath))) chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) if err != nil || !zddc.AllowedWithChain(chain, email) { continue } entryName := item.TrackingNumber + ".html" result = append(result, listing.FileInfo{ Name: entryName, URL: archiveBase + entryName, 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) } }