// Package server assembles the HTTP application: it wires the gin engine, // mounts route groups, and exposes helpers used by main for bootstrap. package server import ( "net/http" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "gorm.io/gorm" "code.littlelan.cn/CarrotAssistant/backend/internal/config" "code.littlelan.cn/CarrotAssistant/backend/internal/security" "code.littlelan.cn/CarrotAssistant/backend/internal/store" ) // Deps bundles the shared dependencies every handler group needs. Keeping // them in one struct avoids a long parameter list as routes grow. type Deps struct { DB *gorm.DB Config *config.Config Crypto *security.Crypto SkillsDir string } // New builds the gin engine with all route groups mounted. func New(d Deps) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(requestLogger()) // Permissive CORS for development; the admin SPA and any embedded client // run on different origins. Tighten AllowedOrigins in production. r.Use(cors.New(cors.Config{ AllowOriginFunc: func(string) bool { return true }, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-App-Token"}, AllowCredentials: true, MaxAge: 12 * time.Hour, })) // Liveness probe for container orchestrators. r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "ts": time.Now().Unix()}) }) d.mountAdminRoutes(r) d.mountChatRoutes(r) return r } // mountChatRoutes wires the public end-user Chat API under /api/v1. Every // route requires a valid X-App-Token. func (d Deps) mountChatRoutes(r *gin.Engine) { g := r.Group("/api/v1") g.Use(d.RequireAppToken()) g.POST("/chat", d.handleChat) g.POST("/chat/confirm", d.handleConfirm) } // mountAdminRoutes wires everything under /admin. Login is public; every // other route sits behind RequireAdmin. func (d Deps) mountAdminRoutes(r *gin.Engine) { g := r.Group("/admin") g.POST("/login", d.handleLogin) auth := g.Group("") auth.Use(d.RequireAdmin()) auth.GET("/me", d.handleMe) // Apps auth.GET("/apps", d.handleListApps) auth.POST("/apps", d.handleCreateApp) auth.GET("/apps/:id", d.handleGetApp) auth.PUT("/apps/:id", d.handleUpdateApp) auth.DELETE("/apps/:id", d.handleDeleteApp) auth.POST("/apps/:id/token/rotate", d.handleRotateToken) // Model configs auth.GET("/model-configs", d.handleListModels) auth.POST("/model-configs", d.handleCreateModel) auth.PUT("/model-configs/:id", d.handleUpdateModel) auth.DELETE("/model-configs/:id", d.handleDeleteModel) // Skills auth.GET("/apps/:id/skills", d.handleListSkills) auth.POST("/apps/:id/skills/generate", d.handleGenerateSkill) auth.GET("/skills/:id", d.handleGetSkill) auth.PUT("/skills/:id", d.handleUpdateSkill) auth.POST("/skills/:id/publish", d.handlePublishSkill) auth.DELETE("/skills/:id", d.handleDeleteSkill) auth.POST("/skills/:id/test", d.handleTestSkill) // Audit logs auth.GET("/audit-logs", d.handleListAuditLogs) } func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() gin.DefaultErrorWriter.Write([]byte( c.Request.Method + " " + c.Request.URL.RequestURI() + " " + itoa(c.Writer.Status()) + " " + time.Since(start).String() + "\n", )) } } // itoa keeps a tiny stdlib-only int->string here so the logger above does // not pull in strconv just for one line. Using strconv would be equally fine. func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b [12]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) } // EnsureAdmin creates username with the given password if no admin exists. // It is a no-op when the username is already taken. func EnsureAdmin(db *gorm.DB, username, password string) error { var count int64 if err := db.Model(&store.AdminUser{}).Count(&count).Error; err != nil { return err } if count > 0 { return nil } hash, err := security.HashPassword(password) if err != nil { return err } return db.Create(&store.AdminUser{Username: username, PasswordHash: hash}).Error }