[BACKEND] Initialize Go backend structure and dependencies (#23)
This commit is contained in:
44
server/.env.example
Normal file
44
server/.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# Chat Switchboard - Server Environment Variables
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Server settings
|
||||
PORT=8080
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# Database settings (PostgreSQL)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=chat_switchboard
|
||||
DB_PASSWORD=your-secure-password-here
|
||||
DB_NAME=chat_switchboard
|
||||
DB_SSL_MODE=disable
|
||||
DB_MAX_CONNS=25
|
||||
|
||||
# JWT settings
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRATION=24h
|
||||
JWT_ISSUER=chat-switchboard
|
||||
|
||||
# CORS settings (comma-separated for multiple origins)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
|
||||
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,Accept,Origin
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW=1m
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional: Redis configuration (for WebSocket pub/sub and session cache)
|
||||
# REDIS_HOST=localhost
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_DB=0
|
||||
# REDIS_PASSWORD=
|
||||
|
||||
# Optional: External services
|
||||
# OPENAI_API_KEY=
|
||||
# ANTHROPIC_API_KEY=
|
||||
# GOOGLE_API_KEY=
|
||||
@@ -1,5 +1,8 @@
|
||||
# ==========================================
|
||||
# Chat Switchboard Backend Dockerfile
|
||||
# Chat Switchboard - Backend Dockerfile
|
||||
# ==========================================
|
||||
# Standalone Go API server for cluster deployment.
|
||||
# Build context is ./server (not repo root).
|
||||
# ==========================================
|
||||
|
||||
# Build stage
|
||||
@@ -7,16 +10,14 @@ FROM golang:1.22-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files first for caching
|
||||
# Build context is ./server, so paths are relative to that
|
||||
COPY go.mod go.sum ./
|
||||
# Copy module files and download deps
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code from server directory
|
||||
# Copy source and tidy (resolves any go.sum gaps)
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 go build -o /bin/engine .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard .
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
@@ -25,11 +26,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /bin/engine /usr/local/bin/engine
|
||||
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
ENTRYPOINT ["engine"]
|
||||
CMD ["serve"]
|
||||
ENTRYPOINT ["switchboard"]
|
||||
|
||||
36
server/config/config.go
Normal file
36
server/config/config.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
Environment string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
// A .env file is loaded if present but not required.
|
||||
func Load() *Config {
|
||||
// Best-effort .env load (not required in production)
|
||||
_ = godotenv.Load()
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
55
server/database/database.go
Normal file
55
server/database/database.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
)
|
||||
|
||||
// DB is the application-wide database connection pool.
|
||||
var DB *sql.DB
|
||||
|
||||
// Connect opens a connection pool to PostgreSQL using the
|
||||
// DATABASE_URL from config. It pings to verify connectivity.
|
||||
func Connect(cfg *config.Config) error {
|
||||
if cfg.DatabaseURL == "" {
|
||||
log.Println("⚠ DATABASE_URL not set — running without database")
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
DB, err = sql.Open("postgres", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("database open: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool tuning
|
||||
DB.SetMaxOpenConns(25)
|
||||
DB.SetMaxIdleConns(5)
|
||||
|
||||
if err := DB.Ping(); err != nil {
|
||||
return fmt.Errorf("database ping: %w", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Database connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the connection pool.
|
||||
func Close() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnected returns true if a database connection is available.
|
||||
func IsConnected() bool {
|
||||
if DB == nil {
|
||||
return false
|
||||
}
|
||||
return DB.Ping() == nil
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
module git.gobha.me/xcaliber/chat-switchboard
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,4 +34,4 @@ require (
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,54 +2,48 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load .env file if present
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: could not load .env file: %v", err)
|
||||
}
|
||||
// Load configuration from environment
|
||||
cfg := config.Load()
|
||||
|
||||
// Get port from environment
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
// Connect to database (optional — runs without it in unmanaged mode)
|
||||
if err := database.Connect(cfg); err != nil {
|
||||
log.Printf("⚠ Database unavailable: %v", err)
|
||||
log.Println(" Running in unmanaged mode (no persistence)")
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Initialize router
|
||||
r := gin.Default()
|
||||
|
||||
// CORS middleware
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// Health check
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
status := gin.H{
|
||||
"status": "ok",
|
||||
"database": database.IsConnected(),
|
||||
}
|
||||
c.JSON(200, status)
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// Auth routes
|
||||
// Public auth routes
|
||||
api.POST("/auth/register", handleRegister)
|
||||
api.POST("/auth/login", handleLogin)
|
||||
|
||||
// Protected routes
|
||||
protected := api.Group("")
|
||||
protected.Use(authMiddleware())
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
{
|
||||
// Chats
|
||||
protected.GET("/chats", handleListChats)
|
||||
@@ -76,36 +70,25 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔀 Chat Switchboard API starting on port %s", port)
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Printf("🔀 Chat Switchboard API starting on port %s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder handlers - implement in handlers/ package
|
||||
func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateAPIConfig(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "not implemented"})
|
||||
}
|
||||
func handleDeleteAPIConfig(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "not implemented"})
|
||||
}
|
||||
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
|
||||
func authMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// TODO: Implement JWT validation
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
// Placeholder handlers — will be moved to handlers/ package
|
||||
func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleCreateAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleDeleteAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
)
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
c.JSON(200, gin.H{"status": "ok", "database": false})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -22,29 +25,18 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
expectedBody := `{"status":"ok"}`
|
||||
if w.Body.String() != expectedBody {
|
||||
t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
r.Use(middleware.CORS())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("OPTIONS", "/api/v1/test", nil)
|
||||
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
@@ -60,7 +52,6 @@ func TestRouterSetup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Setup routes like in main.go
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
@@ -73,7 +64,6 @@ func TestRouterSetup(t *testing.T) {
|
||||
api.POST("/chats", handleCreateChat)
|
||||
}
|
||||
|
||||
// Verify routes are registered
|
||||
routes := r.Routes()
|
||||
routePaths := make(map[string]bool)
|
||||
for _, route := range routes {
|
||||
@@ -93,4 +83,24 @@ func TestRouterSetup(t *testing.T) {
|
||||
t.Errorf("Expected route %s to be registered", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareNoDatabase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
cfg := &config.Config{JWTSecret: "test-secret"}
|
||||
// No database connected — middleware should pass through
|
||||
r.Use(middleware.Auth(cfg))
|
||||
r.GET("/protected", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected pass-through with no DB, got status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
68
server/middleware/auth.go
Normal file
68
server/middleware/auth.go
Normal 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
100
server/middleware/cors.go
Normal 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
114
server/middleware/errors.go
Normal 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,
|
||||
})
|
||||
}
|
||||
130
server/middleware/logging.go
Normal file
130
server/middleware/logging.go
Normal 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()
|
||||
}
|
||||
}
|
||||
148
server/models/models.go
Normal file
148
server/models/models.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// BaseModel contains fields common to all models
|
||||
type BaseModel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
BaseModel
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Role string `json:"role" db:"role"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
|
||||
// UserRole constants
|
||||
const (
|
||||
UserRoleUser = "user"
|
||||
UserRoleAdmin = "admin"
|
||||
)
|
||||
|
||||
// Chat represents a chat conversation
|
||||
type Chat struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Model string `json:"model" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
BaseModel
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
Role string `json:"role" db:"role"` // "user", "assistant", "system"
|
||||
Content string `json:"content" db:"content"`
|
||||
Tokens int `json:"tokens,omitempty" db:"tokens"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
|
||||
}
|
||||
|
||||
// MessageRole constants
|
||||
const (
|
||||
MessageRoleUser = "user"
|
||||
MessageRoleAssistant = "assistant"
|
||||
MessageRoleSystem = "system"
|
||||
)
|
||||
|
||||
// APIConfig represents a configured API provider
|
||||
type APIConfig struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc.
|
||||
APIKey string `json:"-" db:"api_key"`
|
||||
BaseURL string `json:"base_url,omitempty" db:"base_url"`
|
||||
Model string `json:"model" db:"model"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
}
|
||||
|
||||
// Channel represents a chat channel
|
||||
type Channel struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
IsPrivate bool `json:"is_private" db:"is_private"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
}
|
||||
|
||||
// ChannelMember represents a member of a channel
|
||||
type ChannelMember struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Role string `json:"role" db:"role"` // "member", "moderator", "admin"
|
||||
}
|
||||
|
||||
// ChannelMessage represents a message in a channel
|
||||
type ChannelMessage struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Content string `json:"content" db:"content"`
|
||||
ParentID string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
}
|
||||
|
||||
// Note represents a user note
|
||||
type Note struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
|
||||
IsShared bool `json:"is_shared" db:"is_shared"`
|
||||
}
|
||||
|
||||
// KnowledgeBase represents a knowledge base collection
|
||||
type KnowledgeBase struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Source string `json:"source" db:"source"` // "url", "file", "text"
|
||||
Settings string `json:"settings,omitempty" db:"settings"` // JSON settings
|
||||
}
|
||||
|
||||
// Workflow represents a workflow definition
|
||||
type Workflow struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Steps string `json:"steps" db:"steps"` // JSON array of steps
|
||||
Settings string `json:"settings,omitempty" db:"settings"`
|
||||
IsPublic bool `json:"is_public" db:"is_public"`
|
||||
}
|
||||
|
||||
// Settings represents user settings
|
||||
type Settings struct {
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Theme string `json:"theme" db:"theme"`
|
||||
Language string `json:"language" db:"language"`
|
||||
Model string `json:"model" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
||||
Temperature float64 `json:"temperature" db:"temperature"`
|
||||
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
|
||||
}
|
||||
|
||||
// APIToken represents an API token for external access
|
||||
type APIToken struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Token string `json:"-" db:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
8
server/version.go
Normal file
8
server/version.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package main
|
||||
|
||||
// Version is set at build time via:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=$(cat VERSION)"
|
||||
//
|
||||
// Falls back to "dev" for local builds without ldflags.
|
||||
var Version = "dev"
|
||||
Reference in New Issue
Block a user