mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
241 lines
6.3 KiB
Go
241 lines
6.3 KiB
Go
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/<id>/
|
|
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 <id>_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
|
|
}
|