package controllers import ( "bytes" "fmt" "image" _ "image/gif" _ "image/jpeg" "image/png" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "github.com/gin-gonic/gin" ) func uploadsBaseDir() string { dir := config.AppConfig.UploadDir if strings.TrimSpace(dir) == "" { dir = "./uploads" } return dir } // sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath. func sanitizeAndWriteLogo(data []byte, outPath string) error { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return err } b := img.Bounds() minX, minY := b.Max.X, b.Max.Y maxX, maxY := b.Min.X, b.Min.Y for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { r, g, bl, a := img.At(x, y).RGBA() if a <= 0x10 { // near transparent continue } rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8) if rr > 245 && gg > 245 && bb > 245 { // nearly white background continue } if x < minX { minX = x } if y < minY { minY = y } if x > maxX { maxX = x } if y > maxY { maxY = y } } } if minX >= maxX || minY >= maxY { // fallback to full image minX, minY = b.Min.X, b.Min.Y maxX, maxY = b.Max.X-1, b.Max.Y-1 } cw, ch := maxX-minX+1, maxY-minY+1 nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch)) for y := 0; y < ch; y++ { for x := 0; x < cw; x++ { nrgba.Set(x, y, img.At(minX+x, minY+y)) } } // resize to 64px height using nearest-neighbor targetH := 64 if ch != targetH { targetW := int(float64(cw) * float64(targetH) / float64(ch)) if targetW < 1 { targetW = 1 } resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH)) for y2 := 0; y2 < targetH; y2++ { srcY := y2 * ch / targetH for x2 := 0; x2 < targetW; x2++ { srcX := x2 * cw / targetW c := nrgba.NRGBAAt(srcX, srcY) resized.SetNRGBA(x2, y2, c) } } nrgba = resized } // write PNG if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err } f, err := os.Create(outPath) if err != nil { return err } defer f.Close() return png.Encode(f, nrgba) } // ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc. func ensureUniqueFilename(dir, name string) string { base := name ext := "" if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] ext = name[i:] } try := name idx := 1 for { if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) { return try } try = fmt.Sprintf("%s-%d%s", base, idx, ext) idx++ } } // ListSponsors returns list of sponsor logo URLs under /uploads/sponsors func (c *ScoreboardController) ListSponsors(ctx *gin.Context) { sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors") entries, err := os.ReadDir(sponsorDir) if err != nil { ctx.JSON(http.StatusOK, []string{}) return } out := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } name := e.Name() lower := strings.ToLower(name) if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") { out = append(out, "/uploads/sponsors/"+name) } } ctx.JSON(http.StatusOK, out) } // UploadSponsors accepts multipart form files under field name "files" (or single "file") func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) { if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"}) return } sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors") _ = os.MkdirAll(sponsorDir, 0o755) saved := 0 created := make([]string, 0, 8) if ctx.Request.MultipartForm != nil { files := ctx.Request.MultipartForm.File["files"] if len(files) == 0 { if f, hdr, err := ctx.Request.FormFile("file"); err == nil { _ = f.Close() files = []*multipart.FileHeader{hdr} } } for _, hdr := range files { if hdr == nil { continue } src, err := hdr.Open() if err != nil { continue } // do not defer: loop name := sanitizeFilename(hdr.Filename) if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) } base := name if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] } outName := ensureUniqueFilename(sponsorDir, base+".png") outPath := filepath.Join(sponsorDir, outName) var buf bytes.Buffer if _, err := io.Copy(&buf, src); err == nil { if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil { saved++ created = append(created, "/uploads/sponsors/"+outName) } else { // Fallback: write original bytes with original extension rawName := ensureUniqueFilename(sponsorDir, name) rawPath := filepath.Join(sponsorDir, rawName) _ = os.WriteFile(rawPath, buf.Bytes(), 0o644) saved++ created = append(created, "/uploads/sponsors/"+rawName) } } _ = src.Close() } } ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created}) } // DeleteSponsor deletes a sponsor logo by filename (?name=) func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) { name := sanitizeFilename(ctx.Query("name")) if name == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"}) return } p := filepath.Join(uploadsBaseDir(), "sponsors", name) if _, err := os.Stat(p); os.IsNotExist(err) { ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if err := os.Remove(p); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"}) return } ctx.JSON(http.StatusOK, gin.H{"ok": true}) } // DeleteQR deletes the QR image (uploads/qr.png) if present func (c *ScoreboardController) DeleteQR(ctx *gin.Context) { path := filepath.Join(uploadsBaseDir(), "qr.png") if _, err := os.Stat(path); os.IsNotExist(err) { ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if err := os.Remove(path); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"}) return } ctx.JSON(http.StatusOK, gin.H{"ok": true}) } // GetQR returns the current QR image URL if present func (c *ScoreboardController) GetQR(ctx *gin.Context) { path := filepath.Join(uploadsBaseDir(), "qr.png") if _, err := os.Stat(path); err == nil { ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"}) return } ctx.JSON(http.StatusOK, gin.H{"qr": ""}) } // UploadQR accepts a single file and stores/overwrites uploads/qr.png func (c *ScoreboardController) UploadQR(ctx *gin.Context) { file, _, err := ctx.Request.FormFile("file") if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"}) return } defer file.Close() dir := uploadsBaseDir() _ = os.MkdirAll(dir, 0o755) out, err := os.Create(filepath.Join(dir, "qr.png")) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"}) return } defer out.Close() if _, err := io.Copy(out, file); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"}) return } ctx.JSON(http.StatusOK, gin.H{"ok": true}) } // PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use. // Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors. func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) { var body struct { IDs []uint `json:"ids"` } _ = ctx.ShouldBindJSON(&body) var list []models.Sponsor q := c.DB.Model(&models.Sponsor{}) if len(body.IDs) > 0 { q = q.Where("id IN ?", body.IDs) } else { q = q.Where("is_active = ?", true) } if err := q.Find(&list).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors") _ = os.MkdirAll(sponsorDir, 0o755) created := make([]string, 0, len(list)) for _, s := range list { logo := strings.TrimSpace(s.LogoURL) if logo == "" { continue } var data []byte if strings.HasPrefix(logo, "/uploads/") { p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/")) if b, err := os.ReadFile(p); err == nil { data = b } else { continue } } else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") { resp, err := http.Get(logo) if err != nil { continue } func() { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return } b, _ := io.ReadAll(resp.Body) if len(b) > 0 { data = b } }() if len(data) == 0 { continue } } else { continue } base := sanitizeFilename(s.Name) if base == "" { seg := logo if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] } if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] } base = sanitizeFilename(seg) if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) } } outName := ensureUniqueFilename(sponsorDir, base+".png") outPath := filepath.Join(sponsorDir, outName) if err := sanitizeAndWriteLogo(data, outPath); err != nil { // fallback to raw write rawName := ensureUniqueFilename(sponsorDir, base+".png") _ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644) created = append(created, "/uploads/sponsors/"+rawName) } else { created = append(created, "/uploads/sponsors/"+outName) } } ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created}) }