[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

44
server/.env.example Normal file
View 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=

View File

@@ -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
View 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
}

View 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
}

View File

@@ -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
)
)

View File

@@ -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"}) }

View File

@@ -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
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()
}
}

148
server/models/models.go Normal file
View 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
View 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"