mirror of
https://github.com/Dvorinka/ClubLogos.git
synced 2026-06-04 03:52:57 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
*.sqlite
|
||||
*.db
|
||||
logos/
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -0,0 +1,30 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
main
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Database files
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Logos directory
|
||||
logos/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -0,0 +1,36 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates sqlite-libs
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /root/logos
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./main"]
|
||||
@@ -0,0 +1,242 @@
|
||||
# 🇨🇿 Czech Clubs Logos API - Backend
|
||||
|
||||
A Go backend API for serving high-quality, transparent background logos of Czech football & futsal clubs. Logos are mapped by FAČR UUIDs to ensure consistency across projects.
|
||||
|
||||
## 🔧 Tech Stack
|
||||
|
||||
- **Go 1.21+** - Fast and efficient backend
|
||||
- **Gin** - Web framework
|
||||
- **SQLite** - Lightweight database
|
||||
- **FAČR API** - External club data source
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ⚽ Fetch Czech clubs metadata from FAČR Scraper API
|
||||
- 🖼️ Upload & store full-quality transparent logos (SVG/PNG)
|
||||
- 🔄 Reuse FAČR UUID as a unique identifier
|
||||
- 🌐 Serve logos through a simple CDN-style API
|
||||
- 📝 Optional metadata (club name, city, colors, competition)
|
||||
- 📦 Self-hosted with local or cloud storage
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21 or higher
|
||||
- GCC (for SQLite compilation)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
3. Run the server:
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
The API will start on `http://localhost:8080`
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
### Build and run with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t czech-clubs-backend .
|
||||
docker run -p 8080:8080 -v $(pwd)/logos:/root/logos czech-clubs-backend
|
||||
```
|
||||
|
||||
### Or use Docker Compose (recommended):
|
||||
|
||||
From the root directory:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.go # Application entrypoint
|
||||
├── handlers.go # API route handlers
|
||||
├── facr_client.go # FAČR API client
|
||||
├── go.mod # Go dependencies
|
||||
├── go.sum # Dependency checksums
|
||||
├── Dockerfile # Docker configuration
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🚀 API Endpoints
|
||||
|
||||
### Health Check
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
Returns server health status.
|
||||
|
||||
### Search Clubs
|
||||
```
|
||||
GET /clubs/search?q=sparta
|
||||
```
|
||||
Search for clubs by name. Proxies to FAČR API with fallback to demo data.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "22222222-3333-4444-5555-666666666666",
|
||||
"name": "AC Sparta Praha",
|
||||
"city": "Praha",
|
||||
"type": "football"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Club Info
|
||||
```
|
||||
GET /clubs/:id
|
||||
```
|
||||
Get detailed club information by UUID.
|
||||
|
||||
### Upload Logo
|
||||
```
|
||||
POST /logos/:id
|
||||
```
|
||||
Upload a logo for a specific club UUID.
|
||||
|
||||
**Parameters:**
|
||||
- `file` (multipart/form-data) - SVG or PNG file
|
||||
|
||||
**Example with curl:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/logos/22222222-3333-4444-5555-666666666666 \
|
||||
-F "file=@sparta.svg"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": "22222222-3333-4444-5555-666666666666",
|
||||
"filename": "22222222-3333-4444-5555-666666666666.svg",
|
||||
"size": 12345,
|
||||
"message": "logo uploaded successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Logo
|
||||
```
|
||||
GET /logos/:id
|
||||
```
|
||||
Retrieve a logo by UUID. Returns the image file.
|
||||
|
||||
**Response Headers:**
|
||||
- `Content-Type`: `image/svg+xml` or `image/png`
|
||||
- `Cache-Control`: `public, max-age=31536000`
|
||||
|
||||
### Get Logo with Metadata
|
||||
```
|
||||
GET /logos/:id/json
|
||||
```
|
||||
Get logo metadata in JSON format.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "22222222-3333-4444-5555-666666666666",
|
||||
"club_name": "AC Sparta Praha",
|
||||
"club_city": "Praha",
|
||||
"club_type": "football",
|
||||
"logo_url": "http://localhost:8080/logos/22222222-3333-4444-5555-666666666666",
|
||||
"file_size": 12345,
|
||||
"created_at": "2024-01-01T12:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Database Schema
|
||||
|
||||
### logos table
|
||||
|
||||
| Column | Type | Description |
|
||||
|---------------|----------|--------------------------------|
|
||||
| id | TEXT | UUID (Primary Key) |
|
||||
| club_name | TEXT | Club name |
|
||||
| club_city | TEXT | City |
|
||||
| club_type | TEXT | Type (football/futsal) |
|
||||
| file_extension| TEXT | .svg or .png |
|
||||
| file_size | INTEGER | File size in bytes |
|
||||
| created_at | DATETIME | Creation timestamp |
|
||||
| updated_at | DATETIME | Last update timestamp |
|
||||
|
||||
## 🔌 External APIs
|
||||
|
||||
### FAČR Scraper API
|
||||
- **Base URL:** `https://facr.tdvorak.dev`
|
||||
- **Endpoints Used:**
|
||||
- `/club/search?q={query}` - Search clubs
|
||||
- `/club/{id}` - Get club details
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
- UUID format validation
|
||||
- File type validation (SVG/PNG only)
|
||||
- CORS enabled for frontend integration
|
||||
- Input sanitization
|
||||
|
||||
## 🌟 Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|----------------------|
|
||||
| PORT | 8080 | Server port |
|
||||
|
||||
## 📝 Example Workflow
|
||||
|
||||
1. **Search for a club:**
|
||||
```bash
|
||||
curl "http://localhost:8080/clubs/search?q=Slavia"
|
||||
```
|
||||
|
||||
2. **Upload logo for club:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/logos/11111111-2222-3333-4444-555555555555 \
|
||||
-F "file=@slavia.svg"
|
||||
```
|
||||
|
||||
3. **Get logo:**
|
||||
```bash
|
||||
curl http://localhost:8080/logos/11111111-2222-3333-4444-555555555555 \
|
||||
-o slavia.svg
|
||||
```
|
||||
|
||||
4. **Get logo with metadata:**
|
||||
```bash
|
||||
curl http://localhost:8080/logos/11111111-2222-3333-4444-555555555555/json
|
||||
```
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
- ✍️ Admin authentication
|
||||
- 🎨 Auto background remover integration
|
||||
- 🔎 Advanced logo search capabilities
|
||||
- 📦 Cloud storage support (S3, R2, Supabase)
|
||||
- 🗄️ PostgreSQL support
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is part of the Czech Clubs Logos API system.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for Czech Football
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const FACR_API_BASE = "https://facr.tdvorak.dev"
|
||||
|
||||
type FACRClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewFACRClient() *FACRClient {
|
||||
return &FACRClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Club represents a club from the FAČR API
|
||||
type Club struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
City string `json:"city,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
}
|
||||
|
||||
// FACRSearchResponse represents the search response from FAČR API
|
||||
type FACRSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Count int `json:"count"`
|
||||
Results []FACRSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// FACRSearchResult represents a single search result from FAČR API
|
||||
type FACRSearchResult struct {
|
||||
Name string `json:"name"`
|
||||
ClubID string `json:"club_id"`
|
||||
ClubType string `json:"club_type"`
|
||||
URL string `json:"url"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// FACRClubResponse represents the club details response from FAČR API
|
||||
type FACRClubResponse struct {
|
||||
Name string `json:"name"`
|
||||
ClubID string `json:"club_id"`
|
||||
ClubType string `json:"club_type"`
|
||||
ClubInternalID string `json:"club_internal_id"`
|
||||
URL string `json:"url"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Address string `json:"address"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// SearchClubs searches for clubs by query
|
||||
func (c *FACRClient) SearchClubs(query string) ([]Club, error) {
|
||||
url := fmt.Sprintf("%s/club/search?q=%s", FACR_API_BASE, query)
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from FAČR API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("FAČR API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var searchResp FACRSearchResponse
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Convert FACR results to our Club format
|
||||
clubs := make([]Club, 0, len(searchResp.Results))
|
||||
for _, result := range searchResp.Results {
|
||||
// Extract city from address if available
|
||||
city := extractCityFromAddress(result.Address)
|
||||
|
||||
clubs = append(clubs, Club{
|
||||
ID: result.ClubID,
|
||||
Name: result.Name,
|
||||
City: city,
|
||||
Type: result.ClubType,
|
||||
Website: "", // Not provided in search results
|
||||
})
|
||||
}
|
||||
|
||||
return clubs, nil
|
||||
}
|
||||
|
||||
// extractCityFromAddress extracts city name from address string
|
||||
// Address format: "Street, PostalCode City"
|
||||
func extractCityFromAddress(address string) string {
|
||||
if address == "" {
|
||||
return ""
|
||||
}
|
||||
// Try to extract city after postal code (format: "Street, 12345 City")
|
||||
parts := strings.Split(address, ",")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
// Get the part after comma and split by space
|
||||
lastPart := strings.TrimSpace(parts[len(parts)-1])
|
||||
words := strings.Fields(lastPart)
|
||||
if len(words) >= 2 {
|
||||
// Skip postal code (first word) and return the rest as city
|
||||
return strings.Join(words[1:], " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetClub gets a club by ID
|
||||
func (c *FACRClient) GetClub(id string) (*Club, error) {
|
||||
// Try football first, then futsal
|
||||
url := fmt.Sprintf("%s/club/football/%s", FACR_API_BASE, id)
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from FAČR API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("FAČR API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var clubResp FACRClubResponse
|
||||
if err := json.Unmarshal(body, &clubResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Extract city from address
|
||||
city := extractCityFromAddress(clubResp.Address)
|
||||
|
||||
club := &Club{
|
||||
ID: clubResp.ClubID,
|
||||
Name: clubResp.Name,
|
||||
City: city,
|
||||
Type: clubResp.ClubType,
|
||||
Website: "", // Not provided in FACR API
|
||||
}
|
||||
|
||||
return club, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
module czech-clubs-logos-api
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/mattn/go-sqlite3 v1.14.19
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
|
||||
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,468 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var facrClient = NewFACRClient()
|
||||
|
||||
// ==================== Club Handlers ====================
|
||||
|
||||
func searchClubs(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
clubs, err := facrClient.SearchClubs(query)
|
||||
if err != nil {
|
||||
// Return demo data if FAČR API is unavailable
|
||||
c.JSON(http.StatusOK, getDemoClubs(query))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clubs)
|
||||
}
|
||||
|
||||
func getClub(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "club ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
club, err := facrClient.GetClub(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "club not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, club)
|
||||
}
|
||||
|
||||
// Demo data fallback
|
||||
func getDemoClubs(query string) []Club {
|
||||
demoClubs := []Club{
|
||||
{
|
||||
ID: "11111111-2222-3333-4444-555555555555",
|
||||
Name: "SK Slavia Praha",
|
||||
City: "Praha",
|
||||
Type: "football",
|
||||
Website: "https://www.slavia.cz",
|
||||
},
|
||||
{
|
||||
ID: "22222222-3333-4444-5555-666666666666",
|
||||
Name: "AC Sparta Praha",
|
||||
City: "Praha",
|
||||
Type: "football",
|
||||
Website: "https://www.sparta.cz",
|
||||
},
|
||||
{
|
||||
ID: "33333333-4444-5555-6666-777777777777",
|
||||
Name: "FC Viktoria Plzeň",
|
||||
City: "Plzeň",
|
||||
Type: "football",
|
||||
Website: "https://www.fcviktoria.cz",
|
||||
},
|
||||
{
|
||||
ID: "44444444-5555-6666-7777-888888888888",
|
||||
Name: "FC Baník Ostrava",
|
||||
City: "Ostrava",
|
||||
Type: "football",
|
||||
Website: "https://www.fcb.cz",
|
||||
},
|
||||
{
|
||||
ID: "55555555-6666-7777-8888-999999999999",
|
||||
Name: "SK Sigma Olomouc",
|
||||
City: "Olomouc",
|
||||
Type: "football",
|
||||
Website: "https://www.sigmafotbal.cz",
|
||||
},
|
||||
}
|
||||
|
||||
var results []Club
|
||||
lowerQuery := strings.ToLower(query)
|
||||
for _, club := range demoClubs {
|
||||
if strings.Contains(strings.ToLower(club.Name), lowerQuery) {
|
||||
results = append(results, club)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ==================== Logo Handlers ====================
|
||||
|
||||
type LogoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
ClubName string `json:"club_name"`
|
||||
ClubCity string `json:"club_city,omitempty"`
|
||||
ClubType string `json:"club_type,omitempty"`
|
||||
ClubWebsite string `json:"club_website,omitempty"`
|
||||
HasSVG bool `json:"has_svg"`
|
||||
HasPNG bool `json:"has_png"`
|
||||
PrimaryFormat string `json:"primary_format"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
LogoURLSVG string `json:"logo_url_svg,omitempty"`
|
||||
LogoURLPNG string `json:"logo_url_png,omitempty"`
|
||||
FileSizeSVG int64 `json:"file_size_svg,omitempty"`
|
||||
FileSizePNG int64 `json:"file_size_png,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// getLogo returns the logo file (PNG preferred, SVG fallback)
|
||||
func getLogo(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logo ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check format preference from query
|
||||
format := c.Query("format") // can be "svg" or "png"
|
||||
|
||||
var logoPath string
|
||||
var contentType string
|
||||
var found bool
|
||||
|
||||
// Try PNG first (primary format)
|
||||
if format == "" || format == "png" {
|
||||
pngPath := filepath.Join("./logos/png", id+".png")
|
||||
if _, err := os.Stat(pngPath); err == nil {
|
||||
logoPath = pngPath
|
||||
contentType = "image/png"
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// Try SVG if PNG not found or explicitly requested
|
||||
if !found && (format == "" || format == "svg") {
|
||||
svgPath := filepath.Join("./logos/svg", id+".svg")
|
||||
if _, err := os.Stat(svgPath); err == nil {
|
||||
logoPath = svgPath
|
||||
contentType = "image/svg+xml"
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "logo not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.File(logoPath)
|
||||
}
|
||||
|
||||
func getLogoWithMetadata(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logo ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get metadata from database
|
||||
var metadata LogoMetadata
|
||||
var hasSVG, hasPNG int
|
||||
err := db.QueryRow(`
|
||||
SELECT id, club_name, club_city, club_type, club_website,
|
||||
has_svg, has_png, primary_format,
|
||||
file_size_svg, file_size_png,
|
||||
created_at, updated_at
|
||||
FROM logos WHERE id = ?
|
||||
`, id).Scan(
|
||||
&metadata.ID,
|
||||
&metadata.ClubName,
|
||||
&metadata.ClubCity,
|
||||
&metadata.ClubType,
|
||||
&metadata.ClubWebsite,
|
||||
&hasSVG,
|
||||
&hasPNG,
|
||||
&metadata.PrimaryFormat,
|
||||
&metadata.FileSizeSVG,
|
||||
&metadata.FileSizePNG,
|
||||
&metadata.CreatedAt,
|
||||
&metadata.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "logo not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Database error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
metadata.HasSVG = hasSVG == 1
|
||||
metadata.HasPNG = hasPNG == 1
|
||||
|
||||
// Construct logo URLs
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
|
||||
// Primary URL (PNG preferred)
|
||||
if metadata.HasPNG {
|
||||
metadata.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, id)
|
||||
} else if metadata.HasSVG {
|
||||
metadata.LogoURL = fmt.Sprintf("%s/logos/%s?format=svg", baseURL, id)
|
||||
}
|
||||
|
||||
// Format-specific URLs
|
||||
if metadata.HasSVG {
|
||||
metadata.LogoURLSVG = fmt.Sprintf("%s/logos/%s?format=svg", baseURL, id)
|
||||
}
|
||||
if metadata.HasPNG {
|
||||
metadata.LogoURLPNG = fmt.Sprintf("%s/logos/%s?format=png", baseURL, id)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, metadata)
|
||||
}
|
||||
|
||||
// List all logos
|
||||
func listLogos(c *gin.Context) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, club_name, club_city, club_type, club_website,
|
||||
has_svg, has_png, primary_format,
|
||||
created_at, updated_at
|
||||
FROM logos
|
||||
ORDER BY club_name
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logos []LogoMetadata
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
|
||||
for rows.Next() {
|
||||
var logo LogoMetadata
|
||||
var hasSVG, hasPNG int
|
||||
|
||||
err := rows.Scan(
|
||||
&logo.ID,
|
||||
&logo.ClubName,
|
||||
&logo.ClubCity,
|
||||
&logo.ClubType,
|
||||
&logo.ClubWebsite,
|
||||
&hasSVG,
|
||||
&hasPNG,
|
||||
&logo.PrimaryFormat,
|
||||
&logo.CreatedAt,
|
||||
&logo.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logo.HasSVG = hasSVG == 1
|
||||
logo.HasPNG = hasPNG == 1
|
||||
|
||||
if logo.HasPNG {
|
||||
logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, logo.ID)
|
||||
} else if logo.HasSVG {
|
||||
logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=svg", baseURL, logo.ID)
|
||||
}
|
||||
|
||||
logos = append(logos, logo)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, logos)
|
||||
}
|
||||
|
||||
func uploadLogo(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logo ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get club name from form (required)
|
||||
clubName := c.PostForm("club_name")
|
||||
if clubName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "club_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
clubCity := c.PostForm("club_city")
|
||||
clubType := c.PostForm("club_type")
|
||||
clubWebsite := c.PostForm("club_website")
|
||||
|
||||
// Get uploaded file
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if ext != ".svg" && ext != ".png" && ext != ".pdf" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .svg, .png and .pdf files are allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine storage paths
|
||||
var svgPath, pngPath string
|
||||
var hasSVG, hasPNG int
|
||||
var sizeSVG, sizePNG int64
|
||||
|
||||
if ext == ".svg" || ext == ".pdf" {
|
||||
pngPath = filepath.Join("./logos/png", id+".png")
|
||||
|
||||
if ext == ".svg" {
|
||||
svgPath = filepath.Join("./logos/svg", id+".svg")
|
||||
|
||||
// Save SVG
|
||||
if err := c.SaveUploadedFile(file, svgPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save SVG file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get SVG file size
|
||||
if stat, err := os.Stat(svgPath); err == nil {
|
||||
sizeSVG = stat.Size()
|
||||
}
|
||||
hasSVG = 1
|
||||
|
||||
// Convert SVG to PNG
|
||||
log.Printf("Converting SVG to PNG for club: %s", clubName)
|
||||
if err := ConvertSVGToPNG(svgPath, pngPath, 512); err != nil {
|
||||
log.Printf("Warning: Failed to convert SVG to PNG: %v", err)
|
||||
// Don't fail the upload, just log the warning
|
||||
} else {
|
||||
// Optimize PNG
|
||||
if err := OptimizePNG(pngPath); err != nil {
|
||||
log.Printf("Warning: Failed to optimize PNG: %v", err)
|
||||
}
|
||||
// Get PNG file size
|
||||
if stat, err := os.Stat(pngPath); err == nil {
|
||||
sizePNG = stat.Size()
|
||||
hasPNG = 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// PDF file - convert directly to PNG
|
||||
pdfTempPath := filepath.Join("./logos/temp", id+".pdf")
|
||||
os.MkdirAll("./logos/temp", 0755)
|
||||
|
||||
if err := c.SaveUploadedFile(file, pdfTempPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save PDF file"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Converting PDF to PNG for club: %s", clubName)
|
||||
if err := ConvertPDFToPNG(pdfTempPath, pngPath, 512); err != nil {
|
||||
log.Printf("Error: Failed to convert PDF to PNG: %v", err)
|
||||
os.Remove(pdfTempPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to convert PDF to PNG"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up temp PDF
|
||||
os.Remove(pdfTempPath)
|
||||
|
||||
// Optimize PNG
|
||||
if err := OptimizePNG(pngPath); err != nil {
|
||||
log.Printf("Warning: Failed to optimize PNG: %v", err)
|
||||
}
|
||||
|
||||
// Get PNG file size
|
||||
if stat, err := os.Stat(pngPath); err == nil {
|
||||
sizePNG = stat.Size()
|
||||
hasPNG = 1
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// PNG upload
|
||||
pngPath = filepath.Join("./logos/png", id+".png")
|
||||
|
||||
if err := c.SaveUploadedFile(file, pngPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save PNG file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Optimize PNG
|
||||
if err := OptimizePNG(pngPath); err != nil {
|
||||
log.Printf("Warning: Failed to optimize PNG: %v", err)
|
||||
}
|
||||
|
||||
// Get PNG file size
|
||||
if stat, err := os.Stat(pngPath); err == nil {
|
||||
sizePNG = stat.Size()
|
||||
}
|
||||
hasPNG = 1
|
||||
}
|
||||
|
||||
// Save metadata to database
|
||||
_, err = db.Exec(`
|
||||
INSERT OR REPLACE INTO logos (
|
||||
id, club_name, club_city, club_type, club_website,
|
||||
has_svg, has_png, primary_format,
|
||||
file_size_svg, file_size_png, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'png', ?, ?, CURRENT_TIMESTAMP)
|
||||
`, id, clubName, clubCity, clubType, clubWebsite, hasSVG, hasPNG, sizeSVG, sizePNG)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Database error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"id": id,
|
||||
"club_name": clubName,
|
||||
"has_svg": hasSVG == 1,
|
||||
"has_png": hasPNG == 1,
|
||||
"size_svg": sizeSVG,
|
||||
"size_png": sizePNG,
|
||||
"message": "logo uploaded successfully",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ConvertSVGToPNG converts an SVG file to PNG format
|
||||
// Uses ImageMagick/Inkscape if available, otherwise returns error
|
||||
func ConvertSVGToPNG(svgPath, pngPath string, width int) error {
|
||||
// Try using ImageMagick convert command
|
||||
if err := convertWithImageMagick(svgPath, pngPath, width); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try using Inkscape
|
||||
if err := convertWithInkscape(svgPath, pngPath, width); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If no converter available, copy SVG as fallback and log warning
|
||||
return fmt.Errorf("no SVG converter available (install ImageMagick or Inkscape)")
|
||||
}
|
||||
|
||||
// ConvertPDFToPNG converts a PDF file to PNG format
|
||||
// Uses ImageMagick/Ghostscript if available, otherwise returns error
|
||||
func ConvertPDFToPNG(pdfPath, pngPath string, width int) error {
|
||||
// Try using ImageMagick convert command (requires Ghostscript)
|
||||
cmd := exec.Command("convert",
|
||||
"-background", "none",
|
||||
"-density", "300",
|
||||
"-resize", fmt.Sprintf("%dx%d", width, width),
|
||||
fmt.Sprintf("%s[0]", pdfPath), // Only first page
|
||||
pngPath,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("PDF conversion failed (install ImageMagick and Ghostscript): %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertWithImageMagick(svgPath, pngPath string, width int) error {
|
||||
cmd := exec.Command("convert",
|
||||
"-background", "none",
|
||||
"-density", "300",
|
||||
"-resize", fmt.Sprintf("%dx%d", width, width),
|
||||
svgPath,
|
||||
pngPath,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("imagemagick conversion failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertWithInkscape(svgPath, pngPath string, width int) error {
|
||||
cmd := exec.Command("inkscape",
|
||||
"--export-type=png",
|
||||
fmt.Sprintf("--export-filename=%s", pngPath),
|
||||
fmt.Sprintf("--export-width=%d", width),
|
||||
svgPath,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("inkscape conversion failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OptimizePNG optimizes a PNG file (basic implementation)
|
||||
func OptimizePNG(pngPath string) error {
|
||||
// Open the file
|
||||
file, err := os.Open(pngPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Decode the image
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
tempPath := pngPath + ".tmp"
|
||||
tempFile, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tempFile.Close()
|
||||
|
||||
// Encode with compression
|
||||
encoder := png.Encoder{
|
||||
CompressionLevel: png.BestCompression,
|
||||
}
|
||||
|
||||
if err := encoder.Encode(tempFile, img); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace original with optimized
|
||||
if err := os.Rename(tempPath, pngPath); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSVGFile checks if the file is an SVG by reading its content
|
||||
func IsSVGFile(file io.Reader) (bool, error) {
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
|
||||
content := string(buf[:n])
|
||||
return bytes.Contains([]byte(content), []byte("<svg")) ||
|
||||
bytes.Contains([]byte(content), []byte("<?xml")), nil
|
||||
}
|
||||
|
||||
// ValidateImageFile validates that a file is a valid SVG or PNG
|
||||
func ValidateImageFile(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if ext == ".svg" {
|
||||
isSVG, err := IsSVGFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isSVG {
|
||||
return "", fmt.Errorf("file is not a valid SVG")
|
||||
}
|
||||
return "svg", nil
|
||||
}
|
||||
|
||||
if ext == ".png" {
|
||||
_, err := png.Decode(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file is not a valid PNG: %v", err)
|
||||
}
|
||||
return "png", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func main() {
|
||||
// Initialize database
|
||||
var err error
|
||||
db, err = initDB()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize database:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create logos directory if it doesn't exist
|
||||
if err := os.MkdirAll("./logos", 0755); err != nil {
|
||||
log.Fatal("Failed to create logos directory:", err)
|
||||
}
|
||||
|
||||
// Create subdirectories for SVG and PNG
|
||||
if err := os.MkdirAll("./logos/svg", 0755); err != nil {
|
||||
log.Fatal("Failed to create logos/svg directory:", err)
|
||||
}
|
||||
if err := os.MkdirAll("./logos/png", 0755); err != nil {
|
||||
log.Fatal("Failed to create logos/png directory:", err)
|
||||
}
|
||||
|
||||
// Initialize Gin router
|
||||
r := gin.Default()
|
||||
|
||||
// CORS middleware
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Routes
|
||||
setupRoutes(r)
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
log.Printf("🚀 Server starting on port %s", port)
|
||||
log.Printf("📁 Logos directory: ./logos")
|
||||
log.Printf("💾 Database: ./data/db.sqlite")
|
||||
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine) {
|
||||
// Health check
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Club routes (proxy to FAČR API)
|
||||
clubs := r.Group("/clubs")
|
||||
{
|
||||
clubs.GET("/search", searchClubs)
|
||||
clubs.GET("/:id", getClub)
|
||||
}
|
||||
|
||||
// Logo routes
|
||||
logos := r.Group("/logos")
|
||||
{
|
||||
logos.GET("", listLogos)
|
||||
logos.GET("/:id", getLogo)
|
||||
logos.GET("/:id/json", getLogoWithMetadata)
|
||||
logos.POST("/:id", uploadLogo)
|
||||
}
|
||||
}
|
||||
|
||||
func initDB() (*sql.DB, error) {
|
||||
// Create data directory if it doesn't exist
|
||||
if err := os.MkdirAll("./data", 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", "./data/db.sqlite")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create tables
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS logos (
|
||||
id TEXT PRIMARY KEY,
|
||||
club_name TEXT NOT NULL,
|
||||
club_city TEXT,
|
||||
club_type TEXT,
|
||||
club_website TEXT,
|
||||
has_svg INTEGER DEFAULT 0,
|
||||
has_png INTEGER DEFAULT 0,
|
||||
primary_format TEXT DEFAULT 'png',
|
||||
file_size_svg INTEGER,
|
||||
file_size_png INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("✓ Database initialized")
|
||||
return db, nil
|
||||
}
|
||||
Reference in New Issue
Block a user