package services import ( "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" "image/png" "io" "net/http" neturl "net/url" "os" "os/exec" "path/filepath" "strings" "time" "fotbal-club/internal/config" ) // saveAsPNG decodes an image from srcPath and writes it as PNG to dstPath. func saveAsPNG(srcPath, dstPath string) error { f, err := os.Open(srcPath) if err != nil { return err } defer f.Close() img, _, err := image.Decode(f) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { return err } tmp := dstPath + ".tmp" out, err := os.Create(tmp) if err != nil { return err } // Use png encoder (imported via blank import) if err := pngEncode(out, img); err != nil { _ = out.Close() _ = os.Remove(tmp) return err } if err := out.Close(); err != nil { _ = os.Remove(tmp) return err } return os.Rename(tmp, dstPath) } // pngEncode wraps png.Encode without importing directly to top to keep imports tidy func pngEncode(w io.Writer, img image.Image) error { return png.Encode(w, img) } // ProcessFACRLogo downloads a logo from fotbal.cz (FACR) and removes background via rembg. // Returns a public URL (under /uploads) to a transparent PNG. If processing fails or the // URL is not a FACR source, returns the original URL. func ProcessFACRLogo(src string) (string, error) { u := strings.TrimSpace(src) if u == "" { return "", fmt.Errorf("empty url") } // In manual club data mode we avoid any remote FACR/fotbal.cz processing and // return the original URL unchanged so that LogoAPI/manual overrides can be // applied on the frontend without additional HTTP calls. if config.AppConfig != nil { mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode)) if mode == "manual" { return u, nil } } // Feature flag: allow disabling background removal entirely via .env if config.AppConfig != nil && !config.AppConfig.RembgEnabled { // Simply return the original URL (no processing) return u, nil } // Unwrap proxied URLs like /api/v1/proxy/image?url=... if strings.Contains(u, "/proxy/image") { if parsed, err := neturl.Parse(u); err == nil { raw := parsed.Query().Get("url") if strings.TrimSpace(raw) != "" { u = raw } } } if !isFACRURL(u) { // Only process fotbal.cz sources; leave others (logoapi etc.) unchanged return u, nil } baseUpload := strings.TrimSpace(config.AppConfig.UploadDir) if baseUpload == "" { baseUpload = "./uploads" } key := facrKeyFromURL(u) outPath := filepath.Join(baseUpload, "logos", "facr", key+".png") if info, err := os.Stat(outPath); err == nil && info.Size() > 0 { return toPublicURL(outPath), nil } // Ensure directory if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return u, err } // Download source to temp file inTmp := outPath + ".in" if err := downloadWithUA(u, inTmp); err != nil { // On download failure, fallback to original URL return u, err } defer os.Remove(inTmp) // Run Python rembg script with timeout script := filepath.Join("scripts", "rembg_remove_bg.py") outTmp := outPath + ".tmp" ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "python3", script, inTmp, outTmp) // Inherit minimal env; ensure PATH is present. If python3 not installed, this will fail. if err := cmd.Run(); err != nil { // If script missing or python not installed, simply return original FACR URL (fallback) _ = os.Remove(outTmp) return u, err } // Move tmp to destination if non-empty if fi, err := os.Stat(outTmp); err == nil && fi.Size() > 0 { if err := os.Rename(outTmp, outPath); err == nil { return toPublicURL(outPath), nil } } _ = os.Remove(outTmp) return u, errors.New("rembg produced no output") } func isFACRURL(s string) bool { pu, err := neturl.Parse(s) if err != nil { return strings.Contains(strings.ToLower(s), "fotbal.cz") } h := strings.ToLower(pu.Host) return strings.Contains(h, "fotbal.cz") } // facrKeyFromURL tries to extract a stable key (team UUID) from known FACR paths; falls back to sha1(url). func facrKeyFromURL(s string) string { // Try to parse URL path and find /media/kluby// if u, err := neturl.Parse(s); err == nil { parts := strings.Split(strings.Trim(u.Path, "/"), "/") for i := 0; i < len(parts)-1; i++ { if parts[i] == "kluby" && i+1 < len(parts) { cand := strings.TrimSpace(parts[i+1]) if cand != "" { return sanitizeFileKey(cand) } } } // Some search images may embed id in filename like _crop.jpg base := filepath.Base(u.Path) if idx := strings.Index(strings.ToLower(base), "_crop"); idx > 0 { return sanitizeFileKey(base[:idx]) } } // Fallback: sha1 of URL h := sha1.Sum([]byte(s)) return hex.EncodeToString(h[:]) } func sanitizeFileKey(s string) string { s = strings.ToLower(s) // Keep alphanum and dashes only cleaned := make([]rune, 0, len(s)) for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { cleaned = append(cleaned, r) } } if len(cleaned) == 0 { return "unknown" } return string(cleaned) } func toPublicURL(path string) string { // Assumes files live under ./uploads // Convert absolute/relative FS path to /uploads/... i := strings.Index(path, string(filepath.Separator)+"uploads"+string(filepath.Separator)) if i >= 0 { rel := path[i+1:] return "/" + filepath.ToSlash(rel) } // Try to find trailing uploads/... even if base dir differs idx := strings.Index(strings.ReplaceAll(path, "\\", "/"), "/uploads/") if idx >= 0 { return path[idx:] } // Fallback: cannot compute public URL return path } func downloadWithUA(src, outPath string) error { client := &http.Client{Timeout: 20 * time.Second} req, err := http.NewRequest("GET", src, nil) if err != nil { return err } req.Header.Set("User-Agent", "fotbal-club/1.0") resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("status %d", resp.StatusCode) } f, err := os.Create(outPath) if err != nil { return err } if _, err := io.Copy(f, resp.Body); err != nil { _ = f.Close() _ = os.Remove(outPath) return err } if err := f.Close(); err != nil { _ = os.Remove(outPath) return err } return nil }