mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-06-03 18:22:57 +00:00
1689 lines
50 KiB
Go
1689 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
|
clubType = "futsal"
|
|
baseURL = "https://facr.tdvorak.dev"
|
|
)
|
|
|
|
// Paths
|
|
func dataPath() string {
|
|
if p := os.Getenv("DATA_PATH"); p != "" {
|
|
return p
|
|
}
|
|
// Default: Docker volume path (writable)
|
|
return "/app/data/club.json"
|
|
}
|
|
|
|
// ---------------- Image normalization for blog thumbnails ----------------
|
|
// Target dimensions for blog images
|
|
const (
|
|
blogImgW = 1600
|
|
blogImgH = 969
|
|
)
|
|
|
|
// avgLuma computes average luminance of an image (0..255)
|
|
func avgLuma(img image.Image) float64 {
|
|
b := img.Bounds()
|
|
if b.Empty() {
|
|
return 0
|
|
}
|
|
var sum uint64
|
|
var n uint64
|
|
for y := b.Min.Y; y < b.Max.Y; y += 4 { // sample every 4th row for speed
|
|
for x := b.Min.X; x < b.Max.X; x += 4 { // sample every 4th column
|
|
r, g, bv, _ := img.At(x, y).RGBA()
|
|
r8 := float64(r >> 8)
|
|
g8 := float64(g >> 8)
|
|
b8 := float64(bv >> 8)
|
|
// Rec. 601 approximate luma
|
|
l := 0.299*r8 + 0.587*g8 + 0.114*b8
|
|
if l < 0 {
|
|
l = 0
|
|
}
|
|
if l > 255 {
|
|
l = 255
|
|
}
|
|
sum += uint64(l)
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
return float64(sum) / float64(n)
|
|
}
|
|
|
|
// fitWithin returns destination size that fits source into max size, without upscaling
|
|
func fitWithin(sw, sh, mw, mh int) (int, int) {
|
|
if sw <= 0 || sh <= 0 {
|
|
return 0, 0
|
|
}
|
|
if sw <= mw && sh <= mh {
|
|
return sw, sh
|
|
}
|
|
wr := float64(mw) / float64(sw)
|
|
hr := float64(mh) / float64(sh)
|
|
r := wr
|
|
if hr < wr {
|
|
r = hr
|
|
}
|
|
dw := int(float64(sw) * r)
|
|
dh := int(float64(sh) * r)
|
|
if dw < 1 {
|
|
dw = 1
|
|
}
|
|
if dh < 1 {
|
|
dh = 1
|
|
}
|
|
return dw, dh
|
|
}
|
|
|
|
// scaleNearest performs nearest-neighbor downscaling from src to a new RGBA of size (dw, dh)
|
|
func scaleNearest(src image.Image, dw, dh int) *image.RGBA {
|
|
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
|
sb := src.Bounds()
|
|
sw := sb.Dx()
|
|
sh := sb.Dy()
|
|
for y := 0; y < dh; y++ {
|
|
sy := sb.Min.Y + int(float64(y)*float64(sh)/float64(dh))
|
|
for x := 0; x < dw; x++ {
|
|
sx := sb.Min.X + int(float64(x)*float64(sw)/float64(dw))
|
|
dst.Set(x, y, src.At(sx, sy))
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// normalizeBlogImage decodes any supported image (PNG/JPEG) and writes a 1600x969 PNG with letterboxing (black/white)
|
|
func normalizeBlogImage(r io.Reader, outPath string) error {
|
|
img, _, err := image.Decode(r)
|
|
if err != nil {
|
|
return fmt.Errorf("decode image: %w", err)
|
|
}
|
|
// Choose background based on average luminance
|
|
l := avgLuma(img)
|
|
bg := color.Black
|
|
if l > 160 { // bright image -> white bg; tweak threshold as needed
|
|
bg = color.White
|
|
}
|
|
// Compute fitted size (no upscaling)
|
|
srcB := img.Bounds()
|
|
dw, dh := fitWithin(srcB.Dx(), srcB.Dy(), blogImgW, blogImgH)
|
|
var scaled image.Image
|
|
if dw == srcB.Dx() && dh == srcB.Dy() {
|
|
scaled = img
|
|
} else {
|
|
scaled = scaleNearest(img, dw, dh)
|
|
}
|
|
// Compose centered on canvas
|
|
canvas := image.NewRGBA(image.Rect(0, 0, blogImgW, blogImgH))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
|
|
offX := (blogImgW - dw) / 2
|
|
offY := (blogImgH - dh) / 2
|
|
draw.Draw(canvas, image.Rect(offX, offY, offX+dw, offY+dh), scaled, scaled.Bounds().Min, draw.Over)
|
|
// Write PNG atomically
|
|
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
tmp := outPath + ".tmp"
|
|
f, err := os.Create(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
|
if err := enc.Encode(f, canvas); err != nil {
|
|
f.Close()
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("encode png: %w", err)
|
|
}
|
|
f.Close()
|
|
_ = os.Remove(outPath)
|
|
if err := os.Rename(tmp, outPath); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func staticPath() string {
|
|
if p := os.Getenv("STATIC_PATH"); p != "" {
|
|
return p
|
|
}
|
|
// Default: use current working directory when running locally
|
|
cwd, err := os.Getwd()
|
|
if err == nil && cwd != "" {
|
|
// If CWD contains index.html, assume it's the site root
|
|
if _, err := os.Stat(filepath.Join(cwd, "index.html")); err == nil {
|
|
return cwd
|
|
}
|
|
// Otherwise, try parent directory (common when running from ./backend)
|
|
parent := filepath.Dir(cwd)
|
|
if parent != "" {
|
|
if _, err := os.Stat(filepath.Join(parent, "index.html")); err == nil {
|
|
return parent
|
|
}
|
|
}
|
|
// Fallback to CWD even if index.html not found
|
|
return cwd
|
|
}
|
|
// Fallback to container default if CWD is unavailable
|
|
return "/app/site"
|
|
}
|
|
|
|
type ClubDetail struct {
|
|
Name string `json:"name"`
|
|
ClubID string `json:"club_id"`
|
|
ClubType string `json:"club_type"`
|
|
URL string `json:"url"`
|
|
LogoURL string `json:"logo_url"`
|
|
Address string `json:"address"`
|
|
Category string `json:"category"`
|
|
Competitions []struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
TeamCount string `json:"team_count"`
|
|
MatchesLink string `json:"matches_link"`
|
|
Matches []struct {
|
|
DateTime string `json:"date_time"`
|
|
Home string `json:"home"`
|
|
HomeID string `json:"home_id"`
|
|
HomeLogoURL string `json:"home_logo_url"`
|
|
Away string `json:"away"`
|
|
AwayID string `json:"away_id"`
|
|
AwayLogoURL string `json:"away_logo_url"`
|
|
Score string `json:"score"`
|
|
Venue string `json:"venue"`
|
|
MatchID string `json:"match_id"`
|
|
ReportURL string `json:"report_url"`
|
|
FacrLink string `json:"facr_link"`
|
|
} `json:"matches"`
|
|
} `json:"competitions"`
|
|
}
|
|
|
|
// ---------------- Admin Basic Auth ----------------
|
|
func adminCreds() (string, string) {
|
|
u := os.Getenv("ADMIN_USER")
|
|
p := os.Getenv("ADMIN_PASS")
|
|
if u == "" {
|
|
u = "info@tdvorak.dev"
|
|
}
|
|
if p == "" {
|
|
p = "%8s3Yad*!b3*t"
|
|
}
|
|
return u, p
|
|
}
|
|
|
|
func checkBasicAuth(r *http.Request) bool {
|
|
user, pass := adminCreds()
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Basic ") {
|
|
return false
|
|
}
|
|
b64 := strings.TrimPrefix(auth, "Basic ")
|
|
dec, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
parts := strings.SplitN(string(dec), ":", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
return parts[0] == user && parts[1] == pass
|
|
}
|
|
|
|
func requireBasicAuth(w http.ResponseWriter) {
|
|
w.Header().Set("WWW-Authenticate", "Basic realm=admin")
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
}
|
|
|
|
func basicAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !checkBasicAuth(r) {
|
|
requireBasicAuth(w)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Compute next blog numeric ID by scanning blog/*.html
|
|
func nextBlogID(siteRoot string) (int, error) {
|
|
blogDir := filepath.Join(siteRoot, "blog")
|
|
entries, err := os.ReadDir(blogDir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
re := regexp.MustCompile(`^(\d{4})\.html$`)
|
|
max := -1
|
|
for _, e := range entries {
|
|
m := re.FindStringSubmatch(e.Name())
|
|
if len(m) == 2 {
|
|
if n, err := strconv.Atoi(m[1]); err == nil && n > max {
|
|
max = n
|
|
}
|
|
}
|
|
}
|
|
if max < 0 {
|
|
return 0, fmt.Errorf("no posts found to derive id")
|
|
}
|
|
return max + 1, nil
|
|
}
|
|
|
|
// Minimal HTML escaper for title
|
|
func htmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
return s
|
|
}
|
|
|
|
// ---------------- YouTube: periodic refresh and persistence ----------------
|
|
func videosScheduler(ctx context.Context) {
|
|
// Refresh once a day
|
|
for {
|
|
select {
|
|
case <-time.After(24 * time.Hour):
|
|
if err := refreshVideos(ctx); err != nil {
|
|
log.Printf("videos refresh error: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func refreshVideos(ctx context.Context) error {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
base := "https://youtube.tdvorak.dev"
|
|
ch := ytChannel()
|
|
u := base + "/channel_videos?channel=" + url.QueryEscape(ch)
|
|
var resp YTChannelResp
|
|
if err := getJSON(ctx, client, u, &resp); err != nil {
|
|
return fmt.Errorf("yt get: %w", err)
|
|
}
|
|
items := resp.Videos
|
|
if len(items) > 5 {
|
|
items = items[:5]
|
|
}
|
|
vc.mu.Lock()
|
|
vc.data.FetchedAt = time.Now()
|
|
vc.data.Channel = resp.Channel
|
|
vc.data.Items = items
|
|
vc.mu.Unlock()
|
|
if err := writeVideosJSON(); err != nil {
|
|
log.Printf("warn: write videos json: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeVideosJSON() error {
|
|
path := videosPath()
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
vc.mu.RLock()
|
|
payload := struct {
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
Channel string `json:"channel"`
|
|
Items []YTVideo `json:"items"`
|
|
}{FetchedAt: vc.data.FetchedAt, Channel: vc.data.Channel, Items: vc.data.Items}
|
|
vc.mu.RUnlock()
|
|
b, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
|
return fmt.Errorf("write tmp: %w", err)
|
|
}
|
|
_ = os.Remove(path)
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
// Also mirror to static site path for fast fetch by frontend
|
|
// Attempt best-effort; log warnings but do not fail the main write
|
|
// Target files: <STATIC_PATH>/data/videos.json and <STATIC_PATH>/data/video.json
|
|
func() {
|
|
defer func() { _ = recover() }()
|
|
sp := staticPath()
|
|
if sp == "" {
|
|
return
|
|
}
|
|
dataDir := filepath.Join(sp, "data")
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
log.Printf("warn: mkdir static data dir: %v", err)
|
|
return
|
|
}
|
|
// Write videos.json
|
|
dest1 := filepath.Join(dataDir, "videos.json")
|
|
if err := os.WriteFile(dest1, b, 0644); err != nil {
|
|
log.Printf("warn: write static videos.json: %v", err)
|
|
}
|
|
// Write video.json (alias) to match frontend expectation
|
|
dest2 := filepath.Join(dataDir, "video.json")
|
|
if err := os.WriteFile(dest2, b, 0644); err != nil {
|
|
log.Printf("warn: write static video.json: %v", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func videosPath() string {
|
|
if p := os.Getenv("VIDEOS_PATH"); p != "" {
|
|
return p
|
|
}
|
|
// Default: Docker volume path (writable)
|
|
return "/app/data/videos.json"
|
|
}
|
|
|
|
func ytChannel() string {
|
|
if c := os.Getenv("YT_CHANNEL"); c != "" {
|
|
return c
|
|
}
|
|
// Default YouTube channel
|
|
return "@FCBizoniUH"
|
|
}
|
|
|
|
// BlogItem represents a simple blog card item for the homepage
|
|
type BlogItem struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
Link string `json:"link"`
|
|
Image string `json:"image"`
|
|
MTime time.Time `json:"mtime"`
|
|
Categories []string `json:"categories,omitempty"`
|
|
}
|
|
|
|
// generateSlug creates a URL-friendly slug from a title
|
|
func generateSlug(title string) string {
|
|
slug := strings.ToLower(title)
|
|
// Replace Czech characters with their ASCII equivalents
|
|
replacements := map[string]string{
|
|
"á": "a", "ä": "a", "č": "c", "ď": "d", "é": "e", "ě": "e", "í": "i", "ľ": "l",
|
|
"ň": "n", "ó": "o", "ö": "o", "ô": "o", "ř": "r", "š": "s", "ť": "t", "ú": "u",
|
|
"ů": "u", "ý": "y", "ž": "z",
|
|
"Á": "a", "Ä": "a", "Č": "c", "Ď": "d", "É": "e", "Ě": "e", "Í": "i", "Ľ": "l",
|
|
"Ň": "n", "Ó": "o", "Ö": "o", "Ô": "o", "Ř": "r", "Š": "s", "Ť": "t", "Ú": "u",
|
|
"Ů": "u", "Ý": "y", "Ž": "z",
|
|
}
|
|
for czech, ascii := range replacements {
|
|
slug = strings.ReplaceAll(slug, czech, ascii)
|
|
}
|
|
// Remove any character that isn't alphanumeric, space, or hyphen
|
|
re := regexp.MustCompile(`[^a-z0-9\s-]`)
|
|
slug = re.ReplaceAllString(slug, "")
|
|
// Replace spaces and multiple hyphens with a single hyphen
|
|
re = regexp.MustCompile(`[\s-]+`)
|
|
slug = re.ReplaceAllString(slug, "-")
|
|
// Remove leading and trailing hyphens
|
|
slug = strings.Trim(slug, "-")
|
|
return slug
|
|
}
|
|
|
|
// ensureUniqueSlug ensures the slug is unique by appending a number if needed
|
|
func ensureUniqueSlug(siteRoot, baseSlug string) string {
|
|
blogDir := filepath.Join(siteRoot, "blog")
|
|
entries, err := os.ReadDir(blogDir)
|
|
if err != nil {
|
|
return baseSlug
|
|
}
|
|
existingSlugs := make(map[string]bool)
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".html") {
|
|
continue
|
|
}
|
|
// Extract slug from filename if it follows the new pattern
|
|
name := strings.TrimSuffix(e.Name(), ".html")
|
|
// Check if it's a slug-based filename (contains letters, not just numbers)
|
|
if regexp.MustCompile(`[a-z]`).MatchString(name) {
|
|
existingSlugs[name] = true
|
|
}
|
|
}
|
|
if !existingSlugs[baseSlug] {
|
|
return baseSlug
|
|
}
|
|
// Try baseSlug-2, baseSlug-3, etc.
|
|
for i := 2; i < 100; i++ {
|
|
testSlug := fmt.Sprintf("%s-%d", baseSlug, i)
|
|
if !existingSlugs[testSlug] {
|
|
return testSlug
|
|
}
|
|
}
|
|
// Fallback to timestamp
|
|
return fmt.Sprintf("%s-%d", baseSlug, time.Now().Unix())
|
|
}
|
|
|
|
func extractCategories(path string) []string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
s := string(b)
|
|
// match <meta name="category" content="Category">
|
|
re := regexp.MustCompile(`(?is)<meta name="category" content="([^"]+)"`)
|
|
matches := re.FindAllStringSubmatch(s, -1)
|
|
categories := make([]string, len(matches))
|
|
for i, match := range matches {
|
|
categories[i] = match[1]
|
|
}
|
|
return categories
|
|
}
|
|
|
|
// extractAnnotation extracts the annotation from blog HTML meta tags
|
|
func extractAnnotation(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
s := string(b)
|
|
// Try to find description in meta tag first
|
|
re := regexp.MustCompile(`(?is)<meta name="description" content="([^"]+)"`)
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractContentMode extracts the content mode from blog HTML meta tags
|
|
func extractContentMode(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
s := string(b)
|
|
// Try to find content_mode in meta tag first
|
|
re := regexp.MustCompile(`(?is)<meta name="content_mode" content="([^"]+)"`)
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
// Default to visual if not found
|
|
return "visual"
|
|
}
|
|
|
|
// extractSlug extracts the slug from blog HTML meta tags or generates from filename
|
|
func extractSlug(path, filename string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return extractSlugFromContent(string(b))
|
|
}
|
|
|
|
// extractSlugFromContent extracts the slug from HTML content string
|
|
func extractSlugFromContent(htmlContent string) string {
|
|
re := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
|
|
m := re.FindStringSubmatch(htmlContent)
|
|
if len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractBlogID(path, filename string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return strings.TrimSuffix(filename, ".html")
|
|
}
|
|
s := string(b)
|
|
// Try to find ID in meta tag first
|
|
re := regexp.MustCompile(`(?is)<meta name="id" content="([^"]+)"`)
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
// Fallback: use filename without extension
|
|
return strings.TrimSuffix(filename, ".html")
|
|
}
|
|
|
|
func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
|
|
// Use the siteRoot path where blogs are actually located
|
|
blogDir := filepath.Join(siteRoot, "blog")
|
|
|
|
// For local development, you can override with environment variable
|
|
if envPath := os.Getenv("REMOTE_BLOG_DIR"); envPath != "" {
|
|
blogDir = envPath
|
|
}
|
|
|
|
// If blog directory doesn't exist, return error
|
|
if _, err := os.Stat(blogDir); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("blog directory not found: %s. Set REMOTE_BLOG_DIR environment variable or ensure blog directory exists", blogDir)
|
|
}
|
|
|
|
imgDir := filepath.Join(filepath.Dir(blogDir), "img", "blog")
|
|
entries, err := os.ReadDir(blogDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("readdir blog: %w", err)
|
|
}
|
|
// Match both numeric (0001.html) and slug-based filenames
|
|
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
|
|
numericRe := regexp.MustCompile(`^\d{4}$`)
|
|
var items []BlogItem
|
|
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !re.MatchString(name) {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(name, ".html")
|
|
|
|
// Skip if this ID was already processed (deduplication)
|
|
if seenIDs[id] {
|
|
continue
|
|
}
|
|
|
|
// Title and categories extraction from blog HTML
|
|
blogPath := filepath.Join(blogDir, name)
|
|
title := extractTitle(blogPath)
|
|
slug := extractSlug(blogPath, name)
|
|
cats := extractCategories(blogPath)
|
|
|
|
// Mark this ID as seen
|
|
seenIDs[id] = true
|
|
// Also mark slug/numeric counterpart to prevent duplicates
|
|
if slug != "" && slug != id {
|
|
seenIDs[slug] = true
|
|
}
|
|
if numericRe.MatchString(id) && slug != "" {
|
|
seenIDs[slug] = true
|
|
}
|
|
// Determine mod time - prefer image modtime if exists, else html
|
|
mtime := time.Time{}
|
|
htmlInfo, err1 := os.Stat(filepath.Join(blogDir, name))
|
|
if err1 == nil {
|
|
mtime = htmlInfo.ModTime()
|
|
}
|
|
// For image path, try to find corresponding numeric ID
|
|
imageID := id
|
|
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
|
|
// This is a slug, try to find corresponding numeric file
|
|
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
|
|
for _, numericFile := range numericFiles {
|
|
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
|
|
numericPath := filepath.Join(blogDir, numericFile)
|
|
numericSlug := extractSlug(numericPath, numericFile)
|
|
if numericSlug == id {
|
|
imageID = numericID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
|
|
// If image is newer, use that as a proxy for recency
|
|
if imgInfo.ModTime().After(mtime) {
|
|
mtime = imgInfo.ModTime()
|
|
}
|
|
}
|
|
// Use slug-based link if slug exists and is not just numeric, otherwise use numeric
|
|
var link string
|
|
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
|
|
link = "/blog/" + slug
|
|
} else {
|
|
link = "/blog/" + id + ".html"
|
|
}
|
|
items = append(items, BlogItem{
|
|
ID: id,
|
|
Title: title,
|
|
Slug: slug,
|
|
Link: link,
|
|
Image: "/img/blog/" + imageID + ".png",
|
|
MTime: mtime,
|
|
Categories: cats,
|
|
})
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
// Check if files were recently processed (all have same timestamp from setup script)
|
|
recentThreshold := time.Now().Add(-24 * time.Hour)
|
|
allRecent := items[i].MTime.After(recentThreshold) && items[j].MTime.After(recentThreshold)
|
|
|
|
if allRecent {
|
|
// If both files are recent (from setup script), sort by numeric ID (higher = newer)
|
|
ii, err1 := strconv.Atoi(items[i].ID)
|
|
jj, err2 := strconv.Atoi(items[j].ID)
|
|
if err1 == nil && err2 == nil {
|
|
return ii > jj
|
|
}
|
|
// If not numeric, fall back to string comparison
|
|
return items[i].ID > items[j].ID
|
|
}
|
|
|
|
// Otherwise, use modification time (newest first)
|
|
if !items[i].MTime.Equal(items[j].MTime) {
|
|
return items[i].MTime.After(items[j].MTime)
|
|
}
|
|
|
|
// If times are equal and not recent, fallback to numeric ID
|
|
ii, err1 := strconv.Atoi(items[i].ID)
|
|
jj, err2 := strconv.Atoi(items[j].ID)
|
|
if err1 == nil && err2 == nil {
|
|
return ii > jj
|
|
}
|
|
return items[i].ID > items[j].ID
|
|
})
|
|
if limit > 0 && len(items) > limit {
|
|
items = items[:limit]
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// extractTitle finds the first <h1>...</h1> and returns its inner text (very simple, best-effort)
|
|
func extractTitle(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
s := string(b)
|
|
// match <h1 ...>Title</h1>
|
|
re := regexp.MustCompile(`(?is)<h1[^>]*>(.*?)</h1>`) // non-greedy
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) >= 2 {
|
|
// strip HTML tags inside, if any
|
|
inner := m[1]
|
|
// remove any nested tags crudely
|
|
inner = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(inner, "")
|
|
inner = strings.TrimSpace(inner)
|
|
return inner
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type ClubTable struct {
|
|
Name string `json:"name"`
|
|
ClubID string `json:"club_id"`
|
|
ClubType string `json:"club_type"`
|
|
LogoURL string `json:"logo_url"`
|
|
Competitions []struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
TeamCount string `json:"team_count"`
|
|
MatchesLink string `json:"matches_link"`
|
|
Table struct {
|
|
Overall []struct {
|
|
Rank string `json:"rank"`
|
|
Team string `json:"team"`
|
|
TeamID string `json:"team_id"`
|
|
TeamLogo string `json:"team_logo_url"`
|
|
Played string `json:"played"`
|
|
Wins string `json:"wins"`
|
|
Draws string `json:"draws"`
|
|
Losses string `json:"losses"`
|
|
Score string `json:"score"`
|
|
Points string `json:"points"`
|
|
} `json:"overall"`
|
|
} `json:"table"`
|
|
} `json:"competitions"`
|
|
}
|
|
|
|
type Combined struct {
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
ClubDetail ClubDetail `json:"club_detail"`
|
|
ClubTable ClubTable `json:"club_table"`
|
|
}
|
|
|
|
type cache struct {
|
|
mu sync.RWMutex
|
|
data Combined
|
|
}
|
|
|
|
var c cache
|
|
|
|
// ---- YouTube videos cache ----
|
|
type YTVideo struct {
|
|
VideoID string `json:"video_id"`
|
|
Title string `json:"title"`
|
|
Length string `json:"length"`
|
|
ThumbnailURL string `json:"thumbnail_url"`
|
|
ViewsText string `json:"views_text"`
|
|
Views int `json:"views"`
|
|
PublishedText string `json:"published_text"`
|
|
PublishedDate string `json:"published_date"`
|
|
}
|
|
|
|
type YTChannelResp struct {
|
|
Channel string `json:"channel"`
|
|
ChannelURL string `json:"channel_url"`
|
|
SubscribersText string `json:"subscribers_text"`
|
|
Subscribers int `json:"subscribers"`
|
|
Videos []YTVideo `json:"videos"`
|
|
}
|
|
|
|
type videosCache struct {
|
|
mu sync.RWMutex
|
|
data struct {
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
Channel string `json:"channel"`
|
|
Items []YTVideo `json:"items"`
|
|
}
|
|
}
|
|
|
|
var vc videosCache
|
|
|
|
// simple in-memory rate limiter for manual videos refresh
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
hits []time.Time
|
|
}
|
|
|
|
func (rl *rateLimiter) Allow(now time.Time, limit int, per time.Duration) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
// drop timestamps older than window
|
|
cutoff := now.Add(-per)
|
|
i := 0
|
|
for _, t := range rl.hits {
|
|
if t.After(cutoff) {
|
|
rl.hits[i] = t
|
|
i++
|
|
}
|
|
}
|
|
rl.hits = rl.hits[:i]
|
|
if len(rl.hits) >= limit {
|
|
return false
|
|
}
|
|
rl.hits = append(rl.hits, now)
|
|
return true
|
|
}
|
|
|
|
var videosPostLimiter rateLimiter
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
|
|
// initial fetch
|
|
if err := refresh(ctx); err != nil {
|
|
log.Printf("initial refresh error: %v", err)
|
|
}
|
|
|
|
// scheduler
|
|
go scheduler(ctx)
|
|
go videosScheduler(ctx)
|
|
|
|
// Initial videos fetch on startup to warm cache
|
|
if err := refreshVideos(ctx); err != nil {
|
|
log.Printf("initial videos refresh error: %v", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
mux.HandleFunc("/data/club.json", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
_ = json.NewEncoder(w).Encode(c.data)
|
|
case http.MethodDelete:
|
|
// delete on-disk file and clear in-memory cache
|
|
path := dataPath()
|
|
_ = os.Remove(path)
|
|
c.mu.Lock()
|
|
c.data = Combined{}
|
|
c.mu.Unlock()
|
|
// trigger immediate refresh so next GET has fresh data
|
|
if err := refresh(r.Context()); err != nil {
|
|
log.Printf("manual refresh after delete failed: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
mux.HandleFunc("/data/club.js", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
c.mu.RLock()
|
|
payload, _ := json.Marshal(c.data)
|
|
c.mu.RUnlock()
|
|
w.Write([]byte("window.FACR_DATA="))
|
|
w.Write(payload)
|
|
w.Write([]byte(";"))
|
|
})
|
|
|
|
// Blog list JSON for frontend
|
|
mux.HandleFunc("/data/blog-list.json", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
limit := 50 // Default limit for blog list
|
|
if q := r.URL.Query().Get("limit"); q != "" {
|
|
if n, err := strconv.Atoi(q); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
items, err := listLatestBlogs(staticPath(), limit)
|
|
if err != nil {
|
|
log.Printf("blog-list.json error: %v", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(items)
|
|
})
|
|
|
|
// Blog API: latest N posts from filesystem
|
|
mux.HandleFunc("/api/blog/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
limit := 5
|
|
if q := r.URL.Query().Get("limit"); q != "" {
|
|
if n, err := strconv.Atoi(q); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
items, err := listLatestBlogs(staticPath(), limit)
|
|
if err != nil {
|
|
log.Printf("listLatestBlogs error: %v", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(items)
|
|
})
|
|
|
|
// Videos API
|
|
mux.HandleFunc("/api/videos/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
vc.mu.RLock()
|
|
items := vc.data.Items
|
|
fetched := vc.data.FetchedAt
|
|
channel := vc.data.Channel
|
|
vc.mu.RUnlock()
|
|
if len(items) == 0 {
|
|
// lazy refresh if empty
|
|
if err := refreshVideos(r.Context()); err != nil {
|
|
log.Printf("refreshVideos error: %v", err)
|
|
}
|
|
vc.mu.RLock()
|
|
items = vc.data.Items
|
|
fetched = vc.data.FetchedAt
|
|
channel = vc.data.Channel
|
|
vc.mu.RUnlock()
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(struct {
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
Channel string `json:"channel"`
|
|
Items []YTVideo `json:"items"`
|
|
}{FetchedAt: fetched, Channel: channel, Items: items})
|
|
case http.MethodPost:
|
|
// rate limit: 5 requests per minute for manual refresh
|
|
if !videosPostLimiter.Allow(time.Now(), 5, time.Minute) {
|
|
http.Error(w, "rate limit: max 5 refresh per minute", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
if err := refreshVideos(r.Context()); err != nil {
|
|
log.Printf("manual refreshVideos error: %v", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Serve raw persisted videos json for debugging/preview
|
|
mux.HandleFunc("/data/videos.json", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
b, err := os.ReadFile(videosPath())
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write(b)
|
|
})
|
|
|
|
// Blog creation API (admin)
|
|
mux.HandleFunc("/api/blog/new", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if !checkBasicAuth(r) {
|
|
requireBasicAuth(w)
|
|
return
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
|
|
if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slugInput := strings.TrimSpace(r.FormValue("slug"))
|
|
annotation := strings.TrimSpace(r.FormValue("annotation"))
|
|
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
|
|
htmlContent := strings.TrimSpace(r.FormValue("content"))
|
|
catsRaw := strings.TrimSpace(r.FormValue("categories"))
|
|
var cats []string
|
|
if catsRaw != "" {
|
|
for _, p := range strings.Split(catsRaw, ",") {
|
|
c := strings.TrimSpace(p)
|
|
if c != "" {
|
|
cats = append(cats, c)
|
|
}
|
|
}
|
|
}
|
|
if title == "" || htmlContent == "" {
|
|
http.Error(w, "missing title or content", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Default content mode to visual if not specified
|
|
if contentMode == "" {
|
|
contentMode = "visual"
|
|
}
|
|
f, fh, err := r.FormFile("image")
|
|
if err != nil {
|
|
http.Error(w, "missing image", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
// Accept PNG/JPG/JPEG; always store normalized PNG 1600x969
|
|
name := strings.ToLower(fh.Filename)
|
|
if !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")) {
|
|
http.Error(w, "image must be .png, .jpg, or .jpeg", http.StatusBadRequest)
|
|
return
|
|
}
|
|
site := staticPath()
|
|
// Determine next ID
|
|
nid, err := nextBlogID(site)
|
|
if err != nil {
|
|
http.Error(w, "failed to compute next id", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
idStr := fmt.Sprintf("%04d", nid)
|
|
|
|
// Determine slug
|
|
var finalSlug string
|
|
if slugInput != "" {
|
|
// Use provided slug (already validated by frontend)
|
|
finalSlug = slugInput
|
|
} else {
|
|
// Generate from title
|
|
baseSlug := generateSlug(title)
|
|
finalSlug = ensureUniqueSlug(site, baseSlug)
|
|
}
|
|
|
|
// Write image (normalize to 1600x969 with letterboxing)
|
|
imgDir := filepath.Join(site, "img", "blog")
|
|
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
|
http.Error(w, "storage error: img dir", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
imgPath := filepath.Join(imgDir, idStr+".png")
|
|
if err := normalizeBlogImage(f, imgPath); err != nil {
|
|
http.Error(w, "image processing failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Read template and replace
|
|
tplPath := filepath.Join(site, "blog", "0030.html")
|
|
tplBytes, err := os.ReadFile(tplPath)
|
|
if err != nil {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s := string(tplBytes)
|
|
// Replace H1 title inside page header (match when lte-header is among multiple classes)
|
|
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="[^"]*\blte-header\b[^"]*"[^>]*>.*?</h1>`)
|
|
s = reH1.ReplaceAllString(s, "<h1 class=\"lte-header\">"+htmlEscape(title)+"</h1>")
|
|
// Replace main hero image to point to new id
|
|
reImg := regexp.MustCompile(`(?is)src=\"\.\./img/blog/\d{4}\.png\"`)
|
|
s = reImg.ReplaceAllString(s, "src=\"../img/blog/"+idStr+".png\"")
|
|
// Replace post top image similarly if found with different quoting
|
|
reImg2 := regexp.MustCompile(`(?is)src=\"\.\./img/blog/\d{4}\.png\"`)
|
|
s = reImg2.ReplaceAllString(s, "src=\"../img/blog/"+idStr+".png\"")
|
|
// Replace the main content inside <div class="text lte-text-page clearfix"> ... </div>
|
|
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">[\s\S]*?</div>`)
|
|
s = reContent.ReplaceAllString(s, "<div class=\"text lte-text-page clearfix\">\n"+htmlContent+"\n</div>")
|
|
// Inject categories as <meta name="category" content="..."> before </head>
|
|
if len(cats) > 0 {
|
|
var meta string
|
|
for _, c := range cats {
|
|
meta += "<meta name=\"category\" content=\"" + htmlEscape(c) + "\">\n"
|
|
}
|
|
reHead := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHead.ReplaceAllString(s, meta+"</head>")
|
|
}
|
|
// Inject slug as <meta name="slug" content="..."> before </head>
|
|
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(finalSlug) + "\">\n"
|
|
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
|
|
// Inject annotation as <meta name="description" content="..."> before </head>
|
|
if annotation != "" {
|
|
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
|
|
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
|
|
}
|
|
// Inject content mode as <meta name="content_mode" content="..."> before </head>
|
|
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
|
|
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
|
|
// Inject Rybbit analytics script before </head>
|
|
rybbitScript := "<script src=\"https://rybbit.tdvorak.dev/api/script.js\" data-site-id=\"d40b7ffffffa\" defer></script>\n"
|
|
reHeadRybbit := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadRybbit.ReplaceAllString(s, rybbitScript+"</head>")
|
|
|
|
// Write new blog html with both numeric and slug filenames
|
|
blogDir := filepath.Join(site, "blog")
|
|
if err := os.MkdirAll(blogDir, 0755); err != nil {
|
|
http.Error(w, "storage error: blog dir", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Write numeric file (for backward compatibility)
|
|
htmlPath := filepath.Join(blogDir, idStr+".html")
|
|
if err := os.WriteFile(htmlPath, []byte(s), 0644); err != nil {
|
|
http.Error(w, "cannot write blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Write slug file (new format)
|
|
slugPath := filepath.Join(blogDir, finalSlug+".html")
|
|
if err := os.WriteFile(slugPath, []byte(s), 0644); err != nil {
|
|
http.Error(w, "cannot write slug blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": idStr,
|
|
"slug": finalSlug,
|
|
"link": "/blog/" + finalSlug,
|
|
"image": "/img/blog/" + idStr + ".png",
|
|
"message": "created",
|
|
})
|
|
})
|
|
|
|
// Blog slug resolution: convert slug to numeric ID
|
|
mux.HandleFunc("/api/blog/resolve", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
|
if slug == "" {
|
|
http.Error(w, "missing slug", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Validate slug format
|
|
if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(slug) {
|
|
http.Error(w, "invalid slug format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
site := staticPath()
|
|
blogDir := filepath.Join(site, "blog")
|
|
|
|
// First try to find slug-based file
|
|
slugPath := filepath.Join(blogDir, slug+".html")
|
|
if b, err := os.ReadFile(slugPath); err == nil {
|
|
s := string(b)
|
|
// Try to find the corresponding numeric file
|
|
entries, _ := os.ReadDir(blogDir)
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
|
|
continue
|
|
}
|
|
numericPath := filepath.Join(blogDir, name)
|
|
numericContent, _ := os.ReadFile(numericPath)
|
|
if string(numericContent) == s {
|
|
// Found matching numeric file
|
|
numericID := strings.TrimSuffix(name, ".html")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": numericID, "slug": slug})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not found, return 404
|
|
http.Error(w, "slug not found", http.StatusNotFound)
|
|
})
|
|
|
|
// Blog fetch (admin): returns title, content html, image for editing
|
|
mux.HandleFunc("/api/blog/get", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if !checkBasicAuth(r) {
|
|
requireBasicAuth(w)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
re := regexp.MustCompile(`^\d{4}$`)
|
|
if !re.MatchString(id) {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
path := filepath.Join(staticPath(), "blog", id+".html")
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
s := string(b)
|
|
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="[^"]*\blte-header\b[^"]*"[^>]*>(.*?)</h1>`)
|
|
m := reH1.FindStringSubmatch(s)
|
|
title := ""
|
|
if len(m) >= 2 {
|
|
// strip HTML tags inside, if any
|
|
inner := m[1]
|
|
// remove any nested tags crudely
|
|
inner = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(inner, "")
|
|
inner = strings.TrimSpace(inner)
|
|
title = inner
|
|
}
|
|
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">([\s\S]*?)</div>`)
|
|
mc := reContent.FindStringSubmatch(s)
|
|
content := ""
|
|
if len(mc) >= 2 {
|
|
content = strings.TrimSpace(mc[1])
|
|
}
|
|
cats := extractCategories(path)
|
|
slug := extractSlug(path, id+".html")
|
|
annotation := extractAnnotation(path)
|
|
contentMode := extractContentMode(path)
|
|
img := "/img/blog/" + id + ".png"
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "title": title, "slug": slug, "annotation": annotation, "content_mode": contentMode, "content_html": content, "image": img, "categories": cats})
|
|
})
|
|
|
|
// Blog edit (admin): update title/content and optionally replace image
|
|
mux.HandleFunc("/api/blog/edit", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if !checkBasicAuth(r) {
|
|
requireBasicAuth(w)
|
|
return
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
|
|
if err := r.ParseMultipartForm(25 << 20); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.FormValue("id"))
|
|
re := regexp.MustCompile(`^\d{4}$`)
|
|
if !re.MatchString(id) {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slugInput := strings.TrimSpace(r.FormValue("slug"))
|
|
annotation := strings.TrimSpace(r.FormValue("annotation"))
|
|
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
|
|
htmlContent := strings.TrimSpace(r.FormValue("content"))
|
|
catsRaw := strings.TrimSpace(r.FormValue("categories"))
|
|
var cats []string
|
|
if catsRaw != "" {
|
|
for _, p := range strings.Split(catsRaw, ",") {
|
|
c := strings.TrimSpace(p)
|
|
if c != "" {
|
|
cats = append(cats, c)
|
|
}
|
|
}
|
|
}
|
|
if title == "" || htmlContent == "" {
|
|
http.Error(w, "missing title or content", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Default content mode to visual if not specified
|
|
if contentMode == "" {
|
|
contentMode = "visual"
|
|
}
|
|
site := staticPath()
|
|
if f, fh, err := r.FormFile("image"); err == nil {
|
|
defer f.Close()
|
|
// Accept PNG/JPG/JPEG; always store normalized PNG 1600x969
|
|
name := strings.ToLower(fh.Filename)
|
|
if !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")) {
|
|
http.Error(w, "image must be .png, .jpg, or .jpeg", http.StatusBadRequest)
|
|
return
|
|
}
|
|
imgPath := filepath.Join(site, "img", "blog", id+".png")
|
|
if err := os.MkdirAll(filepath.Dir(imgPath), 0755); err != nil {
|
|
http.Error(w, "storage error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := normalizeBlogImage(f, imgPath); err != nil {
|
|
http.Error(w, "image processing failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
hPath := filepath.Join(site, "blog", id+".html")
|
|
b, err := os.ReadFile(hPath)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
s := string(b)
|
|
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="lte-header"[^>]*>.*?</h1>`)
|
|
s = reH1.ReplaceAllString(s, "<h1 class=\"lte-header\">"+htmlEscape(title)+"</h1>")
|
|
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">[\s\S]*?</div>`)
|
|
s = reContent.ReplaceAllString(s, "<div class=\"text lte-text-page clearfix\">\n"+htmlContent+"\n</div>")
|
|
// Replace categories meta tags
|
|
reMeta := regexp.MustCompile(`(?is)<meta name="category" content="[^"]*"\s*/?>\s*`)
|
|
s = reMeta.ReplaceAllString(s, "")
|
|
if len(cats) > 0 {
|
|
var meta string
|
|
for _, c := range cats {
|
|
meta += "<meta name=\"category\" content=\"" + htmlEscape(c) + "\">\n"
|
|
}
|
|
reHead := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHead.ReplaceAllString(s, meta+"</head>")
|
|
}
|
|
// Update or add slug meta tag
|
|
reSlug := regexp.MustCompile(`(?is)<meta name="slug" content="[^"]*"\s*/?>\s*`)
|
|
s = reSlug.ReplaceAllString(s, "")
|
|
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(slugInput) + "\">\n"
|
|
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
|
|
// Update or add annotation meta tag
|
|
reAnnotation := regexp.MustCompile(`(?is)<meta name="description" content="[^"]*"\s*/?>\s*`)
|
|
s = reAnnotation.ReplaceAllString(s, "")
|
|
if annotation != "" {
|
|
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
|
|
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
|
|
}
|
|
// Update or add content mode meta tag
|
|
reContentMode := regexp.MustCompile(`(?is)<meta name="content_mode" content="[^"]*"\s*/?>\s*`)
|
|
s = reContentMode.ReplaceAllString(s, "")
|
|
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
|
|
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
|
|
// Ensure Rybbit analytics script is present (idempotent: remove existing first, then add)
|
|
reRybbit := regexp.MustCompile(`(?is)<script[^>]*src="https://rybbit\.tdvorak\.dev/api/script\.js"[^>]*>\s*</script>\s*`)
|
|
s = reRybbit.ReplaceAllString(s, "")
|
|
rybbitScript := "<script src=\"https://rybbit.tdvorak.dev/api/script.js\" data-site-id=\"d40b7ffffffa\" defer></script>\n"
|
|
reHeadRybbit := regexp.MustCompile(`(?is)</head>`)
|
|
s = reHeadRybbit.ReplaceAllString(s, rybbitScript+"</head>")
|
|
if err := os.WriteFile(hPath, []byte(s), 0644); err != nil {
|
|
http.Error(w, "cannot write", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
// Blog delete (admin)
|
|
mux.HandleFunc("/api/blog/delete", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
if !checkBasicAuth(r) {
|
|
requireBasicAuth(w)
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
// Accept numeric ID (0001) or slug (my-post-title)
|
|
validID := regexp.MustCompile(`^\d{4}$`).MatchString(id) || regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(id)
|
|
if !validID {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
site := staticPath()
|
|
blogDir := filepath.Join(site, "blog")
|
|
|
|
// If numeric ID, also find and delete the slug file
|
|
if regexp.MustCompile(`^\d{4}$`).MatchString(id) {
|
|
// Delete numeric HTML file
|
|
numericPath := filepath.Join(blogDir, id+".html")
|
|
// Find slug before deleting
|
|
if content, err := os.ReadFile(numericPath); err == nil {
|
|
slug := extractSlugFromContent(string(content))
|
|
if slug != "" && slug != id {
|
|
_ = os.Remove(filepath.Join(blogDir, slug+".html"))
|
|
}
|
|
}
|
|
_ = os.Remove(numericPath)
|
|
// Delete image
|
|
_ = os.Remove(filepath.Join(site, "img", "blog", id+".png"))
|
|
} else {
|
|
// It's a slug - find the numeric ID and delete both
|
|
entries, _ := os.ReadDir(blogDir)
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
|
|
continue
|
|
}
|
|
numericID := strings.TrimSuffix(name, ".html")
|
|
numericPath := filepath.Join(blogDir, name)
|
|
if content, err := os.ReadFile(numericPath); err == nil {
|
|
fileSlug := extractSlugFromContent(string(content))
|
|
if fileSlug == id {
|
|
// Found matching numeric file, delete both
|
|
_ = os.Remove(numericPath)
|
|
_ = os.Remove(filepath.Join(site, "img", "blog", numericID+".png"))
|
|
_ = os.Remove(filepath.Join(blogDir, id+".html"))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
// Static file server for the frontend
|
|
sp := staticPath()
|
|
log.Printf("serving static from: %s", sp)
|
|
fs := http.FileServer(http.Dir(sp))
|
|
// Serve common asset prefixes explicitly
|
|
mux.Handle("/img/", fs)
|
|
mux.Handle("/css/", fs)
|
|
mux.Handle("/js/", fs)
|
|
// Protect /admin/ with Basic Auth
|
|
mux.Handle("/admin/", basicAuth(http.StripPrefix("/admin/", http.FileServer(http.Dir(filepath.Join(staticPath(), "admin"))))))
|
|
|
|
// Custom slug-based blog routing
|
|
mux.HandleFunc("/blog/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Extract the slug/ID from the path
|
|
path := strings.TrimPrefix(r.URL.Path, "/blog/")
|
|
if path == "" || path == "/" {
|
|
// Serve the main blog listing page
|
|
http.ServeFile(w, r, filepath.Join(sp, "blog.html"))
|
|
return
|
|
}
|
|
|
|
// Remove .html extension if present
|
|
if strings.HasSuffix(path, ".html") {
|
|
path = strings.TrimSuffix(path, ".html")
|
|
}
|
|
|
|
// Check if it's a numeric ID (legacy format)
|
|
if regexp.MustCompile(`^\d{4}$`).MatchString(path) {
|
|
// Serve numeric file directly
|
|
numericPath := filepath.Join(sp, "blog", path+".html")
|
|
if _, err := os.Stat(numericPath); err == nil {
|
|
http.ServeFile(w, r, numericPath)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if it's a slug (new format)
|
|
if regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(path) {
|
|
slugPath := filepath.Join(sp, "blog", path+".html")
|
|
if _, err := os.Stat(slugPath); err == nil {
|
|
http.ServeFile(w, r, slugPath)
|
|
return
|
|
}
|
|
|
|
// If slug file doesn't exist, try to resolve slug to numeric file
|
|
blogDir := filepath.Join(sp, "blog")
|
|
entries, err := os.ReadDir(blogDir)
|
|
if err == nil {
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
|
|
continue
|
|
}
|
|
numericPath := filepath.Join(blogDir, name)
|
|
|
|
// Check if this numeric file has the matching slug
|
|
b, err := os.ReadFile(numericPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := string(b)
|
|
re := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) >= 2 && m[1] == path {
|
|
http.ServeFile(w, r, numericPath)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not found, serve 404
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
mux.Handle("/zapasy/", fs)
|
|
// Fallback: serve index.html at root, otherwise delegate to static file server
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
|
http.ServeFile(w, r, filepath.Join(sp, "index.html"))
|
|
return
|
|
}
|
|
fs.ServeHTTP(w, r)
|
|
})
|
|
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
srv := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: mux,
|
|
}
|
|
go func() {
|
|
log.Printf("server listening on :%s", port)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
ctxShut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctxShut)
|
|
}
|
|
|
|
func okCORS(w http.ResponseWriter) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
|
|
func scheduler(ctx context.Context) {
|
|
// default 30m; during match window (±2h around any match today), 2m
|
|
for {
|
|
var d time.Duration = 30 * time.Minute
|
|
if withinMatchWindow() {
|
|
d = 2 * time.Minute
|
|
}
|
|
select {
|
|
case <-time.After(d):
|
|
if err := refresh(ctx); err != nil {
|
|
log.Printf("refresh error: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func withinMatchWindow() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
loc, err := time.LoadLocation("Europe/Prague")
|
|
if err != nil {
|
|
// Fallback to local/UTC if tzdata is missing to avoid panic
|
|
loc = time.Local
|
|
}
|
|
now := time.Now().In(loc)
|
|
for _, comp := range c.data.ClubDetail.Competitions {
|
|
for _, m := range comp.Matches {
|
|
// date format in API example: "12.08.2023 18:00" or "12.08.2023 18:00"
|
|
t, err := time.ParseInLocation("02.01.2006 15:04", m.DateTime, loc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if absDuration(now.Sub(t)) <= 2*time.Hour {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func absDuration(d time.Duration) time.Duration {
|
|
if d < 0 {
|
|
return -d
|
|
}
|
|
return d
|
|
}
|
|
|
|
func refresh(ctx context.Context) error {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
urlDetail := fmt.Sprintf("%s/club/%s/%s", baseURL, clubType, clubID)
|
|
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
|
|
|
|
var detail ClubDetail
|
|
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
|
return fmt.Errorf("detail: %w", err)
|
|
}
|
|
var table ClubTable
|
|
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
|
return fmt.Errorf("table: %w", err)
|
|
}
|
|
|
|
// Override or inject facr_link based on match_id
|
|
for i := range detail.Competitions {
|
|
for j := range detail.Competitions[i].Matches {
|
|
mid := detail.Competitions[i].Matches[j].MatchID
|
|
if mid != "" {
|
|
detail.Competitions[i].Matches[j].FacrLink = fmt.Sprintf("https://www.fotbal.cz/futsal/zapasy/futsal/%s", mid)
|
|
}
|
|
// Override logo URLs for our club in match details
|
|
if detail.Competitions[i].Matches[j].HomeID == clubID {
|
|
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
|
|
}
|
|
if detail.Competitions[i].Matches[j].AwayID == clubID {
|
|
detail.Competitions[i].Matches[j].AwayLogoURL = "/img/logo.png"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Override logo URLs for our club in the table standings
|
|
for i := range table.Competitions {
|
|
for j := range table.Competitions[i].Table.Overall {
|
|
if table.Competitions[i].Table.Overall[j].TeamID == clubID {
|
|
table.Competitions[i].Table.Overall[j].TeamLogo = "/img/logo.png"
|
|
}
|
|
}
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.data = Combined{
|
|
FetchedAt: time.Now(),
|
|
ClubDetail: detail,
|
|
ClubTable: table,
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// persist to disk for control/deletion
|
|
if err := writeDiskJSON(c.data); err != nil {
|
|
log.Printf("warn: write disk json: %v", err)
|
|
}
|
|
log.Printf("refreshed data: comps=%d", len(detail.Competitions))
|
|
return nil
|
|
}
|
|
|
|
func getJSON(ctx context.Context, client *http.Client, url string, out any) error {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("status %d: %s", resp.StatusCode, bytes.TrimSpace(b))
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
func writeDiskJSON(d Combined) error {
|
|
path := dataPath()
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
b, err := json.MarshalIndent(d, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
|
return fmt.Errorf("write tmp: %w", err)
|
|
}
|
|
// On Windows, Rename over existing file may fail; remove target first.
|
|
_ = os.Remove(path)
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|