chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
43
internal/bootstrap/banner.go
Normal file
43
internal/bootstrap/banner.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
)
|
||||
|
||||
func printBanner() {
|
||||
printBannerTo(os.Stdout, config.Current())
|
||||
}
|
||||
|
||||
func printBannerTo(w io.Writer, cfg config.Config) {
|
||||
_, _ = fmt.Fprint(w, renderBanner(cfg))
|
||||
}
|
||||
|
||||
func renderBanner(cfg config.Config) string {
|
||||
port := cfg.Server.Port
|
||||
if port <= 0 {
|
||||
port = 8080
|
||||
}
|
||||
dbType := cfg.DB.Type
|
||||
if dbType == "" {
|
||||
dbType = "unknown"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
_ ____ _____ _ _ _____ ____ _____ ____ _ __
|
||||
/ \ / ___| ____| \ | |_ _| | _ \| ____/ ___|| |/ /
|
||||
/ _ \| | _| _| | \| | | | | | | | _| \___ \| ' /
|
||||
/ ___ \ |_| | |___| |\ | | | | |_| | |___ ___) | . \
|
||||
/_/ \_\____|_____|_| \_| |_| |____/|_____|____/|_|\_\
|
||||
|
||||
:: AGENT DESK ::
|
||||
|
||||
Port : %d
|
||||
DB : %s
|
||||
Address : http://127.0.0.1:%d
|
||||
|
||||
`, port, dbType, port)
|
||||
}
|
||||
42
internal/bootstrap/banner_test.go
Normal file
42
internal/bootstrap/banner_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
)
|
||||
|
||||
func TestRenderBanner(t *testing.T) {
|
||||
got := renderBanner(config.Config{
|
||||
Server: config.ServerConfig{Port: 8083},
|
||||
DB: config.DBConfig{Type: "sqlite"},
|
||||
})
|
||||
|
||||
expected := []string{
|
||||
":: AGENT DESK ::",
|
||||
"Port : 8083",
|
||||
"DB : sqlite",
|
||||
"Address : http://127.0.0.1:8083",
|
||||
}
|
||||
for _, item := range expected {
|
||||
if !strings.Contains(got, item) {
|
||||
t.Fatalf("renderBanner() missing %q in:\n%s", item, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBannerDefaults(t *testing.T) {
|
||||
got := renderBanner(config.Config{})
|
||||
|
||||
expected := []string{
|
||||
"Port : 8080",
|
||||
"DB : unknown",
|
||||
"Address : http://127.0.0.1:8080",
|
||||
}
|
||||
for _, item := range expected {
|
||||
if !strings.Contains(got, item) {
|
||||
t.Fatalf("renderBanner() missing %q in:\n%s", item, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
internal/bootstrap/db.go
Normal file
112
internal/bootstrap/db.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
|
||||
// "gorm.io/driver/sqlite" // Sqlite driver based on CGO
|
||||
"github.com/glebarez/sqlite" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details
|
||||
"gorm.io/gorm"
|
||||
|
||||
// "gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func InitDB(cfg config.DBConfig) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Type)) {
|
||||
case "sqlite":
|
||||
if err := ensureSQLiteDir(cfg.DSN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialector = sqlite.Open(cfg.DSN)
|
||||
case "mysql":
|
||||
dialector = mysql.Open(cfg.DSN)
|
||||
case "postgres", "postgresql", "pg":
|
||||
dialector = postgres.Open(cfg.DSN)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported db type: %s", cfg.Type)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
),
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.MaxIdleConns > 0 {
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
}
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
if cfg.ConnMaxIdleTimeSeconds > 0 {
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTimeSeconds) * time.Second)
|
||||
}
|
||||
if cfg.ConnMaxLifetimeSeconds > 0 {
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
|
||||
}
|
||||
|
||||
sqls.SetDB(db)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func ensureSQLiteDir(dsn string) error {
|
||||
dbPath := sqliteFilePath(dsn)
|
||||
if dbPath == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(dbPath)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
func sqliteFilePath(dsn string) string {
|
||||
if dsn == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
path := dsn
|
||||
if after, ok := strings.CutPrefix(path, "file:"); ok {
|
||||
path = after
|
||||
}
|
||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
|
||||
normalized := strings.TrimSpace(path)
|
||||
if normalized == "" || normalized == ":memory:" || strings.Contains(normalized, "mode=memory") {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
79
internal/bootstrap/db_test.go
Normal file
79
internal/bootstrap/db_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteFilePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
dsn string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "plain relative path",
|
||||
dsn: "./data/app.db",
|
||||
want: "./data/app.db",
|
||||
},
|
||||
{
|
||||
name: "file uri with query",
|
||||
dsn: "file:./data/app.db?_busy_timeout=5000",
|
||||
want: "./data/app.db",
|
||||
},
|
||||
{
|
||||
name: "memory dsn",
|
||||
dsn: "file::memory:?cache=shared",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "memory alias",
|
||||
dsn: ":memory:",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sqliteFilePath(tt.dsn); got != tt.want {
|
||||
t.Fatalf("sqliteFilePath(%q) = %q, want %q", tt.dsn, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSQLiteDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseDir := t.TempDir()
|
||||
dbPath := filepath.Join(baseDir, "nested", "app.db")
|
||||
dsn := "file:" + dbPath + "?_busy_timeout=5000"
|
||||
|
||||
if err := ensureSQLiteDir(dsn); err != nil {
|
||||
t.Fatalf("ensureSQLiteDir() error = %v", err)
|
||||
}
|
||||
|
||||
if info, err := os.Stat(filepath.Dir(dbPath)); err != nil {
|
||||
t.Fatalf("os.Stat() error = %v", err)
|
||||
} else if !info.IsDir() {
|
||||
t.Fatalf("expected %q to be a directory", filepath.Dir(dbPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_UnsupportedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := InitDB(config.DBConfig{
|
||||
Type: "oracle",
|
||||
DSN: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported db type, got nil")
|
||||
}
|
||||
}
|
||||
50
internal/bootstrap/init.go
Normal file
50
internal/bootstrap/init.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"agent-desk/internal/ai/rag/vectordb"
|
||||
"agent-desk/internal/oidcclient"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/logx"
|
||||
"agent-desk/internal/services/cronx"
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
_ "agent-desk/internal/services/event_handlers"
|
||||
)
|
||||
|
||||
func Init(configPath string) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
slog.Error("init config failed", "error", err)
|
||||
return err
|
||||
}
|
||||
config.SetCurrent(cfg)
|
||||
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
|
||||
|
||||
logx.Init(logx.Config{
|
||||
Level: cfg.Logger.Level,
|
||||
Format: cfg.Logger.Format,
|
||||
AddSource: cfg.Logger.AddSource,
|
||||
})
|
||||
|
||||
if _, err := InitDB(cfg.DB); err != nil {
|
||||
slog.Error("init db failed", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := InitMigrations(); err != nil {
|
||||
slog.Error("init migrations failed", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := vectordb.Init(&cfg.VectorDB); err != nil {
|
||||
slog.Error("init vector db failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动任务调度器
|
||||
cronx.Init()
|
||||
if err := oidcclient.Init(context.Background()); err != nil {
|
||||
slog.Warn("init oidc at startup failed (will retry lazily on request)", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
15
internal/bootstrap/migration.go
Normal file
15
internal/bootstrap/migration.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"agent-desk/internal/migration"
|
||||
"agent-desk/internal/models"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func InitMigrations() error {
|
||||
if err := sqls.DB().AutoMigrate(models.Models...); err != nil {
|
||||
return err
|
||||
}
|
||||
return migration.Migrate()
|
||||
}
|
||||
524
internal/bootstrap/routes.go
Normal file
524
internal/bootstrap/routes.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/handlers/api"
|
||||
"agent-desk/internal/handlers/dashboard"
|
||||
"agent-desk/internal/handlers/third"
|
||||
"agent-desk/internal/middleware"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/ratelimit"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publicRateLimits holds one limiter per abuse-prone unauthenticated endpoint.
|
||||
// They are built once per server rather than per request, and the middleware keys
|
||||
// them by client address.
|
||||
//
|
||||
// Channel webhooks under /api/third, the websocket routes and /api/webhooks are
|
||||
// deliberately absent. A platform that receives a 429 from its webhook stops
|
||||
// retrying and eventually disables the delivery, which would take a whole channel
|
||||
// offline; those endpoints authenticate by signature instead. The dashboard is
|
||||
// absent too - it is already behind AuthMiddleware, and staff sharing one office
|
||||
// address would throttle each other.
|
||||
type publicRateLimits struct {
|
||||
login *ratelimit.Limiter
|
||||
supportRegister *ratelimit.Limiter
|
||||
sessionExchange *ratelimit.Limiter
|
||||
upload *ratelimit.Limiter
|
||||
docFeedback *ratelimit.Limiter
|
||||
emailRequest *ratelimit.Limiter
|
||||
}
|
||||
|
||||
// Limits are sized for a human driving a browser, not for a machine. They are
|
||||
// deliberately generous: the point is to make flooding expensive, not to police
|
||||
// legitimate use. The session exchange budget in particular has to absorb a whole
|
||||
// support office loading the widget from behind one NAT address.
|
||||
const (
|
||||
limitLogin = 20
|
||||
limitSupportRegister = 10
|
||||
limitSessionExchange = 120
|
||||
limitUpload = 30
|
||||
limitDocFeedback = 20
|
||||
// An integration filing requests on a visitor's behalf is machine traffic, but it
|
||||
// should not be able to queue a support office's worth of work in one window.
|
||||
limitEmailRequest = 30
|
||||
)
|
||||
|
||||
func newPublicRateLimits(cfg config.RateLimitConfig) publicRateLimits {
|
||||
if !cfg.IsEnabled() {
|
||||
// Every field stays nil and a nil limiter allows everything, so "disabled"
|
||||
// needs no branch at any call site.
|
||||
return publicRateLimits{}
|
||||
}
|
||||
window := time.Duration(cfg.WindowSecondsOrDefault()) * time.Second
|
||||
return publicRateLimits{
|
||||
login: ratelimit.New(limitLogin, window),
|
||||
supportRegister: ratelimit.New(limitSupportRegister, window),
|
||||
sessionExchange: ratelimit.New(limitSessionExchange, window),
|
||||
upload: ratelimit.New(limitUpload, window),
|
||||
docFeedback: ratelimit.New(limitDocFeedback, window),
|
||||
emailRequest: ratelimit.New(limitEmailRequest, window),
|
||||
}
|
||||
}
|
||||
|
||||
func registerApiAuthRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.POST("/login", middleware.RateLimit(limits.login), api.Login)
|
||||
group.POST("/logout", api.Logout)
|
||||
group.GET("/profile", api.Profile)
|
||||
group.POST("/profile/update", api.UpdateProfile)
|
||||
group.POST("/profile/avatar/upload", api.UploadProfileAvatar)
|
||||
group.GET("/oidc_callback", api.OIDCCallback)
|
||||
group.GET("/callback/custom", api.OIDCCallback)
|
||||
group.POST("/oidc_exchange", api.OIDCExchange)
|
||||
group.GET("/oidc_login", api.OIDCLogin)
|
||||
}
|
||||
|
||||
func registerApiChannelRoutes(group *gin.RouterGroup) {
|
||||
group.Any("/config", api.ChannelAnyConfig)
|
||||
}
|
||||
|
||||
func registerApiWebhookRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/org-sync", api.OrgSyncWebhook)
|
||||
group.POST("/dos-org-sync", api.DOSOrgSyncWebhook)
|
||||
}
|
||||
|
||||
func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange)
|
||||
}
|
||||
|
||||
func registerApiConversationRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", api.ConversationGetBy)
|
||||
group.Any("/list", api.ConversationAnyList)
|
||||
group.POST("/close", api.ConversationPostClose)
|
||||
group.POST("/create", api.ConversationPostCreate)
|
||||
group.POST("/create_or_match", api.ConversationPostCreate_or_match)
|
||||
group.POST("/satisfaction", api.ConversationPostSatisfaction)
|
||||
}
|
||||
|
||||
func registerApiMessageRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.Any("/list", api.MessageAnyList)
|
||||
group.POST("/read", api.MessagePostRead)
|
||||
group.POST("/send", api.MessagePostSend)
|
||||
// One shared budget for both upload routes: what matters is how many bytes an
|
||||
// unauthenticated caller can push at the storage layer, not which of the two
|
||||
// endpoints they used.
|
||||
uploadLimit := middleware.RateLimit(limits.upload)
|
||||
group.POST("/upload_attachment", uploadLimit, api.MessagePostUpload_attachment)
|
||||
group.POST("/upload_image", uploadLimit, api.MessagePostUpload_image)
|
||||
}
|
||||
|
||||
func registerApiSupportRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.GET("/config", api.SupportConfigGetConfig)
|
||||
group.POST("/auth/register", middleware.RateLimit(limits.supportRegister), api.SupportAuthPostRegister)
|
||||
group.GET("/me", api.SupportGetMe)
|
||||
group.Any("/doc-page/list", api.DocPageAnyList)
|
||||
group.GET("/doc-page/navigation", api.DocPageGetNavigation)
|
||||
group.GET("/doc-page/:id", api.DocPageGetBy)
|
||||
group.POST("/doc-page/feedback", middleware.RateLimit(limits.docFeedback), api.DocPagePostFeedback)
|
||||
group.Any("/community/categories/list", api.CategoryAnyList)
|
||||
group.Any("/community/posts/list", api.PostAnyList)
|
||||
group.GET("/community/posts/:id", api.PostGetBy)
|
||||
group.POST("/community/posts/create", api.PostPostCreate)
|
||||
group.POST("/community/posts/update", api.PostPostUpdate)
|
||||
group.POST("/community/posts/accept_comment", api.PostPostAcceptComment)
|
||||
group.Any("/community/comments/list", api.CommentAnyList)
|
||||
group.POST("/community/comments/create", api.CommentPostCreate)
|
||||
group.POST("/community/comments/update", api.CommentPostUpdate)
|
||||
group.POST("/community/comments/delete", api.CommentPostDelete)
|
||||
group.POST("/community/comments/report", api.CommentPostReport)
|
||||
group.POST("/community/reactions/toggle", api.ReactionPostToggle)
|
||||
}
|
||||
|
||||
func registerDashboardSupportConfigRoutes(group *gin.RouterGroup) {
|
||||
group.GET("", dashboard.SupportConfigGetConfig)
|
||||
group.POST("/save", dashboard.SupportConfigPostSave)
|
||||
}
|
||||
|
||||
func registerDashboardBrandingRoutes(group *gin.RouterGroup) {
|
||||
group.GET("", dashboard.BrandingGetConfig)
|
||||
group.POST("/save", dashboard.BrandingPostSave)
|
||||
}
|
||||
|
||||
func registerDashboardDashboardRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/overview", dashboard.DashboardGetOverview)
|
||||
}
|
||||
|
||||
func registerDashboardSetupRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/readiness", dashboard.SetupGetReadiness)
|
||||
group.POST("/simulate_route", dashboard.SetupPostSimulate_route)
|
||||
group.POST("/quick_fix", dashboard.SetupPostQuick_fix)
|
||||
}
|
||||
|
||||
func registerDashboardUserRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.UserGetBy)
|
||||
group.POST("/assign_role", dashboard.UserPostAssign_role)
|
||||
group.POST("/change_password", dashboard.UserPostChange_password)
|
||||
group.POST("/create", dashboard.UserPostCreate)
|
||||
group.POST("/delete", dashboard.UserPostDelete)
|
||||
group.Any("/list", dashboard.UserAnyList)
|
||||
group.Any("/list_all", dashboard.UserAnyList_all)
|
||||
group.POST("/reset_password", dashboard.UserPostReset_password)
|
||||
group.POST("/update", dashboard.UserPostUpdate)
|
||||
group.POST("/update_status", dashboard.UserPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardOrganizationRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/my_list", dashboard.OrganizationUserList)
|
||||
group.POST("/switch", dashboard.OrganizationSwitch)
|
||||
}
|
||||
|
||||
func registerDashboardCompanyRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.CompanyGetBy)
|
||||
group.POST("/create", dashboard.CompanyPostCreate)
|
||||
group.POST("/delete", dashboard.CompanyPostDelete)
|
||||
group.Any("/list", dashboard.CompanyAnyList)
|
||||
group.POST("/update", dashboard.CompanyPostUpdate)
|
||||
group.POST("/update_status", dashboard.CompanyPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardCustomerRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.CustomerGetBy)
|
||||
group.POST("/create", dashboard.CustomerPostCreate)
|
||||
group.POST("/delete", dashboard.CustomerPostDelete)
|
||||
group.POST("/list", dashboard.CustomerPostList)
|
||||
group.POST("/save_profile", dashboard.CustomerPostSave_profile)
|
||||
group.POST("/update", dashboard.CustomerPostUpdate)
|
||||
group.POST("/update_status", dashboard.CustomerPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardCustomerContactRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/create", dashboard.CustomerContactPostCreate)
|
||||
group.POST("/delete", dashboard.CustomerContactPostDelete)
|
||||
group.Any("/list", dashboard.CustomerContactAnyList)
|
||||
group.POST("/update", dashboard.CustomerContactPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardRoleRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.RoleGetBy)
|
||||
group.POST("/assign_permission", dashboard.RolePostAssign_permission)
|
||||
group.POST("/create", dashboard.RolePostCreate)
|
||||
group.POST("/delete", dashboard.RolePostDelete)
|
||||
group.Any("/list", dashboard.RoleAnyList)
|
||||
group.GET("/list_all", dashboard.RoleGetList_all)
|
||||
group.POST("/update", dashboard.RolePostUpdate)
|
||||
group.POST("/update_sort", dashboard.RolePostUpdate_sort)
|
||||
group.POST("/update_status", dashboard.RolePostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardPermissionRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.PermissionGetBy)
|
||||
group.Any("/list", dashboard.PermissionAnyList)
|
||||
group.POST("/sync", dashboard.PermissionPostSync)
|
||||
}
|
||||
|
||||
func registerDashboardSessionRoutes(group *gin.RouterGroup) {
|
||||
group.Any("/list", dashboard.SessionAnyList)
|
||||
group.POST("/revoke", dashboard.SessionPostRevoke)
|
||||
group.POST("/revoke/by/user", dashboard.SessionPostRevokeByUser)
|
||||
}
|
||||
|
||||
func registerDashboardTagRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.TagGetBy)
|
||||
group.POST("/create", dashboard.TagPostCreate)
|
||||
group.POST("/delete", dashboard.TagPostDelete)
|
||||
group.Any("/list", dashboard.TagAnyList)
|
||||
group.GET("/list_all", dashboard.TagGetList_all)
|
||||
group.POST("/update", dashboard.TagPostUpdate)
|
||||
group.POST("/update_sort", dashboard.TagPostUpdate_sort)
|
||||
group.POST("/update_status", dashboard.TagPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardConversationRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.ConversationGetBy)
|
||||
group.POST("/add_tag", dashboard.ConversationPostAdd_tag)
|
||||
group.POST("/assign", dashboard.ConversationPostAssign)
|
||||
group.POST("/close", dashboard.ConversationPostClose)
|
||||
group.Any("/conversations", dashboard.ConversationAnyConversations)
|
||||
group.POST("/dispatch", dashboard.ConversationPostDispatch)
|
||||
group.POST("/link_customer", dashboard.ConversationPostLink_customer)
|
||||
group.Any("/list", dashboard.ConversationAnyList)
|
||||
group.Any("/message_list", dashboard.ConversationAnyMessage_list)
|
||||
group.POST("/read", dashboard.ConversationPostRead)
|
||||
group.POST("/recall_message", dashboard.ConversationPostRecall_message)
|
||||
group.POST("/remove_tag", dashboard.ConversationPostRemove_tag)
|
||||
group.POST("/send_message", dashboard.ConversationPostSend_message)
|
||||
group.POST("/suggest_reply", dashboard.ConversationPostSuggest_reply)
|
||||
group.POST("/summarize", dashboard.ConversationPostSummarize)
|
||||
group.POST("/transfer", dashboard.ConversationPostTransfer)
|
||||
group.POST("/upload_attachment", dashboard.ConversationPostUpload_attachment)
|
||||
group.POST("/upload_image", dashboard.ConversationPostUpload_image)
|
||||
}
|
||||
|
||||
func registerDashboardTicketRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.TicketGetBy)
|
||||
group.POST("/assign", dashboard.TicketPostAssign)
|
||||
group.POST("/change_status", dashboard.TicketPostChange_status)
|
||||
group.POST("/create", dashboard.TicketPostCreate)
|
||||
group.POST("/create_from_conversation", dashboard.TicketPostCreate_from_conversation)
|
||||
group.POST("/delete_view", dashboard.TicketPostDelete_view)
|
||||
group.POST("/link_customer", dashboard.TicketPostLink_customer)
|
||||
group.Any("/list", dashboard.TicketAnyList)
|
||||
group.POST("/progress/create", dashboard.TicketPostProgressCreate)
|
||||
group.Any("/progress/list", dashboard.TicketAnyProgressList)
|
||||
group.POST("/save_view", dashboard.TicketPostSave_view)
|
||||
group.Any("/summary", dashboard.TicketAnySummary)
|
||||
group.POST("/update", dashboard.TicketPostUpdate)
|
||||
group.Any("/view_list", dashboard.TicketAnyView_list)
|
||||
}
|
||||
|
||||
func registerDashboardNotificationRoutes(group *gin.RouterGroup) {
|
||||
group.Any("/list", dashboard.NotificationAnyList)
|
||||
group.POST("/mark_all_read", dashboard.NotificationPostMark_all_read)
|
||||
group.POST("/mark_read", dashboard.NotificationPostMark_read)
|
||||
group.GET("/unread_count", dashboard.NotificationGetUnread_count)
|
||||
}
|
||||
|
||||
func registerDashboardQuickReplyRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.QuickReplyGetBy)
|
||||
group.POST("/create", dashboard.QuickReplyPostCreate)
|
||||
group.POST("/delete", dashboard.QuickReplyPostDelete)
|
||||
group.Any("/list", dashboard.QuickReplyAnyList)
|
||||
group.GET("/list_all", dashboard.QuickReplyGetList_all)
|
||||
group.POST("/update", dashboard.QuickReplyPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardChannelRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.ChannelGetBy)
|
||||
group.POST("/create", dashboard.ChannelPostCreate)
|
||||
group.POST("/delete", dashboard.ChannelPostDelete)
|
||||
group.Any("/list", dashboard.ChannelAnyList)
|
||||
group.POST("/reset_user_token_secret", dashboard.ChannelPostReset_user_token_secret)
|
||||
group.POST("/rollback_ai_agent_rollout", dashboard.ChannelPostRollback_ai_agent_rollout)
|
||||
group.POST("/update", dashboard.ChannelPostUpdate)
|
||||
group.POST("/update_status", dashboard.ChannelPostUpdate_status)
|
||||
// Verifying a bound mail server is a configuration action, so it sits under
|
||||
// channel.update rather than inventing a permission nobody can be granted
|
||||
// without a migration.
|
||||
group.POST("/test_email", dashboard.ChannelPostTest_email)
|
||||
}
|
||||
|
||||
func registerDashboardAgentRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AgentGetBy)
|
||||
group.POST("/create", dashboard.AgentPostCreate)
|
||||
group.POST("/delete", dashboard.AgentPostDelete)
|
||||
group.Any("/list", dashboard.AgentAnyList)
|
||||
group.GET("/list_all", dashboard.AgentGetList_all)
|
||||
group.POST("/update", dashboard.AgentPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardAgentTeamRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AgentTeamGetBy)
|
||||
group.POST("/create", dashboard.AgentTeamPostCreate)
|
||||
group.POST("/delete", dashboard.AgentTeamPostDelete)
|
||||
group.Any("/list", dashboard.AgentTeamAnyList)
|
||||
group.GET("/list_all", dashboard.AgentTeamGetList_all)
|
||||
group.POST("/update", dashboard.AgentTeamPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardAgentTeamScheduleRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AgentTeamScheduleGetBy)
|
||||
group.POST("/batch_generate", dashboard.AgentTeamSchedulePostBatch_generate)
|
||||
group.POST("/batch_preview", dashboard.AgentTeamSchedulePostBatch_preview)
|
||||
group.Any("/calendar", dashboard.AgentTeamScheduleAnyCalendar)
|
||||
group.POST("/create", dashboard.AgentTeamSchedulePostCreate)
|
||||
group.POST("/delete", dashboard.AgentTeamSchedulePostDelete)
|
||||
group.Any("/list", dashboard.AgentTeamScheduleAnyList)
|
||||
group.POST("/update", dashboard.AgentTeamSchedulePostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/publish", dashboard.AIAgentPostPublish)
|
||||
group.POST("/rollback", dashboard.AIAgentPostRollback)
|
||||
group.POST("/rollback_rollout", dashboard.AIAgentPostRollback_rollout)
|
||||
group.Any("/:id/revision/list", dashboard.AIAgentAnyRevisionList)
|
||||
group.GET("/:id", dashboard.AIAgentGetBy)
|
||||
group.POST("/create", dashboard.AIAgentPostCreate)
|
||||
group.POST("/delete", dashboard.AIAgentPostDelete)
|
||||
group.Any("/list", dashboard.AIAgentAnyList)
|
||||
group.GET("/list_all", dashboard.AIAgentGetList_all)
|
||||
group.POST("/update", dashboard.AIAgentPostUpdate)
|
||||
group.POST("/update_sort", dashboard.AIAgentPostUpdate_sort)
|
||||
group.POST("/update_status", dashboard.AIAgentPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/create", dashboard.AIWorkflowPostCreate)
|
||||
group.POST("/update", dashboard.AIWorkflowPostUpdate)
|
||||
group.POST("/delete", dashboard.AIWorkflowPostDelete)
|
||||
group.POST("/restore-version", dashboard.AIWorkflowPostRestoreVersion)
|
||||
group.Any("/list", dashboard.AIWorkflowAnyList)
|
||||
group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
|
||||
group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition)
|
||||
group.GET("/template/list", dashboard.AIWorkflowGetTemplateList)
|
||||
group.POST("/validate", dashboard.AIWorkflowPostValidate)
|
||||
group.POST("/publish", dashboard.AIWorkflowPostPublish)
|
||||
group.Any("/run/list", dashboard.AIWorkflowAnyRunList)
|
||||
group.GET("/run/:id", dashboard.AIWorkflowGetRunBy)
|
||||
group.Any("/version/list", dashboard.AIWorkflowAnyVersionList)
|
||||
group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy)
|
||||
group.GET("/:id/usage", dashboard.AIWorkflowGetUsage)
|
||||
group.GET("/:id", dashboard.AIWorkflowGetBy)
|
||||
}
|
||||
|
||||
func registerDashboardAgentRunRoutes(group *gin.RouterGroup) {
|
||||
group.Any("/metrics", dashboard.AgentRunAnyMetrics)
|
||||
group.POST("/evaluate", dashboard.AgentRunPostEvaluate)
|
||||
group.Any("/list", dashboard.AgentRunAnyList)
|
||||
group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback)
|
||||
group.GET("/:id", dashboard.AgentRunGetBy)
|
||||
}
|
||||
|
||||
func registerDashboardAIConfigRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AIConfigGetBy)
|
||||
group.POST("/create", dashboard.AIConfigPostCreate)
|
||||
group.POST("/delete", dashboard.AIConfigPostDelete)
|
||||
group.Any("/list", dashboard.AIConfigAnyList)
|
||||
group.Any("/list_all", dashboard.AIConfigAnyList_all)
|
||||
group.POST("/update", dashboard.AIConfigPostUpdate)
|
||||
group.POST("/update_sort", dashboard.AIConfigPostUpdateSort)
|
||||
group.POST("/update_status", dashboard.AIConfigPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardAssetRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AssetGetBy)
|
||||
group.POST("/create", dashboard.AssetPostCreate)
|
||||
group.POST("/delete", dashboard.AssetPostDelete)
|
||||
group.Any("/list", dashboard.AssetAnyList)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeBaseRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.KnowledgeBaseGetBy)
|
||||
group.POST("/create", dashboard.KnowledgeBasePostCreate)
|
||||
group.POST("/delete", dashboard.KnowledgeBasePostDelete)
|
||||
group.Any("/list", dashboard.KnowledgeBaseAnyList)
|
||||
group.Any("/list_all", dashboard.KnowledgeBaseAnyList_all)
|
||||
group.POST("/rebuild_index", dashboard.KnowledgeBasePostRebuild_index)
|
||||
group.POST("/update", dashboard.KnowledgeBasePostUpdate)
|
||||
group.POST("/update_sort", dashboard.KnowledgeBasePostUpdate_sort)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeDirectoryRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/list_all", dashboard.KnowledgeDirectoryGetList_all)
|
||||
group.POST("/create", dashboard.KnowledgeDirectoryPostCreate)
|
||||
group.POST("/delete", dashboard.KnowledgeDirectoryPostDelete)
|
||||
group.POST("/update", dashboard.KnowledgeDirectoryPostUpdate)
|
||||
group.POST("/update_sort", dashboard.KnowledgeDirectoryPostUpdate_sort)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.KnowledgeDocumentGetBy)
|
||||
group.POST("/batch_delete", dashboard.KnowledgeDocumentPostBatch_delete)
|
||||
group.POST("/batch_move", dashboard.KnowledgeDocumentPostBatch_move)
|
||||
group.POST("/create", dashboard.KnowledgeDocumentPostCreate)
|
||||
group.POST("/delete", dashboard.KnowledgeDocumentPostDelete)
|
||||
group.Any("/list", dashboard.KnowledgeDocumentAnyList)
|
||||
group.POST("/update", dashboard.KnowledgeDocumentPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/import_template", dashboard.KnowledgeFAQGetImport_template)
|
||||
group.GET("/export", dashboard.KnowledgeFAQGetExport)
|
||||
group.GET("/:id", dashboard.KnowledgeFAQGetBy)
|
||||
group.POST("/batch_delete", dashboard.KnowledgeFAQPostBatch_delete)
|
||||
group.POST("/batch_move", dashboard.KnowledgeFAQPostBatch_move)
|
||||
group.POST("/create", dashboard.KnowledgeFAQPostCreate)
|
||||
group.POST("/delete", dashboard.KnowledgeFAQPostDelete)
|
||||
group.POST("/import", dashboard.KnowledgeFAQPostImport)
|
||||
group.Any("/list", dashboard.KnowledgeFAQAnyList)
|
||||
group.POST("/update", dashboard.KnowledgeFAQPostUpdate)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeRetrieveRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/build", dashboard.KnowledgeRetrievePostBuild)
|
||||
group.POST("/debug/answer", dashboard.KnowledgeRetrievePostDebugAnswer)
|
||||
group.POST("/debug/search", dashboard.KnowledgeRetrievePostDebugSearch)
|
||||
}
|
||||
|
||||
func registerDashboardKnowledgeRetrieveLogRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.KnowledgeRetrieveLogGetBy)
|
||||
group.Any("/list", dashboard.KnowledgeRetrieveLogAnyList)
|
||||
}
|
||||
|
||||
func registerDashboardDocPageRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.DocPageGetBy)
|
||||
group.POST("/create", dashboard.DocPagePostCreate)
|
||||
group.POST("/delete", dashboard.DocPagePostDelete)
|
||||
group.Any("/list", dashboard.DocPageAnyList)
|
||||
group.GET("/list_all", dashboard.DocPageGetList_all)
|
||||
group.POST("/update", dashboard.DocPagePostUpdate)
|
||||
group.POST("/update_settings", dashboard.DocPagePostUpdate_settings)
|
||||
group.POST("/update_sort", dashboard.DocPagePostUpdate_sort)
|
||||
group.POST("/change_status", dashboard.DocPagePostChange_status)
|
||||
}
|
||||
|
||||
func registerDashboardCommunityCategoryRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/create", dashboard.CategoryPostCreate)
|
||||
group.POST("/delete", dashboard.CategoryPostDelete)
|
||||
group.Any("/list", dashboard.CategoryAnyList)
|
||||
group.GET("/list_all", dashboard.CategoryGetList_all)
|
||||
group.POST("/update", dashboard.CategoryPostUpdate)
|
||||
group.POST("/update_sort", dashboard.CategoryPostUpdateSort)
|
||||
}
|
||||
|
||||
func registerDashboardCommunityPostRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.PostGetBy)
|
||||
group.POST("/accept_comment", dashboard.PostPostAcceptComment)
|
||||
group.POST("/comment/create", dashboard.CommentPostCreate)
|
||||
group.POST("/comment/moderate", dashboard.CommentPostModerate)
|
||||
group.Any("/list", dashboard.PostAnyList)
|
||||
group.POST("/moderate", dashboard.PostPostModerate)
|
||||
}
|
||||
|
||||
func registerDashboardSkillDefinitionRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.SkillDefinitionGetBy)
|
||||
group.POST("/create", dashboard.SkillDefinitionPostCreate)
|
||||
group.POST("/debug_resume", dashboard.SkillDefinitionPostDebug_resume)
|
||||
group.POST("/debug_run", dashboard.SkillDefinitionPostDebug_run)
|
||||
group.POST("/delete", dashboard.SkillDefinitionPostDelete)
|
||||
group.Any("/list", dashboard.SkillDefinitionAnyList)
|
||||
group.GET("/list_all", dashboard.SkillDefinitionGetList_all)
|
||||
group.POST("/restore", dashboard.SkillDefinitionPostRestore)
|
||||
group.POST("/update", dashboard.SkillDefinitionPostUpdate)
|
||||
group.POST("/update_status", dashboard.SkillDefinitionPostUpdate_status)
|
||||
}
|
||||
|
||||
func registerDashboardMCPRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/call_tool", dashboard.MCPPostCall_tool)
|
||||
group.Any("/catalog", dashboard.MCPAnyCatalog)
|
||||
group.Any("/list_servers", dashboard.MCPAnyList_servers)
|
||||
group.POST("/list_tools", dashboard.MCPPostList_tools)
|
||||
group.POST("/test_connection", dashboard.MCPPostTest_connection)
|
||||
}
|
||||
|
||||
func registerThirdTelegramRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/webhook", third.TelegramPostWebhook)
|
||||
group.POST("/webhook/:channel_id", third.TelegramPostWebhook)
|
||||
}
|
||||
|
||||
func registerThirdZaloRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/webhook", third.ZaloPostWebhook)
|
||||
group.POST("/webhook/:channel_id", third.ZaloPostWebhook)
|
||||
}
|
||||
|
||||
func registerThirdSlackRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/webhook", third.SlackPostWebhook)
|
||||
group.POST("/webhook/:channel_id", third.SlackPostWebhook)
|
||||
}
|
||||
|
||||
func registerThirdDiscordRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/webhook", third.DiscordPostWebhook)
|
||||
group.POST("/webhook/:channel_id", third.DiscordPostWebhook)
|
||||
}
|
||||
|
||||
func registerThirdEmailRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.POST("/webhook", third.EmailPostWebhook)
|
||||
group.POST("/webhook/:channel_id", third.EmailPostWebhook)
|
||||
// Programmatic intake, rate limited per caller rather than left open: unlike a
|
||||
// mail provider webhook this endpoint is reachable by anyone who learns the URL,
|
||||
// and each call can create a conversation.
|
||||
group.POST("/request", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
|
||||
group.POST("/request/:channel_id", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
|
||||
}
|
||||
320
internal/bootstrap/server.go
Normal file
320
internal/bootstrap/server.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/mcps"
|
||||
_ "agent-desk/internal/ai/runtime"
|
||||
"agent-desk/internal/handlers/api"
|
||||
"agent-desk/internal/middleware"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/ginx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/tracex"
|
||||
"agent-desk/internal/services"
|
||||
"agent-desk/internal/services/storage"
|
||||
webspa "agent-desk/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func NewServer() (*gin.Engine, error) {
|
||||
cfg := config.Current()
|
||||
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
printBanner()
|
||||
|
||||
app := gin.New()
|
||||
|
||||
// Gin defaults to trusting every proxy, which makes ClientIP() return the
|
||||
// leftmost X-Forwarded-For value - a header any caller can set. Everything
|
||||
// keyed on a client address depends on this being settled first: the login
|
||||
// credential log, the user's last login IP, and any abuse control.
|
||||
if platform := cfg.Server.TrustedPlatformHeader(); platform != "" {
|
||||
app.TrustedPlatform = platform
|
||||
}
|
||||
if err := app.SetTrustedProxies(cfg.Server.TrustedProxiesOrDefault()); err != nil {
|
||||
return nil, fmt.Errorf("invalid server.trustedProxies: %w", err)
|
||||
}
|
||||
|
||||
app.Use(requestIDMiddleware())
|
||||
app.Use(corsMiddleware())
|
||||
app.Use(gin.Recovery())
|
||||
app.Use(requestLogMiddleware())
|
||||
app.Use(maxBodySizeMiddleware())
|
||||
app.Use(i18nx.Middleware())
|
||||
|
||||
addRouter(app)
|
||||
|
||||
handleSpa(app)
|
||||
|
||||
storageGroup := app.Group(cfg.Storage.Local.BaseURL, assetResponseHeaders())
|
||||
storageGroup.StaticFS("", ginx.StaticFiles(cfg.Storage.Local.Root))
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// assetResponseHeaders guards the locally stored assets.
|
||||
//
|
||||
// Those files are user-supplied bytes served from this application's own origin,
|
||||
// so a response a browser renders inline is same-origin content. nosniff stops a
|
||||
// browser reinterpreting the payload, and forcing a download for anything that is
|
||||
// not previewable media means a document type that slipped in before this policy
|
||||
// existed still cannot run as a page.
|
||||
func assetResponseHeaders() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
if !storage.IsPreviewableExtension(path.Ext(ctx.Request.URL.Path)) {
|
||||
ctx.Header("Content-Disposition", "attachment")
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
allowedOrigins := config.Current().Server.CORS.AllowedOrigins
|
||||
allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Id-Signature, X-External-Id-Signed-At, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
||||
exposeHeaders := "Content-Length, Content-Type, Authorization, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Id-Signature, X-External-Id-Signed-At, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
||||
allowMethods := "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
allowedOriginSet := make(map[string]struct{}, len(allowedOrigins))
|
||||
for _, origin := range allowedOrigins {
|
||||
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
allowedOriginSet[origin] = struct{}{}
|
||||
}
|
||||
return func(ctx *gin.Context) {
|
||||
if isWebsocketUpgrade(ctx) {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
origin := strings.TrimRight(strings.TrimSpace(ctx.GetHeader("Origin")), "/")
|
||||
if origin != "" {
|
||||
ctx.Header("Vary", "Origin")
|
||||
if _, ok := allowedOriginSet[origin]; !ok {
|
||||
if ctx.Request.Method == http.MethodOptions {
|
||||
ctx.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ctx.Header("Access-Control-Allow-Origin", origin)
|
||||
ctx.Header("Access-Control-Allow-Methods", allowMethods)
|
||||
ctx.Header("Access-Control-Allow-Headers", allowHeaders)
|
||||
ctx.Header("Access-Control-Expose-Headers", exposeHeaders)
|
||||
ctx.Header("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if ctx.Request.Method == http.MethodOptions {
|
||||
ctx.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
requestID := tracex.EnsureRequestID(ctx.GetHeader(tracex.RequestIDHeader))
|
||||
ctx.Set(tracex.GinRequestIDKey, requestID)
|
||||
if requestID != "" {
|
||||
ctx.Header(tracex.RequestIDHeader, requestID)
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestLogMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
start := time.Now()
|
||||
path := ctx.Request.URL.Path
|
||||
method := ctx.Request.Method
|
||||
requestID, _ := ctx.Get(tracex.GinRequestIDKey)
|
||||
ctx.Next()
|
||||
|
||||
slog.Info("http request",
|
||||
"requestId", requestID,
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", ctx.Writer.Status(),
|
||||
"elapsed", time.Since(start).Milliseconds(),
|
||||
"clientIp", ctx.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func maxBodySizeMiddleware() gin.HandlerFunc {
|
||||
limit := config.Current().Storage.MaxRequestBodySizeBytes()
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isWebsocketUpgrade(ctx *gin.Context) bool {
|
||||
if !strings.EqualFold(ctx.GetHeader("Upgrade"), "websocket") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(ctx.GetHeader("Connection")), "upgrade")
|
||||
}
|
||||
|
||||
func addRouter(app *gin.Engine) {
|
||||
limits := newPublicRateLimits(config.Current().Server.RateLimit)
|
||||
|
||||
app.Any("/api/mcp", gin.WrapH(mcps.NewHTTPHandler()))
|
||||
|
||||
apiGroup := app.Group("/api")
|
||||
apiGroup.GET("/health", api.Health)
|
||||
apiGroup.GET("/config", api.PublicConfig)
|
||||
registerApiAuthRoutes(apiGroup.Group("/auth"), limits)
|
||||
registerApiChannelRoutes(apiGroup.Group("/channel"))
|
||||
registerApiCustomerRoutes(apiGroup.Group("/customer"), limits)
|
||||
registerApiConversationRoutes(apiGroup.Group("/conversation", middleware.ExternalUserMiddleware))
|
||||
registerApiMessageRoutes(apiGroup.Group("/message", middleware.ExternalUserMiddleware), limits)
|
||||
registerApiSupportRoutes(apiGroup.Group("/support"), limits)
|
||||
registerApiWebhookRoutes(apiGroup.Group("/webhooks"))
|
||||
|
||||
wsGroup := app.Group("/api/ws")
|
||||
wsGroup.GET("/dashboard", middleware.AuthMiddleware, services.WsService.HandleDashboardWS)
|
||||
wsGroup.GET("/dashboard/notification", middleware.AuthMiddleware, services.WsService.HandleDashboardNotificationWS)
|
||||
wsGroup.GET("/open", services.WsService.HandleOpenWS)
|
||||
|
||||
dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware)
|
||||
registerDashboardDashboardRoutes(dashboardGroup.Group("/dashboard"))
|
||||
registerDashboardSetupRoutes(dashboardGroup.Group("/setup"))
|
||||
registerDashboardUserRoutes(dashboardGroup.Group("/user"))
|
||||
registerDashboardOrganizationRoutes(dashboardGroup.Group("/organization"))
|
||||
registerDashboardCompanyRoutes(dashboardGroup.Group("/company"))
|
||||
registerDashboardCustomerRoutes(dashboardGroup.Group("/customer"))
|
||||
registerDashboardCustomerContactRoutes(dashboardGroup.Group("/customer-contact"))
|
||||
registerDashboardRoleRoutes(dashboardGroup.Group("/role"))
|
||||
registerDashboardPermissionRoutes(dashboardGroup.Group("/permission"))
|
||||
registerDashboardSessionRoutes(dashboardGroup.Group("/session"))
|
||||
registerDashboardTagRoutes(dashboardGroup.Group("/tag"))
|
||||
registerDashboardConversationRoutes(dashboardGroup.Group("/conversation"))
|
||||
registerDashboardTicketRoutes(dashboardGroup.Group("/ticket"))
|
||||
registerDashboardNotificationRoutes(dashboardGroup.Group("/notification"))
|
||||
registerDashboardQuickReplyRoutes(dashboardGroup.Group("/quick-reply"))
|
||||
registerDashboardChannelRoutes(dashboardGroup.Group("/channel"))
|
||||
registerDashboardAgentRoutes(dashboardGroup.Group("/agent"))
|
||||
registerDashboardAgentTeamRoutes(dashboardGroup.Group("/agent-team"))
|
||||
registerDashboardAgentTeamScheduleRoutes(dashboardGroup.Group("/agent-team-schedule"))
|
||||
registerDashboardAIAgentRoutes(dashboardGroup.Group("/ai-agent"))
|
||||
registerDashboardAIWorkflowRoutes(dashboardGroup.Group("/ai-workflow"))
|
||||
registerDashboardAgentRunRoutes(dashboardGroup.Group("/agent-run"))
|
||||
registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config"))
|
||||
registerDashboardAssetRoutes(dashboardGroup.Group("/asset"))
|
||||
registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base"))
|
||||
registerDashboardKnowledgeDirectoryRoutes(dashboardGroup.Group("/knowledge-directory"))
|
||||
registerDashboardKnowledgeDocumentRoutes(dashboardGroup.Group("/knowledge-document"))
|
||||
registerDashboardKnowledgeFAQRoutes(dashboardGroup.Group("/knowledge-faq"))
|
||||
registerDashboardKnowledgeRetrieveRoutes(dashboardGroup.Group("/knowledge-retrieve"))
|
||||
registerDashboardKnowledgeRetrieveLogRoutes(dashboardGroup.Group("/knowledge-retrieve-log"))
|
||||
registerDashboardSupportConfigRoutes(dashboardGroup.Group("/support/config"))
|
||||
registerDashboardBrandingRoutes(dashboardGroup.Group("/branding"))
|
||||
registerDashboardDocPageRoutes(dashboardGroup.Group("/doc-page"))
|
||||
registerDashboardCommunityCategoryRoutes(dashboardGroup.Group("/support-community/categories"))
|
||||
registerDashboardCommunityPostRoutes(dashboardGroup.Group("/support-community/posts"))
|
||||
registerDashboardSkillDefinitionRoutes(dashboardGroup.Group("/skill-definition"))
|
||||
registerDashboardMCPRoutes(dashboardGroup.Group("/mcp"))
|
||||
|
||||
thirdGroup := app.Group("/api/third")
|
||||
registerThirdTelegramRoutes(thirdGroup.Group("/telegram"))
|
||||
registerThirdZaloRoutes(thirdGroup.Group("/zalo"))
|
||||
registerThirdSlackRoutes(thirdGroup.Group("/slack"))
|
||||
registerThirdDiscordRoutes(thirdGroup.Group("/discord"))
|
||||
registerThirdEmailRoutes(thirdGroup.Group("/email"), limits)
|
||||
}
|
||||
|
||||
type spaShellRewrite struct {
|
||||
Route string
|
||||
ShellPath string
|
||||
Match func(*gin.Context) bool
|
||||
}
|
||||
|
||||
func handleSpa(app *gin.Engine) {
|
||||
cfg := config.Current()
|
||||
|
||||
spaHandler := ginx.HandleSPA(app, ginx.SPAOptions{
|
||||
Root: "./web/out",
|
||||
EmbeddedFS: webspa.SPA,
|
||||
EmbeddedRoot: "out",
|
||||
// The export only contains dashboard.html, support.html and
|
||||
// workbench.html, so the bare origin has no index to serve.
|
||||
RootRedirect: "/support",
|
||||
DirOptions: ginx.DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: func() []string {
|
||||
notFoundPrefixes := []string{"/api/"}
|
||||
if baseURL := strings.TrimRight(cfg.Storage.Local.BaseURL, "/"); baseURL != "" {
|
||||
notFoundPrefixes = append(notFoundPrefixes, baseURL+"/")
|
||||
}
|
||||
return notFoundPrefixes
|
||||
}(),
|
||||
NotFoundHandler: func(ctx *gin.Context) {
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, i18nx.T(ctx, "error.notFound")))
|
||||
},
|
||||
})
|
||||
|
||||
// SPA rewrites
|
||||
registerSPAShellRewrites(app, spaHandler)
|
||||
}
|
||||
|
||||
func registerSPAShellRewrites(app *gin.Engine, spaHandler gin.HandlerFunc) {
|
||||
rewrites := []spaShellRewrite{
|
||||
{
|
||||
// Runtime-authored document pages cannot be enumerated by static export.
|
||||
Route: "/support/docs/*slug",
|
||||
ShellPath: "/support/docs.html",
|
||||
},
|
||||
{
|
||||
// Keep the public RESTful URL and serve the exported post detail shell for IDs.
|
||||
Route: "/support/community/posts/:id",
|
||||
ShellPath: "/support/community/posts/detail.html",
|
||||
Match: func(ctx *gin.Context) bool {
|
||||
value, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||||
return err == nil && value > 0
|
||||
},
|
||||
},
|
||||
{
|
||||
Route: "/support/community/posts/new",
|
||||
ShellPath: "/support/community/posts/new.html",
|
||||
},
|
||||
{
|
||||
// Community category pages are runtime-authored through category slugs.
|
||||
Route: "/support/community/categories/:slug",
|
||||
ShellPath: "/support/community/categories.html",
|
||||
Match: func(ctx *gin.Context) bool {
|
||||
slug := ctx.Param("slug")
|
||||
return slug != ""
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, rewrite := range rewrites {
|
||||
handler := newSPAShellRewriteHandler(spaHandler, rewrite)
|
||||
app.GET(rewrite.Route, handler)
|
||||
app.HEAD(rewrite.Route, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func newSPAShellRewriteHandler(spaHandler gin.HandlerFunc, rewrite spaShellRewrite) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if rewrite.Match == nil || rewrite.Match(ctx) {
|
||||
ctx.Request.URL.Path = rewrite.ShellPath
|
||||
}
|
||||
spaHandler(ctx)
|
||||
}
|
||||
}
|
||||
148
internal/bootstrap/server_clientip_test.go
Normal file
148
internal/bootstrap/server_clientip_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// captureRequestLog swaps the slog default for one writing into buf, because
|
||||
// requestLogMiddleware records the address Gin resolved. That is the only place
|
||||
// the server exposes its ClientIP() decision, and it is the value that ends up in
|
||||
// t_login_credential_log and t_user.last_login_ip.
|
||||
func captureRequestLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
func setTestServerConfig(server config.ServerConfig) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Server: server,
|
||||
Storage: config.StorageConfig{Local: config.LocalStorageConfig{Root: "storage", BaseURL: "/storage"}},
|
||||
})
|
||||
}
|
||||
|
||||
// TestGinDefaultTrustsEveryProxy pins the vulnerability the configuration above
|
||||
// exists to close. A bare Gin engine - which is what NewServer used to build -
|
||||
// trusts 0.0.0.0/0 and ::/0, so validateHeader walks X-Forwarded-For right to
|
||||
// left, finds no untrusted proxy to stop at, and returns the leftmost value the
|
||||
// caller chose. If this assertion ever starts failing, Gin's default has changed
|
||||
// and the trusted-proxy configuration should be revisited rather than assumed
|
||||
// necessary.
|
||||
func TestGinDefaultTrustsEveryProxy(t *testing.T) {
|
||||
app := gin.New()
|
||||
var resolved string
|
||||
app.GET("/probe", func(ctx *gin.Context) { resolved = ctx.ClientIP() })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.RemoteAddr = "203.0.113.7:52000"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.9")
|
||||
app.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if resolved != "198.51.100.9" {
|
||||
t.Fatalf("a bare engine resolved ClientIP to %q, expected the forged 198.51.100.9", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerIgnoresForgedForwardedForFromAnUntrustedPeer(t *testing.T) {
|
||||
buf := captureRequestLog(t)
|
||||
setTestServerConfig(config.ServerConfig{})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
// A public peer is outside every default trusted range, so nothing it claims
|
||||
// about the originating address may be believed.
|
||||
req.RemoteAddr = "203.0.113.7:52000"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.9")
|
||||
req.Header.Set("X-Real-IP", "198.51.100.9")
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "clientIp=203.0.113.7") {
|
||||
t.Fatalf("expected the real peer address to be logged, got: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "198.51.100.9") {
|
||||
t.Fatalf("a forged forwarding header was trusted: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerReadsForwardedForFromATrustedProxy(t *testing.T) {
|
||||
buf := captureRequestLog(t)
|
||||
setTestServerConfig(config.ServerConfig{TrustedProxies: []string{"10.0.0.0/8"}})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
req.RemoteAddr = "10.0.0.5:52000"
|
||||
// A hostile client prepends a value it chose; the proxy appends the address it
|
||||
// actually saw. Gin walks the list right to left and stops at the first
|
||||
// address that is not itself a trusted proxy, which is the appended one.
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.9, 203.0.113.7")
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "clientIp=203.0.113.7") {
|
||||
t.Fatalf("expected the proxy-appended address, got: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "198.51.100.9") {
|
||||
t.Fatalf("the client-prepended address was trusted: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerPrefersTheConfiguredTrustedPlatformHeader(t *testing.T) {
|
||||
buf := captureRequestLog(t)
|
||||
setTestServerConfig(config.ServerConfig{TrustedPlatform: "cloudflare"})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
req.RemoteAddr = "10.0.0.5:52000"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.9")
|
||||
// The edge overwrites this header rather than appending to it, which is what
|
||||
// makes it usable as an identity at all.
|
||||
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "clientIp=203.0.113.7") {
|
||||
t.Fatalf("expected the platform header to win, got: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "198.51.100.9") {
|
||||
t.Fatalf("X-Forwarded-For was consulted despite a trusted platform: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRejectsAnInvalidTrustedProxyCIDR(t *testing.T) {
|
||||
captureRequestLog(t)
|
||||
setTestServerConfig(config.ServerConfig{TrustedProxies: []string{"10.0.0.0/8", "not-a-cidr"}})
|
||||
|
||||
if _, err := NewServer(); err == nil {
|
||||
t.Fatal("NewServer() accepted an unparseable CIDR; a typo here would silently disable the trust boundary")
|
||||
} else if !strings.Contains(err.Error(), "server.trustedProxies") {
|
||||
t.Fatalf("NewServer() error = %v, expected it to name server.trustedProxies", err)
|
||||
}
|
||||
}
|
||||
162
internal/bootstrap/server_ratelimit_test.go
Normal file
162
internal/bootstrap/server_ratelimit_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func parseInt(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatalf("%q is not an integer", value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// setRateLimitTestConfig disables password login so /api/auth/login returns
|
||||
// before it reaches the database. The limiter runs ahead of the handler either
|
||||
// way, which is what these tests are about.
|
||||
func setRateLimitTestConfig(rateLimit config.RateLimitConfig) {
|
||||
disabled := false
|
||||
config.SetCurrent(&config.Config{
|
||||
Server: config.ServerConfig{
|
||||
RateLimit: rateLimit,
|
||||
CORS: config.CORSConfig{AllowedOrigins: []string{}},
|
||||
},
|
||||
Auth: config.AuthConfig{PasswordLoginEnabled: &disabled},
|
||||
Storage: config.StorageConfig{Local: config.LocalStorageConfig{Root: "storage", BaseURL: "/storage"}},
|
||||
})
|
||||
}
|
||||
|
||||
func postJSON(app *gin.Engine, path, clientIP string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"username":"admin","password":"secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = clientIP + ":52000"
|
||||
app.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestNewServerRateLimitsThePublicLoginEndpoint(t *testing.T) {
|
||||
setRateLimitTestConfig(config.RateLimitConfig{})
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= limitLogin; i++ {
|
||||
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d was throttled although the login limit is %d per window", i, limitLogin)
|
||||
}
|
||||
}
|
||||
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d got status %d, want 429", limitLogin+1, rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("the 429 carried no Retry-After header")
|
||||
}
|
||||
|
||||
// A second address must keep working, otherwise one attacker could lock the
|
||||
// login page for every user behind their own NAT.
|
||||
if rec := postJSON(app, "/api/auth/login", "198.51.100.9"); rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatal("an unrelated address was throttled by another address's requests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRateLimitsTheSupportRegistrationEndpoint(t *testing.T) {
|
||||
setRateLimitTestConfig(config.RateLimitConfig{})
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= limitSupportRegister; i++ {
|
||||
postJSON(app, "/api/support/auth/register", "203.0.113.7")
|
||||
}
|
||||
if rec := postJSON(app, "/api/support/auth/register", "203.0.113.7"); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("registration attempt %d got status %d, want 429", limitSupportRegister+1, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewServerLeavesWebhooksAndPublicReadsUnthrottled guards the exemption that
|
||||
// matters most. A channel platform that receives a 429 from its webhook stops
|
||||
// retrying and eventually disables the delivery, which takes a whole channel
|
||||
// offline - a far worse outcome than the flood the limit was meant to stop.
|
||||
func TestNewServerLeavesWebhooksAndPublicReadsUnthrottled(t *testing.T) {
|
||||
setRateLimitTestConfig(config.RateLimitConfig{})
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/health"},
|
||||
{http.MethodGet, "/api/config"},
|
||||
{http.MethodPost, "/api/webhooks/org-sync"},
|
||||
{http.MethodGet, "/api/third/telegram/webhook"},
|
||||
{http.MethodPost, "/api/third/whatsapp/webhook"},
|
||||
}
|
||||
for _, r := range requests {
|
||||
for i := 0; i < 200; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(r.method, r.path, strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "203.0.113.7:52000"
|
||||
app.ServeHTTP(rec, req)
|
||||
// Any status is acceptable here except 429: these routes either
|
||||
// succeed, fail validation, or error out, but they must never be
|
||||
// throttled.
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("%s %s returned 429 on request %d; this route must stay unthrottled", r.method, r.path, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRateLimitCanBeDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
setRateLimitTestConfig(config.RateLimitConfig{Enabled: &disabled})
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < limitLogin*4; i++ {
|
||||
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d was throttled although rate limiting is disabled", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRateLimitWindowIsConfigurable(t *testing.T) {
|
||||
setRateLimitTestConfig(config.RateLimitConfig{WindowSeconds: 3600})
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
for i := 1; i <= limitLogin; i++ {
|
||||
postJSON(app, "/api/auth/login", "203.0.113.7")
|
||||
}
|
||||
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("got status %d, want 429", rec.Code)
|
||||
}
|
||||
if retryAfter := rec.Header().Get("Retry-After"); retryAfter == "" {
|
||||
t.Fatal("no Retry-After header")
|
||||
} else if seconds := parseInt(t, retryAfter); seconds < 3500 || seconds > 3600 {
|
||||
t.Errorf("Retry-After = %s, want roughly the configured 3600 second window", retryAfter)
|
||||
}
|
||||
}
|
||||
479
internal/bootstrap/server_route_test.go
Normal file
479
internal/bootstrap/server_route_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewServerRegistersGinRoutes(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
routes := make(map[string]bool)
|
||||
for _, route := range app.Routes() {
|
||||
routes[route.Method+" "+route.Path] = true
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
http.MethodPost + " /api/auth/login",
|
||||
http.MethodGet + " /api/config",
|
||||
http.MethodGet + " /api/health",
|
||||
http.MethodGet + " /api/auth/oidc_login",
|
||||
http.MethodGet + " /api/auth/oidc_callback",
|
||||
http.MethodGet + " /api/auth/callback/custom",
|
||||
http.MethodPost + " /api/auth/oidc_exchange",
|
||||
http.MethodGet + " /api/auth/profile",
|
||||
http.MethodPost + " /api/webhooks/org-sync",
|
||||
http.MethodGet + " /api/dashboard/organization/my_list",
|
||||
http.MethodPost + " /api/dashboard/organization/switch",
|
||||
http.MethodGet + " /api/dashboard/user/list",
|
||||
http.MethodGet + " /api/dashboard/user/:id",
|
||||
http.MethodPost + " /api/dashboard/user/create",
|
||||
http.MethodPost + " /api/dashboard/conversation/send_message",
|
||||
http.MethodGet + " /api/conversation/:id",
|
||||
http.MethodGet + " /api/conversation/list",
|
||||
http.MethodPost + " /api/conversation/create",
|
||||
http.MethodPost + " /api/conversation/create_or_match",
|
||||
http.MethodPost + " /api/conversation/close",
|
||||
http.MethodPost + " /api/third/email/webhook",
|
||||
http.MethodPost + " /api/third/email/webhook/:channel_id",
|
||||
http.MethodPost + " /api/third/email/request",
|
||||
http.MethodPost + " /api/third/email/request/:channel_id",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/default-definition",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/template/list",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/list",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
|
||||
http.MethodGet + " /api/dashboard/agent-run/metrics",
|
||||
http.MethodPost + " /api/dashboard/agent-run/evaluate",
|
||||
http.MethodGet + " /api/dashboard/agent-run/:id",
|
||||
http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout",
|
||||
http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout",
|
||||
http.MethodPost + " /api/dashboard/agent-run/quality_feedback",
|
||||
http.MethodGet + " /api/dashboard/agent-run/list",
|
||||
http.MethodGet + " /api/ws/dashboard",
|
||||
http.MethodGet + " /api/ws/open",
|
||||
}
|
||||
for _, route := range expected {
|
||||
if !routes[route] {
|
||||
t.Fatalf("expected route %s to be registered", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerHealthEndpointIsPublic(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if !body.Success {
|
||||
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||
}
|
||||
if body.Data.Status != "ok" {
|
||||
t.Fatalf("status=%q want ok", body.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerExposesPublicConfig(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Language: "zh-CN",
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: false,
|
||||
ClientSecret: "must-not-leak",
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Language string `json:"language"`
|
||||
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
|
||||
OIDCEnabled bool `json:"oidcEnabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if !body.Success {
|
||||
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||
}
|
||||
if body.Data.Language != "zh-CN" {
|
||||
t.Fatalf("language=%q want zh-CN", body.Data.Language)
|
||||
}
|
||||
if !body.Data.PasswordLoginEnabled {
|
||||
t.Fatalf("passwordLoginEnabled=false want true")
|
||||
}
|
||||
if body.Data.OIDCEnabled {
|
||||
t.Fatalf("oidcEnabled=true want false")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "must-not-leak") {
|
||||
t.Fatalf("response leaked sensitive OIDC config: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerPasswordLoginDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
config.SetCurrent(&config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
PasswordLoginEnabled: &disabled,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
// 1. /api/config should return passwordLoginEnabled=false
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var configBody struct {
|
||||
Data struct {
|
||||
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &configBody); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if configBody.Data.PasswordLoginEnabled {
|
||||
t.Fatalf("expected passwordLoginEnabled=false, got true")
|
||||
}
|
||||
|
||||
// 2. /api/auth/login should be rejected
|
||||
rec = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"admin","password":"secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
var loginBody struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &loginBody); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if loginBody.Success {
|
||||
t.Fatalf("expected login failure when password login is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
||||
for _, rootDir := range []string{"web/out", "../web/out", "../../web/out"} {
|
||||
_ = os.MkdirAll(rootDir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(rootDir, "index.html"), []byte("<html>spa</html>"), 0o644)
|
||||
defer func(d string) {
|
||||
_ = os.Remove(filepath.Join(d, "index.html"))
|
||||
}(rootDir)
|
||||
}
|
||||
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
contentType string
|
||||
}{
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
|
||||
{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
{path: "/support/docs/runtime-authored-slug", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
{path: "/support/community/posts/12345", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
{path: "/support/community/categories/product", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
{path: "/support/community/posts/new", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
|
||||
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerHardensStoredAssetResponses(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"screenshot.png", "archive.zip", "legacy-page.html"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: root,
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
contentType string
|
||||
wantAttachment bool
|
||||
}{
|
||||
{path: "/storage/screenshot.png", wantStatus: http.StatusOK, contentType: "image/png"},
|
||||
{path: "/storage/archive.zip", wantStatus: http.StatusOK, wantAttachment: true},
|
||||
// A file planted before the upload policy existed must still not render.
|
||||
{path: "/storage/legacy-page.html", wantStatus: http.StatusOK, wantAttachment: true},
|
||||
{path: "/storage/missing.png", wantStatus: http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("%s X-Content-Type-Options=%q want nosniff", tt.path, got)
|
||||
}
|
||||
if tt.contentType != "" && !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
|
||||
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
|
||||
}
|
||||
got := rec.Header().Get("Content-Disposition")
|
||||
if tt.wantAttachment && got != "attachment" {
|
||||
t.Fatalf("%s Content-Disposition=%q want attachment", tt.path, got)
|
||||
}
|
||||
if !tt.wantAttachment && got != "" {
|
||||
t.Fatalf("%s Content-Disposition=%q want empty so the asset renders inline", tt.path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Server: config.ServerConfig{
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://console.example.com"},
|
||||
},
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
req.Header.Set("Origin", "https://console.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://console.example.com" {
|
||||
t.Fatalf("Access-Control-Allow-Origin=%q want %q", got, "https://console.example.com")
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) {
|
||||
t.Fatalf("Access-Control-Allow-Methods=%q should contain %q", got, http.MethodPost)
|
||||
}
|
||||
if got := rec.Header().Get("Vary"); got != "Origin" {
|
||||
t.Fatalf("Vary=%q want %q", got, "Origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRejectsUnconfiguredCORSOrigin(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Server: config.ServerConfig{
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://console.example.com"},
|
||||
},
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("Access-Control-Allow-Origin=%q want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerEchoesRequestID(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/not-exists", nil)
|
||||
req.Header.Set("X-Request-Id", "trace-123")
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
if got := rec.Header().Get("X-Request-Id"); got != "trace-123" {
|
||||
t.Fatalf("X-Request-Id=%q want %q", got, "trace-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerGeneratesRequestID(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/not-exists", nil))
|
||||
|
||||
if got := rec.Header().Get("X-Request-Id"); got == "" {
|
||||
t.Fatalf("X-Request-Id should be generated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user