package controllers import ( "bytes" "fmt" "image" "image/png" _ "image/gif" _ "image/jpeg" "mime/multipart" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" ) // 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) { entries, err := os.ReadDir(filepath.Join("uploads", "sponsors")) 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 } _ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755) saved := 0 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(filepath.Join("uploads", "sponsors"), base+".png") outPath := filepath.Join("uploads", "sponsors", outName) var buf bytes.Buffer if _, err := io.Copy(&buf, src); err == nil { if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil { saved++ } else { // Fallback: write original bytes with original extension rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name) rawPath := filepath.Join("uploads", "sponsors", rawName) _ = os.WriteFile(rawPath, buf.Bytes(), 0o644) saved++ } } _ = src.Close() } } ctx.JSON(http.StatusOK, gin.H{"saved": saved}) } // 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("uploads", "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}) } // GetQR returns the current QR image URL if present func (c *ScoreboardController) GetQR(ctx *gin.Context) { path := filepath.Join("uploads", "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() _ = os.MkdirAll("uploads", 0o755) out, err := os.Create(filepath.Join("uploads", "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}) }