chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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