This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
package database
import (
"context"
"log"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
// DB is the global database connection pool
DB *gorm.DB
// dbLogger is the logger instance for database operations
dbLogger = logger.New(
log.New(log.Writer(), "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Warn,
Colorful: true,
IgnoreRecordNotFoundError: true,
},
)
)
// InitDB initializes the database connection with connection pooling
func InitDB() (*gorm.DB, error) {
var err error
// Set up database connection with connection pooling
DB, err = gorm.Open(postgres.Open(config.AppConfig.DatabaseURL), &gorm.Config{
Logger: dbLogger,
PrepareStmt: true,
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
return nil, err
}
// Get the underlying sql.DB instance to configure connection pool
sqlDB, err := DB.DB()
if err != nil {
return nil, err
}
// Set connection pool parameters
sqlDB.SetMaxIdleConns(config.AppConfig.MaxIdleConnections)
sqlDB.SetMaxOpenConns(config.AppConfig.MaxOpenConnections)
sqlDB.SetConnMaxLifetime(config.AppConfig.ConnMaxLifetime)
log.Println("Database connection established with connection pooling")
return DB, nil
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
return DB
}
// CloseDB closes the database connection
func CloseDB() error {
sqlDB, err := DB.DB()
if err != nil {
log.Printf("Error getting database instance: %v", err)
return err
}
// Close the database connection
if err := sqlDB.Close(); err != nil {
log.Printf("Error closing database connection: %v", err)
return err
}
log.Println("Database connection closed")
return nil
}
// WithTransaction executes a function within a database transaction
func WithTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
tx := DB.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r) // re-throw panic after Rollback
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback().Error; rbErr != nil {
return rbErr
}
return err
}
return tx.Commit().Error
}
// MigrateDB runs database migrations for all models
func MigrateDB(db *gorm.DB) error {
// Enable UUID extension
err := db.Exec(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`).Error
if err != nil {
return err
}
// Run migrations for all models
return db.AutoMigrate(
&models.User{},
&models.Article{},
&models.Category{},
&models.Team{},
&models.Player{},
&models.Sponsor{},
&models.Settings{},
&models.MatchOverride{},
&models.TeamLogoOverride{},
&models.ContactMessage{},
&models.ContactCategory{},
&models.Contact{},
&models.NewsletterSubscription{},
&models.VisitorEvent{},
&models.ArticleTeamLink{},
&models.ArticleMatchLink{},
&models.CompetitionAlias{},
&models.EmailLog{},
&models.EmailEvent{},
&models.PasswordReset{},
&models.AboutPage{},
// Add event tables so public endpoints don't fail before any writes occur
&models.Event{},
&models.EventAttachment{},
&models.UploadedFile{},
&models.FileUsage{},
)
}
// SeedDB populates the database with initial data
func SeedDB(db *gorm.DB) error {
// Check if we already have data
var count int64
if err := db.Model(&models.User{}).Count(&count).Error; err != nil {
return err
}
// If we already have data, don't seed
if count > 0 {
log.Println("Database already seeded, skipping...")
return nil
}
// Create admin user
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
return err
}
adminUser := models.User{
Email: "admin@example.com",
Password: string(hashedPassword),
FirstName: "Admin",
LastName: "User",
Role: "admin",
}
if err := db.Create(&adminUser).Error; err != nil {
return err
}
log.Println("Database seeded successfully with admin user")
log.Printf("Admin credentials: admin@example.com / admin123")
return nil
}
// HealthCheck performs a simple database query to check if the connection is alive
func HealthCheck() error {
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Ping()
}
+1177
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
package logger
import (
"fmt"
"os"
"strings"
)
// Log levels
const (
LevelDebug = "DEBUG"
LevelInfo = "INFO"
LevelWarn = "WARN"
LevelError = "ERROR"
)
// Logger is the interface for logging
var (
// DefaultLogger is the default logger instance
DefaultLogger = NewLogger(LevelInfo)
)
// Logger represents a simple logger
type Logger struct {
level string
}
// NewLogger creates a new logger instance with the specified log level
func NewLogger(level string) *Logger {
return &Logger{level: level}
}
// SetLevel changes the logger level at runtime
func (l *Logger) SetLevel(level string) {
switch strings.ToUpper(strings.TrimSpace(level)) {
case LevelDebug, LevelInfo, LevelWarn, LevelError:
l.level = strings.ToUpper(strings.TrimSpace(level))
default:
// keep previous
}
}
// SetLevel changes the default logger level at runtime
func SetLevel(level string) {
if DefaultLogger != nil {
DefaultLogger.SetLevel(level)
}
}
// Debug logs a debug message
func (l *Logger) Debug(message string, args ...interface{}) {
if l.shouldLog(LevelDebug) {
l.log(LevelDebug, message, args...)
}
}
// Info logs an info message
func (l *Logger) Info(message string, args ...interface{}) {
if l.shouldLog(LevelInfo) {
l.log(LevelInfo, message, args...)
}
}
// Warn logs a warning message
func (l *Logger) Warn(message string, args ...interface{}) {
if l.shouldLog(LevelWarn) {
l.log(LevelWarn, message, args...)
}
}
// Error logs an error message
func (l *Logger) Error(message string, args ...interface{}) {
if l.shouldLog(LevelError) {
l.log(LevelError, message, args...)
}
}
// shouldLog checks if the log level is enabled
func (l *Logger) shouldLog(level string) bool {
levels := map[string]int{
LevelDebug: 0,
LevelInfo: 1,
LevelWarn: 2,
LevelError: 3,
}
return levels[level] >= levels[l.level]
}
// log writes the log message to stderr
func (l *Logger) log(level, message string, args ...interface{}) {
msg := message
if len(args) > 0 {
msg = fmt.Sprintf(message, args...)
}
fmt.Fprintf(os.Stderr, "[%s] %s\n", level, msg)
}
// Package level functions
// Debug logs a debug message using the default logger
func Debug(message string, args ...interface{}) {
DefaultLogger.Debug(message, args...)
}
// Info logs an info message using the default logger
func Info(message string, args ...interface{}) {
DefaultLogger.Info(message, args...)
}
// Warn logs a warning message using the default logger
func Warn(message string, args ...interface{}) {
DefaultLogger.Warn(message, args...)
}
// Error logs an error message using the default logger
func Error(message string, args ...interface{}) {
DefaultLogger.Error(message, args...)
}
+95
View File
@@ -0,0 +1,95 @@
package utils
import (
"errors"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// JWTClaims represents the JWT claims structure
type JWTClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateJWT generates a new JWT token for the given user
func GenerateJWT(userID uint, email, role string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &JWTClaims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "fotbal-club",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(GetJWTSecret()))
if err != nil {
return "", err
}
return tokenString, nil
}
// ParseJWT parses and validates a JWT token
func ParseJWT(tokenString string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(GetJWTSecret()), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
// GetJWTSecret returns the JWT secret from environment variable or a default value
func GetJWTSecret() string {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
return "default-secret-key-change-in-production"
}
return secret
}
// GetUserFromContext gets the authenticated user from Gin context
func GetUserFromContext(c *gin.Context) (*JWTClaims, error) {
// Preferred: claims set by auth middleware
if v, ok := c.Get("claims"); ok {
if cl, ok2 := v.(*JWTClaims); ok2 {
return cl, nil
}
}
// Fallback: parse Authorization header (Bearer <token>)
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return nil, errors.New("authorization header missing")
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, errors.New("invalid authorization header format")
}
token := parts[1]
cl, err := ParseJWT(token)
if err != nil {
return nil, err
}
return cl, nil
}
+18
View File
@@ -0,0 +1,18 @@
package utils
import "golang.org/x/crypto/bcrypt"
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
// CheckPassword verifies a password against a hash
func CheckPassword(password, hashedPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
+82
View File
@@ -0,0 +1,82 @@
package utils
import (
"regexp"
"strings"
)
// SanitizeHTML removes potentially dangerous HTML tags and attributes
// This is a basic implementation. For production, consider using bluemonday library
func SanitizeHTML(html string) string {
// Remove script tags and content
reScript := regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`)
html = reScript.ReplaceAllString(html, "")
// Remove inline event handlers (onclick, onerror, etc.)
reEvents := regexp.MustCompile(`(?i)\s*on\w+\s*=\s*["'][^"']*["']`)
html = reEvents.ReplaceAllString(html, "")
// Remove javascript: URLs
reJSURL := regexp.MustCompile(`(?i)javascript:`)
html = reJSURL.ReplaceAllString(html, "")
// Remove iframe tags (can be optionally allowed if needed)
reIframe := regexp.MustCompile(`(?i)<iframe[^>]*>.*?</iframe>`)
html = reIframe.ReplaceAllString(html, "")
// Remove object/embed tags
reObject := regexp.MustCompile(`(?i)<(object|embed)[^>]*>.*?</\1>`)
html = reObject.ReplaceAllString(html, "")
// Remove style tags (if CSS injection is a concern)
// Uncomment if you want to remove inline styles
// reStyle := regexp.MustCompile(`(?i)<style[^>]*>.*?</style>`)
// html = reStyle.ReplaceAllString(html, "")
return strings.TrimSpace(html)
}
// SanitizeString removes HTML tags entirely and returns plain text
func SanitizeString(input string) string {
// Remove all HTML tags
reHTML := regexp.MustCompile(`<[^>]*>`)
text := reHTML.ReplaceAllString(input, " ")
// Normalize whitespace
text = strings.Join(strings.Fields(text), " ")
return strings.TrimSpace(text)
}
// ValidateURL checks if a URL is safe (http/https only)
func ValidateURL(url string) bool {
if url == "" {
return true
}
lower := strings.ToLower(strings.TrimSpace(url))
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "/")
}
// SanitizeFilename removes dangerous characters from filenames
func SanitizeFilename(filename string) string {
// Remove path traversal attempts
filename = strings.ReplaceAll(filename, "..", "")
filename = strings.ReplaceAll(filename, "/", "")
filename = strings.ReplaceAll(filename, "\\", "")
// Allow only safe characters
re := regexp.MustCompile(`[^a-zA-Z0-9._-]`)
filename = re.ReplaceAllString(filename, "_")
// Limit length
if len(filename) > 200 {
filename = filename[:200]
}
return filename
}
// RemoveNullBytes removes null bytes that can cause issues
func RemoveNullBytes(s string) string {
return strings.ReplaceAll(s, "\x00", "")
}
+54
View File
@@ -0,0 +1,54 @@
package utils
import (
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type SubscriberTokenClaims struct {
Email string `json:"email"`
jwt.RegisteredClaims
}
// GenerateSubscriberToken creates a signed token for a subscriber email, expires in `ttl` minutes
func GenerateSubscriberToken(email string, ttlMinutes int) (string, error) {
if ttlMinutes <= 0 {
ttlMinutes = 60
}
now := time.Now()
claims := &SubscriberTokenClaims{
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlMinutes) * time.Minute)),
Issuer: "fotbal-club",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(getJWTSecret()))
}
// ParseSubscriberToken validates and returns the email embedded in the token
func ParseSubscriberToken(tokenString string) (string, error) {
token, err := jwt.ParseWithClaims(tokenString, &SubscriberTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(getJWTSecret()), nil
})
if err != nil {
return "", err
}
if claims, ok := token.Claims.(*SubscriberTokenClaims); ok && token.Valid {
return claims.Email, nil
}
return "", jwt.ErrTokenMalformed
}
func getJWTSecret() string {
s := os.Getenv("JWT_SECRET")
if s == "" {
return "default-secret-key-change-in-production"
}
return s
}