package controllers import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/services" "fotbal-club/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type GalleryController struct { DB *gorm.DB } func NewGalleryController(db *gorm.DB) *GalleryController { return &GalleryController{DB: db} } // ZoneramaAlbum represents a single album with photos type ZoneramaAlbum struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Date string `json:"date"` PhotosCount int `json:"photos_count"` ViewsCount int `json:"views_count"` Photos []ZoneramaPhoto `json:"photos"` FetchedAt string `json:"fetched_at,omitempty"` } // ZoneramaPhoto represents a single photo type ZoneramaPhoto struct { ID string `json:"id"` PageURL string `json:"page_url"` Image1500 string `json:"image_1500"` } // ZoneramaProfile represents the profile with list of albums (metadata only) type ZoneramaProfile struct { InputLink string `json:"input_link"` Albums []struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Date string `json:"date"` PhotosCount int `json:"photos_count"` ViewsCount int `json:"views_count"` } `json:"albums"` FetchedAt string `json:"fetched_at"` } // GetGalleryAlbums returns all fetched albums (public) func (gc *GalleryController) GetGalleryAlbums(c *gin.Context) { albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNoContent, gin.H{"albums": []interface{}{}}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { logger.Error("Failed to parse albums JSON: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } c.JSON(http.StatusOK, gin.H{"albums": albums}) } // GetGalleryAlbum returns a single album by ID (public) func (gc *GalleryController) GetGalleryAlbum(c *gin.Context) { albumID := c.Param("id") if albumID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"}) return } albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } for _, album := range albums { if album.ID == albumID { c.JSON(http.StatusOK, album) return } } c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"}) } // GetGalleryProfile returns the profile metadata (admin) func (gc *GalleryController) GetGalleryProfile(c *gin.Context) { profileFile := filepath.Join("cache", "prefetch", "zonerama_profile.json") data, err := os.ReadFile(profileFile) if err != nil { c.JSON(http.StatusNoContent, gin.H{}) return } var profile ZoneramaProfile if err := json.Unmarshal(data, &profile); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid profile data"}) return } c.JSON(http.StatusOK, profile) } // FetchAlbum fetches a single album and adds it to the albums collection (admin) func (gc *GalleryController) FetchAlbum(c *gin.Context) { var body struct { Link string `json:"link" binding:"required"` PhotoLimit int `json:"photo_limit"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) return } // Validate it's an album URL if !strings.Contains(body.Link, "/Album/") { c.JSON(http.StatusBadRequest, gin.H{"error": "Link must be a Zonerama album URL (must contain /Album/)"}) return } // Set default photo limit if body.PhotoLimit == 0 { body.PhotoLimit = 50 // Default to 50 photos per album } // Call external API (configurable base) apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/") apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d", apiBase, url.QueryEscape(body.Link), body.PhotoLimit) logger.Info("Fetching album from Zonerama API: %s", apiURL) client := &http.Client{Timeout: 60 * time.Second} req, err := http.NewRequest("GET", apiURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } req.Header.Set("User-Agent", "fotbal-club/1.0") resp, err := client.Do(req) if err != nil { logger.Error("Album fetch failed: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch album: " + err.Error()}) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Album API returned status %d", resp.StatusCode) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Zonerama API returned status %d", resp.StatusCode)}) return } // Zonerama API returns {"input_link": "...", "albums": [...]} var apiResponse struct { InputLink string `json:"input_link"` Albums []ZoneramaAlbum `json:"albums"` } if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { logger.Error("Failed to parse album response: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse album data"}) return } if len(apiResponse.Albums) == 0 { logger.Error("No albums returned from API") c.JSON(http.StatusNotFound, gin.H{"error": "Album not found in API response"}) return } // Get the first album (should be the only one for single album endpoint) albumData := apiResponse.Albums[0] // Add fetched timestamp albumData.FetchedAt = time.Now().Format(time.RFC3339) // Load existing albums albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") var albums []ZoneramaAlbum if data, err := os.ReadFile(albumsFile); err == nil { _ = json.Unmarshal(data, &albums) } // Check if album already exists and update it, or add new found := false for i, a := range albums { if a.ID == albumData.ID { albums[i] = albumData found = true logger.Info("Updated existing album: %s", albumData.ID) break } } if !found { albums = append([]ZoneramaAlbum{albumData}, albums...) // Prepend new album logger.Info("Added new album: %s", albumData.ID) } // Save back to file if err := os.MkdirAll(filepath.Dir(albumsFile), 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cache directory"}) return } albumsJSON, err := json.MarshalIndent(albums, "", " ") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize albums"}) return } if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"}) return } logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos)) // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } c.JSON(http.StatusOK, gin.H{ "message": "Album fetched and saved successfully", "album": albumData, }) } // DeleteAlbum removes an album from the collection (admin) func (gc *GalleryController) DeleteAlbum(c *gin.Context) { albumID := c.Param("id") if albumID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"}) return } albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } // Find and remove newAlbums := []ZoneramaAlbum{} found := false for _, album := range albums { if album.ID != albumID { newAlbums = append(newAlbums, album) } else { found = true } } if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"}) return } // Save back albumsJSON, _ := json.MarshalIndent(newAlbums, "", " ") if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"}) return } logger.Info("Deleted album: %s", albumID) // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"}) } // RefreshFromZonerama triggers a refresh of the Zonerama gallery from the configured URL (admin) func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { // Load settings to get the Zonerama URL var settings struct { GalleryURL string `json:"gallery_url"` } if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil { logger.Error("Failed to load settings: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"}) return } zoneramaURL := strings.TrimSpace(settings.GalleryURL) if zoneramaURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"}) return } // Validate it's a Zonerama URL if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"}) return } logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL) // Call the refresh service in a goroutine to avoid blocking go func() { if err := services.RefreshZoneramaNow(zoneramaURL); err != nil { logger.Error("Zonerama refresh failed: %v", err) } else { logger.Info("Zonerama refresh completed successfully") // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) } } }() c.JSON(http.StatusOK, gin.H{ "message": "Zonerama refresh started", "url": zoneramaURL, }) } // ProxyImage proxies image requests to Zonerama to avoid CORS issues (public) func (gc *GalleryController) ProxyImage(c *gin.Context) { imageURL := c.Query("url") if imageURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing image URL"}) return } // Validate that the URL is from Zonerama if !strings.Contains(imageURL, "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image URL"}) return } // Fetch the image client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("GET", imageURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } req.Header.Set("User-Agent", "fotbal-club/1.0") resp, err := client.Do(req) if err != nil { logger.Error("Image fetch failed: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"}) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Image fetch returned status %d", resp.StatusCode) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"}) return } // Copy headers for key, values := range resp.Header { for _, value := range values { c.Header(key, value) } } // Set CORS headers c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET") // Stream the image c.Status(resp.StatusCode) _, err = io.Copy(c.Writer, resp.Body) if err != nil { logger.Error("Failed to stream image: %v", err) } }