150 lines
4.2 KiB
Go
150 lines
4.2 KiB
Go
|
|
// 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
|
||
|
|
}
|