package server import ( "context" "encoding/json" "net/http" "strings" "time" "github.com/libnovel/scraper/internal/storage" ) // handleGetRanking returns all ranking items sorted by rank ascending. // Cover fields that hold a MinIO object key (e.g. "novelfire.net/assets/book-covers/slug.jpg") // are rewritten to a /api/cover/{key} proxy URL so the UI can fetch them // without knowing about the internal MinIO topology. func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { items, err := s.store.ReadRankingItems(r.Context()) if err != nil { s.log.Error("ranking read failed", "err", err) http.Error(w, `{"error":"failed to read ranking"}`, http.StatusInternalServerError) return } if items == nil { items = []storage.RankingItem{} } // Rewrite cover keys to proxy URLs. // Keys stored by triggerDirectScrape look like: // "novelfire.net/assets/book-covers/shadow-slave.jpg" // We expose them as: // "/api/cover/novelfire.net/shadow-slave" // (the handler strips the domain and slug from the path, reconstructs the key) for i := range items { cover := items[i].Cover if cover != "" && !strings.HasPrefix(cover, "http") { // cover is a MinIO key; extract domain + slug for the proxy path. // Key format: {domain}/assets/book-covers/{slug}.jpg parts := strings.SplitN(cover, "/assets/book-covers/", 2) if len(parts) == 2 { domain := parts[0] slug := strings.TrimSuffix(parts[1], ".jpg") items[i].Cover = "/api/cover/" + domain + "/" + slug } } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(items) } // handleGetCover proxies a cover image stored in the MinIO browse bucket. // Route: GET /api/cover/{domain}/{slug} // It reconstructs the MinIO key as {domain}/assets/book-covers/{slug}.jpg, // fetches the object, and streams it to the client. // Returns 404 if not yet downloaded, allowing the UI to fall back to the // original source URL. func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) { domain := r.PathValue("domain") slug := r.PathValue("slug") if domain == "" || slug == "" { http.Error(w, "missing domain or slug", http.StatusBadRequest) return } key := s.store.BrowseCoverKey(domain, slug) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() data, contentType, ok, err := s.store.GetBrowseAsset(ctx, key) if err != nil { s.log.Warn("handleGetCover: GetBrowseAsset error", "key", key, "err", err) http.Error(w, "storage error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } if contentType == "" { contentType = "image/jpeg" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(data) }