package controllers import ( "encoding/json" "fmt" "image" "image/jpeg" "image/png" "net/http" "os" "path/filepath" "strings" "time" "fotbal-club/internal/config" "github.com/disintegration/imaging" "github.com/gin-gonic/gin" _ "golang.org/x/image/webp" ) type ImageProcessingController struct{} // ImageProcessRequest represents the request body for image processing type ImageProcessRequest struct { ImageURL string `json:"image_url"` // URL of image to process Operation string `json:"operation"` // crop, resize, rotate, flip, filter Width int `json:"width"` // Target width (for resize) Height int `json:"height"` // Target height (for resize) CropX int `json:"crop_x"` // Crop coordinates CropY int `json:"crop_y"` CropWidth int `json:"crop_width"` CropHeight int `json:"crop_height"` Rotation int `json:"rotation"` // Rotation angle (90, 180, 270) FlipH bool `json:"flip_h"` // Flip horizontal FlipV bool `json:"flip_v"` // Flip vertical Brightness float64 `json:"brightness"` // -100 to 100 Contrast float64 `json:"contrast"` // -100 to 100 Saturation float64 `json:"saturation"` // -100 to 100 Blur float64 `json:"blur"` // 0 to 10 Sharpen float64 `json:"sharpen"` // 0 to 10 Grayscale bool `json:"grayscale"` // Convert to grayscale Quality int `json:"quality"` // JPEG quality 1-100 } // ProcessImage handles image processing operations func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) { var req ImageProcessRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) return } // Validate quality if req.Quality <= 0 || req.Quality > 100 { req.Quality = 85 // Default quality } // Load image img, format, err := ctrl.loadImage(req.ImageURL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to load image: " + err.Error()}) return } // Apply operations based on request processedImg := img // Crop if req.CropWidth > 0 && req.CropHeight > 0 { processedImg = imaging.Crop(processedImg, image.Rect( req.CropX, req.CropY, req.CropX+req.CropWidth, req.CropY+req.CropHeight, )) } // Resize if req.Width > 0 || req.Height > 0 { width := req.Width height := req.Height if width == 0 { width = 0 // Auto width } if height == 0 { height = 0 // Auto height } processedImg = imaging.Resize(processedImg, width, height, imaging.Lanczos) } // Rotate if req.Rotation != 0 { switch req.Rotation % 360 { case 90, -270: processedImg = imaging.Rotate90(processedImg) case 180, -180: processedImg = imaging.Rotate180(processedImg) case 270, -90: processedImg = imaging.Rotate270(processedImg) } } // Flip if req.FlipH { processedImg = imaging.FlipH(processedImg) } if req.FlipV { processedImg = imaging.FlipV(processedImg) } // Brightness if req.Brightness != 0 { processedImg = imaging.AdjustBrightness(processedImg, req.Brightness) } // Contrast if req.Contrast != 0 { processedImg = imaging.AdjustContrast(processedImg, req.Contrast) } // Saturation if req.Saturation != 0 { processedImg = imaging.AdjustSaturation(processedImg, req.Saturation) } // Blur if req.Blur > 0 { processedImg = imaging.Blur(processedImg, req.Blur) } // Sharpen if req.Sharpen > 0 { processedImg = imaging.Sharpen(processedImg, req.Sharpen) } // Grayscale if req.Grayscale { processedImg = imaging.Grayscale(processedImg) } // Save processed image outputPath, err := ctrl.saveProcessedImage(processedImg, format, req.Quality) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save image: " + err.Error()}) return } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { xfl := strings.ToLower(xf) if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") { host = xf } } absolute := scheme + "://" + host + outputPath c.JSON(http.StatusOK, gin.H{ "url": outputPath, "absolute_url": absolute, "format": format, }) } // loadImage loads an image from a URL or local path func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) { // Check if it's a local file path if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") { // Local file under configured uploads dir base := config.AppConfig.UploadDir if strings.TrimSpace(base) == "" { base = "./uploads" } rel := strings.TrimPrefix(imageURL, "/") rel = strings.TrimPrefix(rel, "uploads/") localPath := filepath.Join(base, filepath.FromSlash(rel)) file, err := os.Open(localPath) if err != nil { return nil, "", fmt.Errorf("failed to open local file: %w", err) } defer file.Close() img, format, err := image.Decode(file) if err != nil { return nil, "", fmt.Errorf("failed to decode image: %w", err) } return img, format, nil } // HTTP URL - use custom client and headers, some CDNs block default Go UA client := &http.Client{Timeout: 20 * time.Second} req, err := http.NewRequest("GET", imageURL, nil) if err != nil { return nil, "", fmt.Errorf("failed to create request: %w", err) } // Set a recognizable UA; align with other proxy endpoints req.Header.Set("User-Agent", "fotbal-club/1.0") req.Header.Set("Accept", "image/*") // Some providers (e.g. Zonerama) may require a referer if strings.Contains(strings.ToLower(imageURL), "zonerama.com") { req.Header.Set("Referer", "https://zonerama.com/") } resp, err := client.Do(req) if err != nil { return nil, "", fmt.Errorf("failed to fetch image: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode) } img, format, err := image.Decode(resp.Body) if err != nil { return nil, "", fmt.Errorf("failed to decode image: %w", err) } return img, format, nil } // saveProcessedImage saves the processed image and returns the path func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) { // Create uploads directory if it doesn't exist (use configured UploadDir) uploadsDir := config.AppConfig.UploadDir if strings.TrimSpace(uploadsDir) == "" { uploadsDir = "./uploads" } if err := os.MkdirAll(uploadsDir, 0755); err != nil { return "", fmt.Errorf("failed to create uploads directory: %w", err) } // Generate unique filename timestamp := time.Now().UnixNano() / int64(time.Millisecond) filename := fmt.Sprintf("processed_%d.jpg", timestamp) outputPath := filepath.Join(uploadsDir, filename) // Create output file outFile, err := os.Create(outputPath) if err != nil { return "", fmt.Errorf("failed to create output file: %w", err) } defer outFile.Close() // Encode and save if format == "png" { err = png.Encode(outFile, img) } else { err = jpeg.Encode(outFile, img, &jpeg.Options{Quality: quality}) } if err != nil { return "", fmt.Errorf("failed to encode image: %w", err) } // Return relative URL return "/uploads/" + filename, nil } // CropAndUpload handles image cropping with upload func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) { // Get image from form data file, err := c.FormFile("image") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No image file provided"}) return } // Get crop parameters var cropParams struct { X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` } cropData := c.PostForm("crop_data") if cropData != "" { if err := json.Unmarshal([]byte(cropData), &cropParams); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid crop parameters"}) return } } quality := 85 if q := c.PostForm("quality"); q != "" { fmt.Sscanf(q, "%d", &quality) } maxWidth := 1500 if mw := c.PostForm("max_width"); mw != "" { fmt.Sscanf(mw, "%d", &maxWidth) } // Open uploaded file src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"}) return } defer src.Close() // Decode image img, format, err := image.Decode(src) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image format"}) return } // Apply crop if parameters provided processedImg := img if cropParams.Width > 0 && cropParams.Height > 0 { processedImg = imaging.Crop(processedImg, image.Rect( cropParams.X, cropParams.Y, cropParams.X+cropParams.Width, cropParams.Y+cropParams.Height, )) } // Resize if larger than max width bounds := processedImg.Bounds() if bounds.Dx() > maxWidth { processedImg = imaging.Resize(processedImg, maxWidth, 0, imaging.Lanczos) } // Save to uploads outputPath, err := ctrl.saveProcessedImage(processedImg, format, quality) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save processed image"}) return } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { xfl := strings.ToLower(xf) if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") { host = xf } } absolute := scheme + "://" + host + outputPath c.JSON(http.StatusOK, gin.H{ "url": outputPath, "absolute_url": absolute, }) } // QuickEdit handles common quick edits in one call func (ctrl *ImageProcessingController) QuickEdit(c *gin.Context) { var req struct { ImageURL string `json:"image_url"` Width int `json:"width"` Rotation int `json:"rotation"` FlipH bool `json:"flip_h"` FlipV bool `json:"flip_v"` Brightness float64 `json:"brightness"` Contrast float64 `json:"contrast"` Saturation float64 `json:"saturation"` Grayscale bool `json:"grayscale"` Quality int `json:"quality"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } if req.Quality <= 0 || req.Quality > 100 { req.Quality = 85 } // Load image img, format, err := ctrl.loadImage(req.ImageURL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to load image: " + err.Error()}) return } processedImg := img // Resize if width specified if req.Width > 0 { processedImg = imaging.Resize(processedImg, req.Width, 0, imaging.Lanczos) } // Rotate if req.Rotation != 0 { switch req.Rotation % 360 { case 90, -270: processedImg = imaging.Rotate90(processedImg) case 180, -180: processedImg = imaging.Rotate180(processedImg) case 270, -90: processedImg = imaging.Rotate270(processedImg) } } // Flip if req.FlipH { processedImg = imaging.FlipH(processedImg) } if req.FlipV { processedImg = imaging.FlipV(processedImg) } // Brightness/Contrast/Saturation if req.Brightness != 0 { processedImg = imaging.AdjustBrightness(processedImg, req.Brightness) } if req.Contrast != 0 { processedImg = imaging.AdjustContrast(processedImg, req.Contrast) } if req.Saturation != 0 { processedImg = imaging.AdjustSaturation(processedImg, req.Saturation) } // Grayscale if req.Grayscale { processedImg = imaging.Grayscale(processedImg) } // Save outputPath, err := ctrl.saveProcessedImage(processedImg, format, req.Quality) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save image"}) return } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { h := strings.TrimSpace(parts[0]) if h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } absolute := scheme + "://" + host + outputPath c.JSON(http.StatusOK, gin.H{ "url": outputPath, "absolute_url": absolute, }) }