[BACKEND] Initialize Go backend structure and dependencies (#23)

This commit is contained in:
2026-02-15 22:50:06 +00:00
parent 7157877f0b
commit c75f976a2d
29 changed files with 1857 additions and 253 deletions

68
server/middleware/auth.go Normal file
View File

@@ -0,0 +1,68 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// Claims represents the JWT payload.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// Auth returns a Gin middleware that validates JWT bearer tokens.
// When no database is connected (unmanaged mode), all requests
// are allowed through without authentication.
func Auth(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth when running without a database (unmanaged mode)
if !database.IsConnected() {
c.Next()
return
}
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authorization header",
})
return
}
tokenString := strings.TrimPrefix(header, "Bearer ")
if tokenString == header {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization format",
})
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
// Store claims in context for downstream handlers
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}

100
server/middleware/cors.go Normal file
View File

@@ -0,0 +1,100 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORSConfig holds the configuration for the CORS middleware
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
AllowCredentials bool
MaxAge int
}
// DefaultCORSConfig returns the default CORS configuration
func DefaultCORSConfig() *CORSConfig {
return &CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"},
AllowCredentials: false,
MaxAge: 86400, // 24 hours
}
}
// CORS returns a Gin middleware handler for CORS (Cross-Origin Resource Sharing)
func CORS() gin.HandlerFunc {
return CORSWithConfig(DefaultCORSConfig())
}
// CORSWithConfig returns a Gin middleware handler for CORS with custom configuration
func CORSWithConfig(config *CORSConfig) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
// Check if the origin is allowed
if len(config.AllowedOrigins) > 0 && config.AllowedOrigins[0] != "*" {
allowed := false
for _, allowedOrigin := range config.AllowedOrigins {
if origin == allowedOrigin {
allowed = true
break
}
}
if !allowed {
c.AbortWithStatusJSON(403, gin.H{
"error": "origin not allowed",
})
return
}
}
// Set CORS headers
if len(config.AllowedOrigins) > 0 {
if config.AllowedOrigins[0] == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
}
}
c.Header("Access-Control-Allow-Methods", joinStringSlice(config.AllowedMethods, ", "))
c.Header("Access-Control-Allow-Headers", joinStringSlice(config.AllowedHeaders, ", "))
if config.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
}
c.Header("Access-Control-Max-Age", stringFromInt(config.MaxAge))
// Handle preflight requests
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// Helper function to join a slice of strings
func joinStringSlice(slice []string, sep string) string {
if len(slice) == 0 {
return ""
}
result := slice[0]
for i := 1; i < len(slice); i++ {
result += sep + slice[i]
}
return result
}
// Helper function to convert int to string
func stringFromInt(n int) string {
if n == 0 {
return ""
}
return string(rune('0'+n%10)) + stringFromInt(n/10)
}

114
server/middleware/errors.go Normal file
View File

@@ -0,0 +1,114 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ErrorHandlerConfig holds the configuration for the error handler middleware
type ErrorHandlerConfig struct {
TimeFormat string
TimeZone string
}
// DefaultErrorHandlerConfig returns the default error handler configuration
func DefaultErrorHandlerConfig() *ErrorHandlerConfig {
return &ErrorHandlerConfig{
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
}
}
// ErrorHandler returns a Gin middleware handler for centralized error handling
func ErrorHandler() gin.HandlerFunc {
return ErrorHandlerWithConfig(DefaultErrorHandlerConfig())
}
// ErrorHandlerWithConfig returns a Gin middleware handler for centralized error handling with custom configuration
func ErrorHandlerWithConfig(config *ErrorHandlerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check if there were any errors
if len(c.Errors) > 0 {
err := c.Errors.Last()
// Determine the status code
statusCode := http.StatusInternalServerError
if gin.Mode() != gin.ReleaseMode {
// In development mode, return detailed error information
c.JSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
"message": err.Error(),
"details": c.Errors,
})
return
}
// In production mode, return a generic error message
c.JSON(statusCode, gin.H{
"error": "internal server error",
})
}
}
}
// ErrorResponse represents a standard error response
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
// NewErrorResponse creates a new error response
func NewErrorResponse(error string, code string, details map[string]string) *ErrorResponse {
return &ErrorResponse{
Error: error,
Code: code,
Details: details,
}
}
// BadRequest creates a 400 Bad Request response
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": message,
})
}
// Unauthorized creates a 401 Unauthorized response
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"error": message,
})
}
// Forbidden creates a 403 Forbidden response
func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"error": message,
})
}
// NotFound creates a 404 Not Found response
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, gin.H{
"error": message,
})
}
// InternalServerError creates a 500 Internal Server Error response
func InternalServerError(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, gin.H{
"error": message,
})
}
// ValidationError creates a response for validation errors
func ValidationError(c *gin.Context, errors map[string]string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "validation failed",
"details": errors,
})
}

View File

@@ -0,0 +1,130 @@
package middleware
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RequestLoggerConfig holds the configuration for the request logger
type RequestLoggerConfig struct {
SkipPaths []string
Logger *log.Logger
TimeFormat string
TimeZone string
}
// DefaultRequestLoggerConfig returns the default request logger configuration
func DefaultRequestLoggerConfig() *RequestLoggerConfig {
return &RequestLoggerConfig{
SkipPaths: []string{"/health", "/ready", "/metrics"},
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
}
}
// Logger returns a Gin middleware handler for logging requests
func Logger() gin.HandlerFunc {
return LoggerWithConfig(DefaultRequestLoggerConfig())
}
// LoggerWithConfig returns a Gin middleware handler for logging requests with custom configuration
func LoggerWithConfig(config *RequestLoggerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip logging for certain paths
path := c.Request.URL.Path
skip := false
for _, skipPath := range config.SkipPaths {
if path == skipPath {
skip = true
break
}
}
start := time.Now()
raw := c.Request.URL.RawQuery
c.Next()
if skip {
return
}
// Calculate latency
latency := time.Since(start)
// Get the client IP
clientIP := c.ClientIP()
// Get the request method
method := c.Request.Method
// Get the status code
statusCode := c.Writer.Status()
// Log the request
if raw != "" {
raw = "?" + raw
}
config.Logger.Printf(
"[%s] %s %s%s | %d | %v | %s",
clientIP,
method,
path,
raw,
statusCode,
latency,
c.Errors.ByType(gin.ErrorTypePrivate).String(),
)
}
}
// Recovery returns a Gin middleware handler for recovering from panics
func Recovery() gin.HandlerFunc {
return RecoveryWithConfig(DefaultRecoveryConfig())
}
// RecoveryConfig holds the configuration for the recovery middleware
type RecoveryConfig struct {
Logger *log.Logger
TimeFormat string
TimeZone string
SkipStackTrace bool
StackTraceSize int
StackAll bool
}
// DefaultRecoveryConfig returns the default recovery configuration
func DefaultRecoveryConfig() *RecoveryConfig {
return &RecoveryConfig{
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
SkipStackTrace: false,
StackTraceSize: 1024,
StackAll: false,
}
}
// RecoveryWithConfig returns a Gin middleware handler for recovering from panics with custom configuration
func RecoveryWithConfig(config *RecoveryConfig) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Log the panic
config.Logger.Printf("[PANIC] %v", err)
// Abort with a 500 error
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
})
}
}()
c.Next()
}
}