77f9998393
CI / docker (push) Successful in 3m13s
引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务 SSE 推送;管理端登录防爆破;协议与服务类型联动;Replicate 模型同步修复;用户端创作台 UI 重构;管理端创作历史替代请求日志。 Co-authored-by: Cursor <cursoragent@cursor.com>
235 lines
5.8 KiB
Go
235 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/rose_cat707/Atlas/internal/service"
|
|
"github.com/rose_cat707/Atlas/internal/settings"
|
|
)
|
|
|
|
type Server struct {
|
|
app *service.App
|
|
loginGuard *loginGuard
|
|
}
|
|
|
|
func NewServer(app *service.App) *Server {
|
|
return &Server{
|
|
app: app,
|
|
loginGuard: newLoginGuard(),
|
|
}
|
|
}
|
|
|
|
func (s *Server) Router() *gin.Engine {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
r := gin.New()
|
|
r.Use(gin.Recovery(), s.corsMiddleware())
|
|
|
|
r.GET("/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
api := r.Group("/api/v1")
|
|
api.POST("/auth/login", s.login)
|
|
api.GET("/auth/status", s.authStatus)
|
|
|
|
protected := api.Group("")
|
|
protected.Use(s.authMiddleware())
|
|
{
|
|
protected.GET("/dashboard", s.dashboard)
|
|
protected.GET("/settings", s.getSettings)
|
|
protected.PUT("/settings", s.putSettings)
|
|
protected.GET("/audit-logs", s.listAuditLogs)
|
|
s.registerAdminRoutes(protected)
|
|
}
|
|
|
|
user := api.Group("")
|
|
s.registerUserRoutes(user)
|
|
|
|
s.registerGatewayRoutes(r)
|
|
|
|
r.NoRoute(s.serveSPA)
|
|
return r
|
|
}
|
|
|
|
func (s *Server) corsMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
|
if c.Request.Method == http.MethodOptions {
|
|
c.AbortWithStatus(204)
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
cfg, err := s.app.Store.GetSettings(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
c.Abort()
|
|
return
|
|
}
|
|
if cfg[settings.KeyAuthEnabled] != "true" {
|
|
c.Next()
|
|
return
|
|
}
|
|
token := bearerToken(c)
|
|
if token == "" || !verifyToken(cfg[settings.KeyAdminPassword], token) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
type loginReq struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (s *Server) authStatus(c *gin.Context) {
|
|
cfg, err := s.app.Store.GetSettings(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"enabled": cfg[settings.KeyAuthEnabled] == "true"})
|
|
}
|
|
|
|
func (s *Server) login(c *gin.Context) {
|
|
ip := c.ClientIP()
|
|
if locked, retryAfter := s.loginGuard.check(ip); locked {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "too many login attempts",
|
|
"retry_after": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req loginReq
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
cfg, err := s.app.Store.GetSettings(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
hash := cfg[settings.KeyAdminPassword]
|
|
if !checkPassword(req.Password, hash) {
|
|
time.Sleep(loginMinDelay)
|
|
if locked, retryAfter := s.loginGuard.recordFailure(ip); locked {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "too many login attempts",
|
|
"retry_after": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
|
return
|
|
}
|
|
s.loginGuard.reset(ip)
|
|
token := issueToken(hash, 24*time.Hour)
|
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
|
}
|
|
|
|
func (s *Server) dashboard(c *gin.Context) {
|
|
cfg, err := s.app.Store.GetSettings(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"app_name": cfg[settings.KeyAppName],
|
|
"data_dir": s.app.DataDir,
|
|
})
|
|
}
|
|
|
|
func (s *Server) getSettings(c *gin.Context) {
|
|
cfg, err := s.app.Store.GetSettings(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, settings.Display(cfg))
|
|
}
|
|
|
|
func (s *Server) putSettings(c *gin.Context) {
|
|
var in map[string]string
|
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
normalized, err := settings.NormalizeSubmit(in)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(normalized) == 0 {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := s.app.Store.SetSettings(ctx, normalized); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = s.app.Store.InsertAuditLog(ctx, "update", "settings", "settings updated")
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (s *Server) listAuditLogs(c *gin.Context) {
|
|
limit, offset := parseLimitOffset(c, 50, 500)
|
|
items, total, err := s.app.Store.ListAuditLogs(c.Request.Context(), limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
|
}
|
|
|
|
func bearerToken(c *gin.Context) string {
|
|
h := c.GetHeader("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func checkPassword(password, stored string) bool {
|
|
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") {
|
|
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
|
|
}
|
|
return password == stored
|
|
}
|
|
|
|
func parseLimitOffset(c *gin.Context, defaultLimit, maxLimit int) (int, int) {
|
|
limit := defaultLimit
|
|
offset := 0
|
|
if v := c.Query("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
if limit > maxLimit {
|
|
limit = maxLimit
|
|
}
|
|
if v := c.Query("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
offset = n
|
|
}
|
|
}
|
|
return limit, offset
|
|
}
|