mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -39,6 +40,75 @@ type BaseController struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// GetArticleBySlug returns a single article by slug (public)
|
||||
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
|
||||
return
|
||||
}
|
||||
var art models.Article
|
||||
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" }
|
||||
if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) }
|
||||
var matchLink models.ArticleMatchLink
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
var aliases []models.CompetitionAlias
|
||||
_ = bc.DB.Find(&aliases).Error
|
||||
bc.addArticleComputedFields(&art, aliases)
|
||||
c.JSON(http.StatusOK, art)
|
||||
}
|
||||
|
||||
// respondArticlesFromCache tries to serve articles from on-disk cache and returns true if it did.
|
||||
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
|
||||
// Helper: read JSON file and respond with pagination
|
||||
readAndRespond := func(path string) bool {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil { return false }
|
||||
// Try payload {items: [...]} first
|
||||
var wrap struct{ Items []models.Article `json:"items"` }
|
||||
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
|
||||
items := wrap.Items
|
||||
total := len(items)
|
||||
start := (page - 1) * size
|
||||
if start < 0 { start = 0 }
|
||||
if start > total { start = total }
|
||||
end := start + size
|
||||
if end > total { end = total }
|
||||
paged := items[start:end]
|
||||
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
|
||||
return true
|
||||
}
|
||||
// Fallback: raw array of articles
|
||||
var arr []models.Article
|
||||
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
|
||||
total := len(arr)
|
||||
start := (page - 1) * size
|
||||
if start < 0 { start = 0 }
|
||||
if start > total { start = total }
|
||||
end := start + size
|
||||
if end > total { end = total }
|
||||
paged := arr[start:end]
|
||||
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Try blogs cache first, then prefetch
|
||||
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) { return true }
|
||||
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func makeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
@@ -832,329 +902,22 @@ func (bc *BaseController) GetArticle(c *gin.Context) {
|
||||
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
||||
art.MatchLink = &matchLink
|
||||
}
|
||||
// Compute helper fields (category_slug, competition_alias, normalized_category, url)
|
||||
var aliases []models.CompetitionAlias
|
||||
_ = bc.DB.Find(&aliases).Error
|
||||
bc.addArticleComputedFields(&art, aliases)
|
||||
c.JSON(http.StatusOK, art)
|
||||
}
|
||||
|
||||
// GetCategories returns a list of all categories (public)
|
||||
func (bc *BaseController) GetCategories(c *gin.Context) {
|
||||
var items []models.Category
|
||||
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category (admin only)
|
||||
func (bc *BaseController) CreateCategory(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if category with same name already exists
|
||||
var existing models.Category
|
||||
if err := bc.DB.Where("name = ?", name).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
||||
return
|
||||
}
|
||||
|
||||
cat := models.Category{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(body.Description),
|
||||
}
|
||||
// Ensure category slug is set and unique
|
||||
s := makeSlug(cat.Name)
|
||||
if s == "" {
|
||||
s = "category"
|
||||
}
|
||||
orig := s
|
||||
for i := 0; i < 50; i++ {
|
||||
var cnt int64
|
||||
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
||||
return
|
||||
}
|
||||
if cnt == 0 {
|
||||
break
|
||||
}
|
||||
s = fmt.Sprintf("%s-%d", orig, i+1)
|
||||
}
|
||||
cat.Slug = s
|
||||
|
||||
if err := bc.DB.Create(&cat).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, cat)
|
||||
}
|
||||
|
||||
// UpdateCategory updates an existing category (admin only)
|
||||
func (bc *BaseController) UpdateCategory(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var cat models.Category
|
||||
if err := bc.DB.First(&cat, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
|
||||
return
|
||||
}
|
||||
// Check if another category with same name exists
|
||||
var existing models.Category
|
||||
if err := bc.DB.Where("name = ? AND id != ?", name, id).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
||||
return
|
||||
}
|
||||
cat.Name = name
|
||||
}
|
||||
|
||||
if body.Description != nil {
|
||||
cat.Description = strings.TrimSpace(*body.Description)
|
||||
}
|
||||
|
||||
if err := bc.DB.Save(&cat).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cat)
|
||||
}
|
||||
|
||||
// DeleteCategory deletes a category (admin only)
|
||||
func (bc *BaseController) DeleteCategory(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var cat models.Category
|
||||
if err := bc.DB.First(&cat, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any articles are using this category
|
||||
var articleCount int64
|
||||
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", id).Count(&articleCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole článků"})
|
||||
return
|
||||
}
|
||||
|
||||
if articleCount > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"chyba": "Nelze smazat kategorii, která obsahuje články",
|
||||
"detail": fmt.Sprintf("Kategorie obsahuje %d článků", articleCount),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bc.DB.Delete(&cat).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
|
||||
}
|
||||
|
||||
// GetArticleBySlug returns a single article by slug (public)
|
||||
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
|
||||
return
|
||||
}
|
||||
var art models.Article
|
||||
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
if art.ImageURL == "" {
|
||||
art.ImageURL = "/dist/img/logo-club-empty.svg"
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// writeArticlesCache writes a JSON snapshot of PUBLISHED articles to cache/blogs/articles.json
|
||||
// Shape: { "items": [Article], "total": N, "page": 1, "page_size": N }
|
||||
func (bc *BaseController) writeArticlesCache() {
|
||||
// Load only published articles ordered by published_at desc, created_at desc
|
||||
var items []models.Article
|
||||
if err := bc.DB.Where("published = ?", true).Order("published_at DESC, created_at DESC").Find(&items).Error; err != nil {
|
||||
return
|
||||
}
|
||||
// Ensure image fallback
|
||||
for i := range items {
|
||||
if items[i].ImageURL == "" {
|
||||
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"page": 1,
|
||||
"page_size": len(items),
|
||||
}
|
||||
b, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dir := filepath.Join("cache", "blogs")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
tmp := filepath.Join(dir, "articles.json.tmp")
|
||||
dst := filepath.Join(dir, "articles.json")
|
||||
if err := os.WriteFile(tmp, b, 0o644); err == nil {
|
||||
_ = os.Rename(tmp, dst)
|
||||
}
|
||||
}
|
||||
|
||||
// respondArticlesFromCache attempts to read cache/blogs/articles.json (or cache/prefetch/articles.json) and respond.
|
||||
// Returns true if a response was written.
|
||||
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
|
||||
// Helper to page slice safely
|
||||
pageSlice := func(arr []map[string]any, page, size int) []map[string]any {
|
||||
if size <= 0 {
|
||||
size = len(arr)
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
start := (page - 1) * size
|
||||
if start >= len(arr) {
|
||||
return []map[string]any{}
|
||||
}
|
||||
end := start + size
|
||||
if end > len(arr) {
|
||||
end = len(arr)
|
||||
}
|
||||
return arr[start:end]
|
||||
}
|
||||
|
||||
readAndRespond := func(p string) bool {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(f).Decode(&raw); err != nil {
|
||||
return false
|
||||
}
|
||||
// Normalize items array
|
||||
var arr []map[string]any
|
||||
if its, ok := raw["items"].([]any); ok {
|
||||
for _, it := range its {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
arr = append(arr, m)
|
||||
}
|
||||
}
|
||||
} else if its, ok := raw["data"].([]any); ok {
|
||||
for _, it := range its {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
arr = append(arr, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return false
|
||||
}
|
||||
// Optional filters from query
|
||||
publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true"
|
||||
featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true"
|
||||
if publishedOnly || featuredOnly {
|
||||
filtered := make([]map[string]any, 0, len(arr))
|
||||
for _, m := range arr {
|
||||
if publishedOnly {
|
||||
if v, ok := m["published"].(bool); !ok || !v {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if featuredOnly {
|
||||
if v, ok := m["featured"].(bool); !ok || !v {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
arr = filtered
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return false
|
||||
}
|
||||
total := len(arr)
|
||||
paged := pageSlice(arr, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
|
||||
return true
|
||||
}
|
||||
|
||||
// Try blogs cache first
|
||||
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) {
|
||||
return true
|
||||
}
|
||||
// Fallback to prefetch cache if available
|
||||
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetArticles returns a paginated list of articles (public by default, admin can request all with published=false)
|
||||
func (bc *BaseController) GetArticles(c *gin.Context) {
|
||||
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
|
||||
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "10"))
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page < 1 { page = 1 }
|
||||
size, _ := strconv.Atoi(sizeStr)
|
||||
if size <= 0 {
|
||||
size = 10
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
if size <= 0 { size = 10 }
|
||||
if size > 100 { size = 100 }
|
||||
|
||||
pParam := strings.ToLower(strings.TrimSpace(c.Query("published")))
|
||||
featuredParam := strings.ToLower(strings.TrimSpace(c.Query("featured"))) == "true"
|
||||
@@ -1163,70 +926,38 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
|
||||
catRaw := strings.TrimSpace(c.Query("category_id"))
|
||||
matchID := strings.TrimSpace(c.Query("match_id"))
|
||||
monthStr := strings.TrimSpace(c.Query("month"))
|
||||
if monthStr == "" {
|
||||
monthStr = strings.TrimSpace(c.Query("date"))
|
||||
}
|
||||
if monthStr == "" { monthStr = strings.TrimSpace(c.Query("date")) }
|
||||
catID := 0
|
||||
if catRaw != "" {
|
||||
if v, err := strconv.Atoi(catRaw); err == nil {
|
||||
catID = v
|
||||
}
|
||||
}
|
||||
if catRaw != "" { if v, err := strconv.Atoi(catRaw); err == nil { catID = v } }
|
||||
|
||||
skipCache := false
|
||||
if pParam == "false" {
|
||||
skipCache = true
|
||||
}
|
||||
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" {
|
||||
skipCache = true
|
||||
}
|
||||
|
||||
if pParam == "false" { skipCache = true }
|
||||
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" { skipCache = true }
|
||||
if !skipCache {
|
||||
if bc.respondArticlesFromCache(c, page, size) {
|
||||
return
|
||||
}
|
||||
if bc.respondArticlesFromCache(c, page, size) { return }
|
||||
}
|
||||
|
||||
var items []models.Article
|
||||
qb := bc.DB.Model(&models.Article{})
|
||||
|
||||
if pParam == "" || pParam == "true" {
|
||||
qb = qb.Where("published = ?", true)
|
||||
}
|
||||
if featuredParam {
|
||||
qb = qb.Where("featured = ?", true)
|
||||
}
|
||||
if catID > 0 {
|
||||
qb = qb.Where("category_id = ?", catID)
|
||||
}
|
||||
if slug != "" {
|
||||
qb = qb.Where("slug = ?", slug)
|
||||
}
|
||||
if pParam == "" || pParam == "true" { qb = qb.Where("published = ?", true) }
|
||||
if featuredParam { qb = qb.Where("featured = ?", true) }
|
||||
if catID > 0 { qb = qb.Where("category_id = ?", catID) }
|
||||
if slug != "" { qb = qb.Where("slug = ?", slug) }
|
||||
if q != "" {
|
||||
like := "%" + strings.ToLower(q) + "%"
|
||||
qb = qb.Where("LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(category_name) LIKE ?", like, like, like)
|
||||
}
|
||||
if matchID != "" {
|
||||
qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID)
|
||||
}
|
||||
if matchID != "" { qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID) }
|
||||
if monthStr != "" {
|
||||
var y, m int
|
||||
if len(monthStr) >= 7 {
|
||||
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil {
|
||||
y = yy
|
||||
}
|
||||
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil {
|
||||
m = mm
|
||||
}
|
||||
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil { y = yy }
|
||||
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil { m = mm }
|
||||
}
|
||||
if y > 0 && m >= 1 && m <= 12 {
|
||||
start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.UTC)
|
||||
nm := m + 1
|
||||
ny := y
|
||||
if nm == 13 {
|
||||
nm = 1
|
||||
ny = y + 1
|
||||
}
|
||||
nm := m + 1; ny := y
|
||||
if nm == 13 { nm = 1; ny = y + 1 }
|
||||
end := time.Date(ny, time.Month(nm), 1, 0, 0, 0, 0, time.UTC)
|
||||
qb = qb.Where("COALESCE(published_at, created_at) >= ? AND COALESCE(published_at, created_at) < ?", start, end)
|
||||
}
|
||||
@@ -1237,23 +968,20 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.Preload("Author").Preload("Category").
|
||||
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
|
||||
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
|
||||
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
if items[i].ImageURL == "" {
|
||||
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
||||
}
|
||||
if items[i].ReadTime == 0 {
|
||||
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
|
||||
}
|
||||
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
|
||||
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
|
||||
}
|
||||
|
||||
// Compute helper fields for list
|
||||
var aliases []models.CompetitionAlias
|
||||
_ = bc.DB.Find(&aliases).Error
|
||||
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
@@ -1262,16 +990,10 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
|
||||
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
|
||||
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "6"))
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page < 1 { page = 1 }
|
||||
size, _ := strconv.Atoi(sizeStr)
|
||||
if size <= 0 {
|
||||
size = 6
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
if size <= 0 { size = 6 }
|
||||
if size > 100 { size = 100 }
|
||||
|
||||
var items []models.Article
|
||||
qb := bc.DB.Model(&models.Article{}).
|
||||
@@ -1285,19 +1007,56 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
|
||||
}
|
||||
if err := qb.Preload("Author").Preload("Category").
|
||||
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
|
||||
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
|
||||
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if items[i].ImageURL == "" {
|
||||
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
||||
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
|
||||
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
|
||||
}
|
||||
// Compute helper fields for list
|
||||
var aliases []models.CompetitionAlias
|
||||
_ = bc.DB.Find(&aliases).Error
|
||||
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
// addArticleComputedFields populates non-persisted helper fields on the Article JSON
|
||||
func (bc *BaseController) addArticleComputedFields(a *models.Article, aliases []models.CompetitionAlias) {
|
||||
// Category slug
|
||||
if a.Category != nil && strings.TrimSpace(a.Category.Slug) != "" {
|
||||
a.CategorySlug = strings.TrimSpace(a.Category.Slug)
|
||||
} else if strings.TrimSpace(a.CategoryName) != "" {
|
||||
a.CategorySlug = makeSlug(a.CategoryName)
|
||||
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
|
||||
a.CategorySlug = makeSlug(a.Category.Name)
|
||||
}
|
||||
// Normalized category (for fast matching on FE)
|
||||
if strings.TrimSpace(a.CategoryName) != "" {
|
||||
a.NormalizedCategory = foldAccents(a.CategoryName)
|
||||
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
|
||||
a.NormalizedCategory = foldAccents(a.Category.Name)
|
||||
}
|
||||
// URL path
|
||||
if strings.TrimSpace(a.Slug) != "" { a.URL = "/news/" + strings.TrimSpace(a.Slug) } else { a.URL = fmt.Sprintf("/articles/%d", a.ID) }
|
||||
// Competition alias mapping (match category against alias or original name)
|
||||
cat := strings.TrimSpace(a.CategoryName)
|
||||
if cat == "" && a.Category != nil {
|
||||
cat = strings.TrimSpace(a.Category.Name)
|
||||
}
|
||||
if cat == "" || len(aliases) == 0 { return }
|
||||
ncat := foldAccents(cat)
|
||||
for _, al := range aliases {
|
||||
aliasNorm := foldAccents(al.Alias)
|
||||
origNorm := foldAccents(al.OriginalName)
|
||||
if aliasNorm != "" {
|
||||
if strings.Contains(ncat, aliasNorm) || strings.Contains(aliasNorm, ncat) { a.CompetitionAlias = al.Alias; return }
|
||||
}
|
||||
if items[i].ReadTime == 0 {
|
||||
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
|
||||
if origNorm != "" {
|
||||
if strings.Contains(ncat, origNorm) || strings.Contains(origNorm, ncat) { a.CompetitionAlias = al.Alias; return }
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
// UpdateArticle updates an existing article (protected)
|
||||
@@ -1363,7 +1122,22 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
||||
var cat models.Category
|
||||
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
cat = models.Category{Name: name}
|
||||
// Create category with a unique slug derived from name
|
||||
s := makeSlug(name)
|
||||
if s == "" {
|
||||
s = fmt.Sprintf("category-%d", time.Now().Unix())
|
||||
}
|
||||
orig := s
|
||||
for i := 0; i < 50; i++ {
|
||||
var sc int64
|
||||
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
||||
return
|
||||
}
|
||||
if sc == 0 { break }
|
||||
s = fmt.Sprintf("%s-%d", orig, i+1)
|
||||
}
|
||||
cat = models.Category{Name: name, Slug: s}
|
||||
if err := bc.DB.Create(&cat).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
||||
return
|
||||
@@ -1473,7 +1247,7 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := bc.DB.Save(&art).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2317,6 +2091,42 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Basic SSRF hardening: block internal/private destinations and unusual ports
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported port"})
|
||||
return
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err == nil {
|
||||
blockedCIDRs := []string{
|
||||
"127.0.0.0/8", // loopback
|
||||
"10.0.0.0/8", // private
|
||||
"172.16.0.0/12", // private
|
||||
"192.168.0.0/16",// private
|
||||
"169.254.0.0/16",// link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
}
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range blockedCIDRs {
|
||||
_, n, perr := net.ParseCIDR(cidr)
|
||||
if perr == nil {
|
||||
nets = append(nets, n)
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "destination not allowed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch with a short timeout
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
@@ -2367,6 +2177,16 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce a reasonable maximum size when Content-Length is provided (8MB)
|
||||
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||
if n, err := strconv.Atoi(cl); err == nil {
|
||||
if n > 8*1024*1024 {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image too large"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream response
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
@@ -4386,6 +4206,154 @@ func (bc *BaseController) DeleteBanner(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
|
||||
}
|
||||
|
||||
func (bc *BaseController) GetCategories(c *gin.Context) {
|
||||
var items []models.Category
|
||||
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (bc *BaseController) CreateCategory(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ?", strings.ToLower(name)).Count(&cnt).Error
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(body.Slug)
|
||||
if slug == "" {
|
||||
slug = makeSlug(name)
|
||||
} else {
|
||||
slug = makeSlug(slug)
|
||||
}
|
||||
orig := slug
|
||||
for i := 0; i < 50; i++ {
|
||||
var sc int64
|
||||
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&sc).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
||||
return
|
||||
}
|
||||
if sc == 0 {
|
||||
break
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", orig, i+1)
|
||||
}
|
||||
item := models.Category{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(body.Description),
|
||||
Slug: slug,
|
||||
}
|
||||
if err := bc.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (bc *BaseController) UpdateCategory(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var item models.Category
|
||||
if err := bc.DB.First(&item, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Slug *string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
v := strings.TrimSpace(*body.Name)
|
||||
if v == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ? AND id != ?", strings.ToLower(v), item.ID).Count(&cnt).Error
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
||||
return
|
||||
}
|
||||
item.Name = v
|
||||
}
|
||||
if body.Description != nil {
|
||||
item.Description = strings.TrimSpace(*body.Description)
|
||||
}
|
||||
if body.Slug != nil {
|
||||
s := strings.TrimSpace(*body.Slug)
|
||||
if s == "" {
|
||||
s = makeSlug(item.Name)
|
||||
} else {
|
||||
s = makeSlug(s)
|
||||
}
|
||||
orig := s
|
||||
for i := 0; i < 50; i++ {
|
||||
var sc int64
|
||||
if err := bc.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", s, item.ID).Count(&sc).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
||||
return
|
||||
}
|
||||
if sc == 0 {
|
||||
break
|
||||
}
|
||||
s = fmt.Sprintf("%s-%d", orig, i+1)
|
||||
}
|
||||
item.Slug = s
|
||||
}
|
||||
if err := bc.DB.Save(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (bc *BaseController) DeleteCategory(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var item models.Category
|
||||
if err := bc.DB.First(&item, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat kategorii s přiřazenými články", "detail": cnt})
|
||||
return
|
||||
}
|
||||
if err := bc.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
|
||||
}
|
||||
|
||||
func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -4405,9 +4373,9 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// Light content sniffing to ensure the uploaded payload matches the declared extension
|
||||
// (helps mitigate mislabelled uploads). We only read the first few bytes.
|
||||
// and basic SVG sanitization (reject obvious script/event patterns).
|
||||
if src, err := f.Open(); err == nil {
|
||||
buf := make([]byte, 512)
|
||||
buf := make([]byte, 2048)
|
||||
n, _ := src.Read(buf)
|
||||
_ = src.Close()
|
||||
detected := http.DetectContentType(buf[:n])
|
||||
@@ -4416,11 +4384,8 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||
case ".pdf":
|
||||
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
|
||||
case ".svg":
|
||||
// Many servers label SVGs inconsistently; allow svg+xml, xml, or text/plain
|
||||
dl := strings.ToLower(detected)
|
||||
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
|
||||
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
|
||||
default:
|
||||
// Common images should report image/*
|
||||
validCT = strings.HasPrefix(detected, "image/")
|
||||
}
|
||||
if !validCT {
|
||||
@@ -4428,6 +4393,19 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Additional SVG content check against common script vectors
|
||||
if ext == ".svg" {
|
||||
if src2, err := f.Open(); err == nil {
|
||||
defer src2.Close()
|
||||
check := make([]byte, 65536)
|
||||
n, _ := io.ReadFull(src2, check)
|
||||
lower := strings.ToLower(string(check[:n]))
|
||||
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
|
||||
Reference in New Issue
Block a user