mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #65
This commit is contained in:
@@ -107,7 +107,7 @@ func LoadConfig() {
|
||||
IdleTimeout: time.Duration(getEnvAsInt("IDLE_TIMEOUT", 120)) * time.Second,
|
||||
|
||||
// Security
|
||||
ContentSecurityPolicy: getEnv("CONTENT_SECURITY_POLICY", "default-src 'self' data: blob: https: http:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src *;"),
|
||||
ContentSecurityPolicy: getEnv("CONTENT_SECURITY_POLICY", "default-src 'self' data: blob: https: http:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src *; frame-ancestors 'self';"),
|
||||
|
||||
// File upload settings
|
||||
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
|
||||
|
||||
@@ -41,7 +41,7 @@ func (ac *AIController) GenerateAboutPage(c *gin.Context) {
|
||||
audience = "fanoušci klubu"
|
||||
}
|
||||
|
||||
system := "Jsi zkušený editor klubových webů. Pomůžeš napsat stránku 'O klubu'. Odpovídej česky, srozumitelně a profesionálně."
|
||||
system := "Jsi zkušený editor klubových webů. Pomůžeš napsat stránku 'O klubu'. DŮLEŽITÉ: Odpovídej v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary. Odpovídej česky, srozumitelně a profesionálně."
|
||||
user := fmt.Sprintf("Poznámky k vytvoření stránky O klubu:\n---\n%s\n---\nNázev klubu: %s\nPreferovaný styl: %s\nCílové publikum: %s\n\nPovinné požadavky:\n1) Zachovej fakta z poznámek a rozšiř je o kontext (historie, hodnoty, úspěchy, tým, zázemí, komunita).\n2) Rozděl text do sekcí s HTML nadpisy (h2/h3) a odstavci (p). Můžeš použít seznamy (ul/li) tam, kde to dává smysl. Bez inline stylů.\n3) Napiš krátký podnadpis (subtitle) vystihující náladu klubu.\n4) Vytvoř SEO titulek (do 60 znaků) a SEO popis (do 160 znaků).\n5) Odpověz POUZE JSON: {\"title\":\"...\", \"subtitle\":\"...\", \"html\":\"...\", \"seo_title\":\"...\", \"seo_description\":\"...\"}.\n6) HTML pole musí obsahovat kompletní obsah stránky dle požadavků.\n", strings.TrimSpace(req.Prompt), clubName, style, audience)
|
||||
|
||||
baseURL := getOpenRouterBaseURL()
|
||||
@@ -206,7 +206,7 @@ func (ac *AIController) GenerateBlog(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Build instruction in Czech - emphasizing user text as primary source, but allow expansion if needed
|
||||
system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). Píšeš česky, srozumitelně a čtivě pro fotbalové fanoušky. HTML výstup bez inline stylů."
|
||||
system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary (např. místo 'nevděkovaný' použij 'nevděčný'). Píšeš srozumitelně a čtivě pro fotbalové fanoušky. HTML výstup bez inline stylů."
|
||||
user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov.\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru, detaily kolem událostí z textu uživatele. Buď čtivý a zajímavý.\n3) Pokud je text dostatečně dlouhý, pouze ho strukturuj do HTML s nadpisy a odstavci.\n4) Vygeneruj výstižný titulek vycházející z obsahu textu uživatele.\n5) Vytvoř URL slug (3-5 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}\n7) HTML obsah = text uživatele + rozvinutí (pokud nutné) strukturovaný do HTML tagů (h2, p, ul, ol). BEZ inline stylů.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience))
|
||||
|
||||
// Prepare OpenRouter request
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ArticleController handles article-related requests
|
||||
type ArticleController struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// NewArticleController creates a new ArticleController
|
||||
func NewArticleController(db *gorm.DB) *ArticleController {
|
||||
return &ArticleController{DB: db}
|
||||
}
|
||||
|
||||
// CreateArticleRequest represents the request body for creating an article
|
||||
type CreateArticleRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Published *bool `json:"published"`
|
||||
PublishedAt *string `json:"published_at"`
|
||||
Featured *bool `json:"featured"`
|
||||
Slug string `json:"slug"`
|
||||
SeoTitle string `json:"seo_title"`
|
||||
SeoDescription string `json:"seo_description"`
|
||||
OgImageURL string `json:"og_image_url"`
|
||||
GalleryAlbumID string `json:"gallery_album_id"`
|
||||
GalleryAlbumURL string `json:"gallery_album_url"`
|
||||
GalleryPhotoIDs []string `json:"gallery_photo_ids"`
|
||||
YouTubeVideoID string `json:"youtube_video_id"`
|
||||
YouTubeVideoTitle string `json:"youtube_video_title"`
|
||||
YouTubeVideoURL string `json:"youtube_video_url"`
|
||||
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
|
||||
}
|
||||
|
||||
// CreateArticle creates a new article with comprehensive error handling
|
||||
func (ac *ArticleController) CreateArticle(c *gin.Context) {
|
||||
// 1. Check authentication
|
||||
uVal, ok := c.Get("user")
|
||||
if !ok {
|
||||
logger.Error("CreateArticle: User not authenticated")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Uživatel není přihlášen"})
|
||||
return
|
||||
}
|
||||
user, ok := uVal.(*models.User)
|
||||
if !ok || user == nil {
|
||||
logger.Error("CreateArticle: Invalid user object in context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Neplatný uživatel"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("CreateArticle: Request from user %d (%s)", user.ID, user.Email)
|
||||
|
||||
// 2. Parse and validate request body
|
||||
var req CreateArticleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.Error("CreateArticle: Invalid request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Neplatná data požadavku",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Log the request
|
||||
logger.Info("CreateArticle: Creating article '%s' by user %d", req.Title, user.ID)
|
||||
|
||||
// 3. Generate or validate slug
|
||||
slug := strings.TrimSpace(req.Slug)
|
||||
if slug == "" {
|
||||
slug = makeSlug(req.Title)
|
||||
logger.Info("CreateArticle: Generated slug '%s' from title", slug)
|
||||
}
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("article-%d", time.Now().Unix())
|
||||
logger.Warn("CreateArticle: Using fallback slug '%s'", slug)
|
||||
}
|
||||
|
||||
// 4. Ensure unique slug
|
||||
originalSlug := slug
|
||||
for i := 0; i < 50; i++ {
|
||||
var count int64
|
||||
if err := ac.DB.Model(&models.Article{}).Where("slug = ?", slug).Count(&count).Error; err != nil {
|
||||
logger.Error("CreateArticle: Error checking slug uniqueness: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", originalSlug, i+1)
|
||||
logger.Info("CreateArticle: Slug collision, trying '%s'", slug)
|
||||
}
|
||||
|
||||
// 5. Resolve or create category
|
||||
var categoryID *uint
|
||||
if req.CategoryID != nil && *req.CategoryID > 0 {
|
||||
categoryID = req.CategoryID
|
||||
logger.Info("CreateArticle: Using category ID %d", *categoryID)
|
||||
} else if strings.TrimSpace(req.CategoryName) != "" {
|
||||
categoryName := strings.TrimSpace(req.CategoryName)
|
||||
var category models.Category
|
||||
err := ac.DB.Where("name = ?", categoryName).First(&category).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new category
|
||||
category = models.Category{Name: categoryName}
|
||||
if err := ac.DB.Create(&category).Error; err != nil {
|
||||
logger.Error("CreateArticle: Error creating category '%s': %v", categoryName, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit kategorii"})
|
||||
return
|
||||
}
|
||||
logger.Info("CreateArticle: Created new category '%s' with ID %d", categoryName, category.ID)
|
||||
} else if err != nil {
|
||||
logger.Error("CreateArticle: Error finding category: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při hledání kategorie"})
|
||||
return
|
||||
}
|
||||
catID := category.ID
|
||||
categoryID = &catID
|
||||
}
|
||||
|
||||
// 6. Set published status and published_at
|
||||
published := false
|
||||
if req.Published != nil {
|
||||
published = *req.Published
|
||||
}
|
||||
var publishedAt *time.Time
|
||||
if req.PublishedAt != nil && strings.TrimSpace(*req.PublishedAt) != "" {
|
||||
if t, err := time.Parse(time.RFC3339, *req.PublishedAt); err == nil {
|
||||
publishedAt = &t
|
||||
} else {
|
||||
logger.Warn("CreateArticle: Could not parse published_at: %v", err)
|
||||
}
|
||||
}
|
||||
if published && publishedAt == nil {
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
// 7. Set featured status
|
||||
featured := false
|
||||
if req.Featured != nil {
|
||||
featured = *req.Featured
|
||||
}
|
||||
|
||||
// 8. Calculate estimated read time
|
||||
readTime := computeEstimatedReadMinutes(req.Content)
|
||||
logger.Info("CreateArticle: Estimated read time: %d minutes", readTime)
|
||||
|
||||
// 9. Prepare SEO fields with fallbacks
|
||||
seoTitle := strings.TrimSpace(req.SeoTitle)
|
||||
if seoTitle == "" {
|
||||
seoTitle = strings.TrimSpace(req.Title)
|
||||
}
|
||||
seoDesc := strings.TrimSpace(req.SeoDescription)
|
||||
if seoDesc == "" {
|
||||
seoDesc = deriveSeoDescription(req.Content)
|
||||
}
|
||||
|
||||
// 10. Set default image if empty
|
||||
imageURL := strings.TrimSpace(req.ImageURL)
|
||||
if imageURL == "" {
|
||||
imageURL = "/dist/img/logo-club-empty.svg"
|
||||
logger.Info("CreateArticle: Using default image")
|
||||
}
|
||||
|
||||
// 11. Create the article object
|
||||
authorID := user.ID
|
||||
article := models.Article{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Content: req.Content,
|
||||
AuthorID: &authorID,
|
||||
CategoryID: categoryID,
|
||||
Published: published,
|
||||
PublishedAt: publishedAt,
|
||||
ImageURL: imageURL,
|
||||
ReadTime: readTime,
|
||||
Slug: slug,
|
||||
SEOTitle: seoTitle,
|
||||
SEODescription: seoDesc,
|
||||
Featured: featured,
|
||||
}
|
||||
|
||||
// 12. Set optional OG image
|
||||
if trimmed := strings.TrimSpace(req.OgImageURL); trimmed != "" {
|
||||
article.OGImageURL = trimmed
|
||||
}
|
||||
|
||||
// 13. Set gallery fields
|
||||
if trimmed := strings.TrimSpace(req.GalleryAlbumID); trimmed != "" {
|
||||
article.GalleryAlbumID = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(req.GalleryAlbumURL); trimmed != "" {
|
||||
article.GalleryAlbumURL = trimmed
|
||||
}
|
||||
if len(req.GalleryPhotoIDs) > 0 {
|
||||
article.GalleryPhotoIDs = strings.Join(req.GalleryPhotoIDs, ",")
|
||||
}
|
||||
|
||||
// 14. Set YouTube fields
|
||||
if trimmed := strings.TrimSpace(req.YouTubeVideoID); trimmed != "" {
|
||||
article.YouTubeVideoID = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(req.YouTubeVideoTitle); trimmed != "" {
|
||||
article.YouTubeVideoTitle = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(req.YouTubeVideoURL); trimmed != "" {
|
||||
article.YouTubeVideoURL = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(req.YouTubeVideoThumbnail); trimmed != "" {
|
||||
article.YouTubeVideoThumbnail = trimmed
|
||||
}
|
||||
|
||||
// 15. Save to database
|
||||
if err := ac.DB.Create(&article).Error; err != nil {
|
||||
logger.Error("CreateArticle: Database error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Nelze vytvořit článek",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("CreateArticle: Successfully created article ID=%d, slug=%s", article.ID, article.Slug)
|
||||
|
||||
// 16. Track file usage (async)
|
||||
go func() {
|
||||
fileTracker := services.NewFileTracker(ac.DB)
|
||||
fileTracker.TrackArticleFiles(&article)
|
||||
}()
|
||||
|
||||
// 17. Reload with associations for response
|
||||
ac.DB.Preload("Author").Preload("Category").First(&article, article.ID)
|
||||
|
||||
// 18. Return success response
|
||||
c.JSON(http.StatusCreated, article)
|
||||
}
|
||||
|
||||
// Note: Helper functions makeSlug, computeEstimatedReadMinutes, and deriveSeoDescription
|
||||
// are defined in base_controller.go and shared across the controllers package
|
||||
@@ -553,6 +553,11 @@ func (bc *BaseController) GetArticle(c *gin.Context) {
|
||||
if art.ReadTime == 0 {
|
||||
art.ReadTime = computeEstimatedReadMinutes(art.Content)
|
||||
}
|
||||
// Load match link if exists
|
||||
var matchLink models.ArticleMatchLink
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
c.JSON(http.StatusOK, art)
|
||||
}
|
||||
|
||||
@@ -712,6 +717,11 @@ func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
|
||||
if art.ReadTime == 0 {
|
||||
art.ReadTime = computeEstimatedReadMinutes(art.Content)
|
||||
}
|
||||
// Load match link if exists
|
||||
var matchLink models.ArticleMatchLink
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
c.JSON(http.StatusOK, art)
|
||||
}
|
||||
|
||||
@@ -2619,6 +2629,14 @@ func (bc *BaseController) CreateArticle(c *gin.Context) {
|
||||
|
||||
// Best-effort: refresh published articles cache
|
||||
go bc.writeArticlesCache()
|
||||
|
||||
// Reload article with associations and match link for complete response
|
||||
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
|
||||
var matchLink models.ArticleMatchLink
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, art)
|
||||
}
|
||||
|
||||
@@ -2787,6 +2805,14 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
||||
|
||||
// Best-effort: refresh published articles cache
|
||||
go bc.writeArticlesCache()
|
||||
|
||||
// Reload article with associations and match link for complete response
|
||||
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
|
||||
var matchLink models.ArticleMatchLink
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, art)
|
||||
}
|
||||
|
||||
@@ -2855,6 +2881,26 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
|
||||
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
||||
}
|
||||
}
|
||||
// Batch load match links for all articles
|
||||
if len(items) > 0 {
|
||||
articleIDs := make([]uint, len(items))
|
||||
for i, art := range items {
|
||||
articleIDs[i] = art.ID
|
||||
}
|
||||
var matchLinks []models.ArticleMatchLink
|
||||
bc.DB.Where("article_id IN ?", articleIDs).Find(&matchLinks)
|
||||
// Create map for quick lookup
|
||||
matchLinkMap := make(map[uint]*models.ArticleMatchLink)
|
||||
for i := range matchLinks {
|
||||
matchLinkMap[matchLinks[i].ArticleID] = &matchLinks[i]
|
||||
}
|
||||
// Assign match links to articles
|
||||
for i := range items {
|
||||
if ml, ok := matchLinkMap[items[i].ID]; ok {
|
||||
items[i].MatchLink = ml
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ImageProcessingController struct{}
|
||||
|
||||
// ImageProcessRequest represents the request body for image processing
|
||||
type ImageProcessRequest struct {
|
||||
ImageURL string `json:"image_url"` // URL of image to process
|
||||
Operation string `json:"operation"` // crop, resize, rotate, flip, filter
|
||||
Width int `json:"width"` // Target width (for resize)
|
||||
Height int `json:"height"` // Target height (for resize)
|
||||
CropX int `json:"crop_x"` // Crop coordinates
|
||||
CropY int `json:"crop_y"`
|
||||
CropWidth int `json:"crop_width"`
|
||||
CropHeight int `json:"crop_height"`
|
||||
Rotation int `json:"rotation"` // Rotation angle (90, 180, 270)
|
||||
FlipH bool `json:"flip_h"` // Flip horizontal
|
||||
FlipV bool `json:"flip_v"` // Flip vertical
|
||||
Brightness float64 `json:"brightness"` // -100 to 100
|
||||
Contrast float64 `json:"contrast"` // -100 to 100
|
||||
Saturation float64 `json:"saturation"` // -100 to 100
|
||||
Blur float64 `json:"blur"` // 0 to 10
|
||||
Sharpen float64 `json:"sharpen"` // 0 to 10
|
||||
Grayscale bool `json:"grayscale"` // Convert to grayscale
|
||||
Quality int `json:"quality"` // JPEG quality 1-100
|
||||
}
|
||||
|
||||
// ProcessImage handles image processing operations
|
||||
func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
||||
var req ImageProcessRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate quality
|
||||
if req.Quality <= 0 || req.Quality > 100 {
|
||||
req.Quality = 85 // Default quality
|
||||
}
|
||||
|
||||
// Load image
|
||||
img, format, err := ctrl.loadImage(req.ImageURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to load image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply operations based on request
|
||||
processedImg := img
|
||||
|
||||
// Crop
|
||||
if req.CropWidth > 0 && req.CropHeight > 0 {
|
||||
processedImg = imaging.Crop(processedImg, image.Rect(
|
||||
req.CropX,
|
||||
req.CropY,
|
||||
req.CropX+req.CropWidth,
|
||||
req.CropY+req.CropHeight,
|
||||
))
|
||||
}
|
||||
|
||||
// Resize
|
||||
if req.Width > 0 || req.Height > 0 {
|
||||
width := req.Width
|
||||
height := req.Height
|
||||
if width == 0 {
|
||||
width = 0 // Auto width
|
||||
}
|
||||
if height == 0 {
|
||||
height = 0 // Auto height
|
||||
}
|
||||
processedImg = imaging.Resize(processedImg, width, height, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Rotate
|
||||
if req.Rotation != 0 {
|
||||
switch req.Rotation % 360 {
|
||||
case 90, -270:
|
||||
processedImg = imaging.Rotate90(processedImg)
|
||||
case 180, -180:
|
||||
processedImg = imaging.Rotate180(processedImg)
|
||||
case 270, -90:
|
||||
processedImg = imaging.Rotate270(processedImg)
|
||||
}
|
||||
}
|
||||
|
||||
// Flip
|
||||
if req.FlipH {
|
||||
processedImg = imaging.FlipH(processedImg)
|
||||
}
|
||||
if req.FlipV {
|
||||
processedImg = imaging.FlipV(processedImg)
|
||||
}
|
||||
|
||||
// Brightness
|
||||
if req.Brightness != 0 {
|
||||
processedImg = imaging.AdjustBrightness(processedImg, req.Brightness)
|
||||
}
|
||||
|
||||
// Contrast
|
||||
if req.Contrast != 0 {
|
||||
processedImg = imaging.AdjustContrast(processedImg, req.Contrast)
|
||||
}
|
||||
|
||||
// Saturation
|
||||
if req.Saturation != 0 {
|
||||
processedImg = imaging.AdjustSaturation(processedImg, req.Saturation)
|
||||
}
|
||||
|
||||
// Blur
|
||||
if req.Blur > 0 {
|
||||
processedImg = imaging.Blur(processedImg, req.Blur)
|
||||
}
|
||||
|
||||
// Sharpen
|
||||
if req.Sharpen > 0 {
|
||||
processedImg = imaging.Sharpen(processedImg, req.Sharpen)
|
||||
}
|
||||
|
||||
// Grayscale
|
||||
if req.Grayscale {
|
||||
processedImg = imaging.Grayscale(processedImg)
|
||||
}
|
||||
|
||||
// Save processed image
|
||||
outputPath, err := ctrl.saveProcessedImage(processedImg, format, req.Quality)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the new URL
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
"format": format,
|
||||
})
|
||||
}
|
||||
|
||||
// loadImage loads an image from a URL or local path
|
||||
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
|
||||
// Check if it's a local file path
|
||||
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
|
||||
// Local file
|
||||
localPath := filepath.Join(".", imageURL)
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to open local file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, format, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// HTTP URL
|
||||
resp, err := http.Get(imageURL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
img, format, err := image.Decode(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// saveProcessedImage saves the processed image and returns the path
|
||||
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
|
||||
// Create uploads directory if it doesn't exist
|
||||
uploadsDir := "./uploads"
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create uploads directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
timestamp := time.Now().UnixNano() / int64(time.Millisecond)
|
||||
filename := fmt.Sprintf("processed_%d.jpg", timestamp)
|
||||
outputPath := filepath.Join(uploadsDir, filename)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Encode and save
|
||||
if format == "png" {
|
||||
err = png.Encode(outFile, img)
|
||||
} else {
|
||||
err = jpeg.Encode(outFile, img, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode image: %w", err)
|
||||
}
|
||||
|
||||
// Return relative URL
|
||||
return "/uploads/" + filename, nil
|
||||
}
|
||||
|
||||
// CropAndUpload handles image cropping with upload
|
||||
func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
|
||||
// Get image from form data
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No image file provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get crop parameters
|
||||
var cropParams struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
cropData := c.PostForm("crop_data")
|
||||
if cropData != "" {
|
||||
if err := json.Unmarshal([]byte(cropData), &cropParams); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid crop parameters"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
quality := 85
|
||||
if q := c.PostForm("quality"); q != "" {
|
||||
fmt.Sscanf(q, "%d", &quality)
|
||||
}
|
||||
|
||||
maxWidth := 1500
|
||||
if mw := c.PostForm("max_width"); mw != "" {
|
||||
fmt.Sscanf(mw, "%d", &maxWidth)
|
||||
}
|
||||
|
||||
// Open uploaded file
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Decode image
|
||||
img, format, err := image.Decode(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply crop if parameters provided
|
||||
processedImg := img
|
||||
if cropParams.Width > 0 && cropParams.Height > 0 {
|
||||
processedImg = imaging.Crop(processedImg, image.Rect(
|
||||
cropParams.X,
|
||||
cropParams.Y,
|
||||
cropParams.X+cropParams.Width,
|
||||
cropParams.Y+cropParams.Height,
|
||||
))
|
||||
}
|
||||
|
||||
// Resize if larger than max width
|
||||
bounds := processedImg.Bounds()
|
||||
if bounds.Dx() > maxWidth {
|
||||
processedImg = imaging.Resize(processedImg, maxWidth, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Save to uploads
|
||||
outputPath, err := ctrl.saveProcessedImage(processedImg, format, quality)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save processed image"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
})
|
||||
}
|
||||
|
||||
// QuickEdit handles common quick edits in one call
|
||||
func (ctrl *ImageProcessingController) QuickEdit(c *gin.Context) {
|
||||
var req struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Width int `json:"width"`
|
||||
Rotation int `json:"rotation"`
|
||||
FlipH bool `json:"flip_h"`
|
||||
FlipV bool `json:"flip_v"`
|
||||
Brightness float64 `json:"brightness"`
|
||||
Contrast float64 `json:"contrast"`
|
||||
Saturation float64 `json:"saturation"`
|
||||
Grayscale bool `json:"grayscale"`
|
||||
Quality int `json:"quality"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quality <= 0 || req.Quality > 100 {
|
||||
req.Quality = 85
|
||||
}
|
||||
|
||||
// Load image
|
||||
img, format, err := ctrl.loadImage(req.ImageURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to load image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
processedImg := img
|
||||
|
||||
// Resize if width specified
|
||||
if req.Width > 0 {
|
||||
processedImg = imaging.Resize(processedImg, req.Width, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Rotate
|
||||
if req.Rotation != 0 {
|
||||
switch req.Rotation % 360 {
|
||||
case 90, -270:
|
||||
processedImg = imaging.Rotate90(processedImg)
|
||||
case 180, -180:
|
||||
processedImg = imaging.Rotate180(processedImg)
|
||||
case 270, -90:
|
||||
processedImg = imaging.Rotate270(processedImg)
|
||||
}
|
||||
}
|
||||
|
||||
// Flip
|
||||
if req.FlipH {
|
||||
processedImg = imaging.FlipH(processedImg)
|
||||
}
|
||||
if req.FlipV {
|
||||
processedImg = imaging.FlipV(processedImg)
|
||||
}
|
||||
|
||||
// Brightness/Contrast/Saturation
|
||||
if req.Brightness != 0 {
|
||||
processedImg = imaging.AdjustBrightness(processedImg, req.Brightness)
|
||||
}
|
||||
if req.Contrast != 0 {
|
||||
processedImg = imaging.AdjustContrast(processedImg, req.Contrast)
|
||||
}
|
||||
if req.Saturation != 0 {
|
||||
processedImg = imaging.AdjustSaturation(processedImg, req.Saturation)
|
||||
}
|
||||
|
||||
// Grayscale
|
||||
if req.Grayscale {
|
||||
processedImg = imaging.Grayscale(processedImg)
|
||||
}
|
||||
|
||||
// Save
|
||||
outputPath, err := ctrl.saveProcessedImage(processedImg, format, req.Quality)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save image"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
})
|
||||
}
|
||||
@@ -229,12 +229,25 @@ func (uc *UmamiController) GetStats(c *gin.Context) {
|
||||
// Get time range from query params (default to last 30 days)
|
||||
days := 30
|
||||
if d := c.Query("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed >= 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
endAt := time.Now().Unix() * 1000 // milliseconds
|
||||
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
|
||||
|
||||
// Calculate time range
|
||||
var startAt, endAt int64
|
||||
endDate := time.Now()
|
||||
if days == 0 {
|
||||
// Today: from midnight to now
|
||||
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
|
||||
startAt = startDate.Unix() * 1000
|
||||
endAt = endDate.Unix() * 1000
|
||||
} else {
|
||||
// Other ranges: from X days ago to now
|
||||
startDate := endDate.AddDate(0, 0, -days)
|
||||
startAt = startDate.Unix() * 1000
|
||||
endAt = endDate.Unix() * 1000
|
||||
}
|
||||
|
||||
stats, err := uc.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
|
||||
if err != nil {
|
||||
@@ -277,13 +290,25 @@ func (uc *UmamiController) GetMetrics(c *gin.Context) {
|
||||
|
||||
days := 30
|
||||
if d := c.Query("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed >= 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
endAt := time.Now().Unix() * 1000
|
||||
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
|
||||
// Calculate time range
|
||||
var startAt, endAt int64
|
||||
endDate := time.Now()
|
||||
if days == 0 {
|
||||
// Today: from midnight to now
|
||||
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
|
||||
startAt = startDate.Unix() * 1000
|
||||
endAt = endDate.Unix() * 1000
|
||||
} else {
|
||||
// Other ranges: from X days ago to now
|
||||
startDate := endDate.AddDate(0, 0, -days)
|
||||
startAt = startDate.Unix() * 1000
|
||||
endAt = endDate.Unix() * 1000
|
||||
}
|
||||
|
||||
metrics, err := uc.umamiService.GetWebsiteMetrics(websiteID, metricType, startAt, endAt)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,8 +11,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// Prevent MIME type sniffing
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Prevent clickjacking
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
// Prevent clickjacking (allow same-origin for PDF previews)
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
|
||||
// Referrer policy
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
@@ -56,7 +56,7 @@ func buildCSP(production bool) string {
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"frame-ancestors 'self'; " +
|
||||
"upgrade-insecure-requests;"
|
||||
}
|
||||
|
||||
@@ -71,5 +71,5 @@ func buildCSP(production bool) string {
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none';"
|
||||
"frame-ancestors 'self';"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ type Article struct {
|
||||
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
|
||||
YouTubeVideoURL string `json:"youtube_video_url"`
|
||||
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
|
||||
// Match link (loaded separately, not stored in this table)
|
||||
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link,omitempty"`
|
||||
}
|
||||
|
||||
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
|
||||
|
||||
@@ -50,6 +50,8 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
pollController := controllers.NewPollController(db)
|
||||
clothingController := controllers.NewClothingController(db)
|
||||
pageElementConfigController := controllers.NewPageElementConfigController(db)
|
||||
imageProcessingController := &controllers.ImageProcessingController{}
|
||||
articleController := controllers.NewArticleController(db)
|
||||
|
||||
// API v1 group
|
||||
{
|
||||
@@ -143,7 +145,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// Articles (protected - accessible by editors and admins)
|
||||
articles := protected.Group("/articles")
|
||||
{
|
||||
articles.POST("", baseController.CreateArticle)
|
||||
articles.POST("", articleController.CreateArticle)
|
||||
articles.PUT("/:id", baseController.UpdateArticle)
|
||||
articles.DELETE("/:id", baseController.DeleteArticle)
|
||||
// Link article to FACR match
|
||||
@@ -408,6 +410,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// Public API routes
|
||||
// Allow uploads publicly so initial setup can upload a club logo before an admin exists.
|
||||
api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage)
|
||||
|
||||
// Image processing endpoints (protected)
|
||||
imageProcessing := protected.Group("/image-processing")
|
||||
{
|
||||
imageProcessing.POST("/process", imageProcessingController.ProcessImage)
|
||||
imageProcessing.POST("/crop-upload", imageProcessingController.CropAndUpload)
|
||||
imageProcessing.POST("/quick-edit", imageProcessingController.QuickEdit)
|
||||
}
|
||||
|
||||
// Public scoreboard
|
||||
api.GET("/scoreboard", scoreboardController.GetPublic)
|
||||
|
||||
@@ -194,17 +194,33 @@ type UmamiAuthResponse struct {
|
||||
}
|
||||
|
||||
type UmamiCreateWebsiteRequest struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
TeamId *string `json:"teamId,omitempty"` // Optional: team ID for Umami v2
|
||||
}
|
||||
|
||||
type UmamiWebsiteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
TeamId *string `json:"teamId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// UmamiTeam represents a team in Umami
|
||||
type UmamiTeam struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// umamiTeamsResponse matches the shape of GET /api/users/:userId/teams
|
||||
type umamiTeamsResponse struct {
|
||||
Data []UmamiTeam `json:"data"`
|
||||
Count int `json:"count"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// NewUmamiService creates a new Umami service instance
|
||||
func NewUmamiService() *UmamiService {
|
||||
return &UmamiService{
|
||||
@@ -307,15 +323,101 @@ func (u *UmamiService) authenticate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserTeams retrieves the authenticated user's teams
|
||||
func (u *UmamiService) GetUserTeams(userID string) ([]UmamiTeam, error) {
|
||||
if err := u.authenticate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/users/%s/teams?page=1&pageSize=10", u.baseURL, userID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create teams request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+u.token)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send teams request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get teams failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var teamsResp umamiTeamsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&teamsResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode teams response: %w", err)
|
||||
}
|
||||
|
||||
return teamsResp.Data, nil
|
||||
}
|
||||
|
||||
// GetCurrentUser retrieves the current authenticated user info from Umami
|
||||
func (u *UmamiService) GetCurrentUser() (map[string]interface{}, error) {
|
||||
if err := u.authenticate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/verify", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create verify request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+u.token)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send verify request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("verify failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var user map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode user response: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateWebsite creates a new website in Umami and returns the website ID
|
||||
func (u *UmamiService) CreateWebsite(name, domain string) (string, error) {
|
||||
if err := u.authenticate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Info("Creating Umami website: name='%s', domain='%s'", name, domain)
|
||||
|
||||
// Try to get user info and teams for Umami v2 compatibility
|
||||
var teamID *string
|
||||
user, err := u.GetCurrentUser()
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get current user info (continuing without team): %v", err)
|
||||
} else {
|
||||
if userID, ok := user["id"].(string); ok && userID != "" {
|
||||
teams, err := u.GetUserTeams(userID)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fetch user teams (continuing without team): %v", err)
|
||||
} else if len(teams) > 0 {
|
||||
// Use the first available team
|
||||
teamID = &teams[0].ID
|
||||
logger.Info("Using team ID: %s (team name: %s)", teams[0].ID, teams[0].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createReq := UmamiCreateWebsiteRequest{
|
||||
Name: name,
|
||||
Domain: domain,
|
||||
TeamId: teamID,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createReq)
|
||||
@@ -323,6 +425,8 @@ func (u *UmamiService) CreateWebsite(name, domain string) (string, error) {
|
||||
return "", fmt.Errorf("failed to marshal create website request: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Sending website creation request to Umami API: %s/api/websites", u.baseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", u.baseURL+"/api/websites", bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create website request: %w", err)
|
||||
@@ -338,17 +442,21 @@ func (u *UmamiService) CreateWebsite(name, domain string) (string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body for detailed error logging
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
logger.Error("Umami website creation failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
return "", fmt.Errorf("create website failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var websiteResp UmamiWebsiteResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&websiteResp); err != nil {
|
||||
if err := json.Unmarshal(bodyBytes, &websiteResp); err != nil {
|
||||
logger.Error("Failed to decode website response: %v, body: %s", err, string(bodyBytes))
|
||||
return "", fmt.Errorf("failed to decode website response: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Successfully created Umami website: %s (ID: %s)", name, websiteResp.ID)
|
||||
logger.Info("Successfully created Umami website: %s (ID: %s, Domain: %s)", name, websiteResp.ID, websiteResp.Domain)
|
||||
return websiteResp.ID, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user