Эх сурвалжийг харах

feat: wire TG crypto + import/reveal-2fa routes into server boot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 долоо хоног өмнө
parent
commit
b4b95b972c
2 өөрчлөгдсөн 446 нэмэгдсэн , 38 устгасан
  1. 164 21
      cmd/server/main.go
  2. 282 17
      internal/handler/router.go

+ 164 - 21
cmd/server/main.go

@@ -1,14 +1,20 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
 	"time"
 
 	"spider/internal/config"
 	"spider/internal/handler"
 	"spider/internal/llm"
 	"spider/internal/model"
+	"spider/internal/notification"
 	"spider/internal/plugin"
 	"spider/internal/plugins/githubcollector"
 	"spider/internal/plugins/tgcollector"
@@ -20,6 +26,7 @@ import (
 	"spider/internal/telegram"
 
 	"github.com/redis/go-redis/v9"
+	"golang.org/x/crypto/bcrypt"
 	"gorm.io/driver/mysql"
 	"gorm.io/gorm"
 )
@@ -39,31 +46,95 @@ func main() {
 		log.Fatalf("connect mysql: %v", err)
 	}
 
-	// 3. AutoMigrate (5 tables)
+	sqlDB, err := db.DB()
+	if err != nil {
+		log.Fatalf("get sql.DB: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(25)
+	sqlDB.SetMaxIdleConns(10)
+	sqlDB.SetConnMaxLifetime(5 * time.Minute)
+	sqlDB.SetConnMaxIdleTime(3 * time.Minute)
+
+	// 3. AutoMigrate
 	err = db.AutoMigrate(
 		&model.Keyword{},
 		&model.Channel{},
 		&model.MerchantRaw{},
 		&model.MerchantClean{},
 		&model.TaskLog{},
+		&model.TaskDetail{},
+		&model.Setting{},
+		&model.GroupMember{},
+		&model.User{},
+		&model.TgAccount{},
+		&model.ScheduleJob{},
+		&model.MerchantNote{},
+		&model.AuditLog{},
+		&model.NotificationConfig{},
+		&model.MerchantArchived{},
+		&model.RolePermission{},
+		&model.Proxy{},
 	)
 	if err != nil {
 		log.Fatalf("automigrate: %v", err)
 	}
 	log.Println("MySQL tables migrated")
 
-	// 4. Connect Redis
+	// 4. Create default admin user if no users exist
+	var userCount int64
+	db.Model(&model.User{}).Count(&userCount)
+	if userCount == 0 {
+		hashed, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
+		db.Create(&model.User{
+			Username:           "admin",
+			Password:           string(hashed),
+			Nickname:           "管理员",
+			Role:               "admin",
+			Enabled:            true,
+			MustChangePassword: true,
+		})
+		log.Println("Default admin user created (admin / admin123)")
+	}
+
+	// 4b. Seed default role permissions if none exist
+	var permCount int64
+	db.Model(&model.RolePermission{}).Count(&permCount)
+	if permCount == 0 {
+		for role, perm := range model.DefaultPermissions() {
+			db.Create(&model.RolePermission{
+				Role:    role,
+				Menus:   perm.Menus,
+				Actions: perm.Actions,
+			})
+		}
+		log.Println("Default role permissions created")
+	}
+
+	// 5. Connect Redis
 	rdb := redis.NewClient(&redis.Options{
 		Addr:     fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port),
 		Password: cfg.Redis.Password,
 		DB:       cfg.Redis.DB,
 	})
+	ctx := context.Background()
+	if err := rdb.Ping(ctx).Err(); err != nil {
+		log.Fatalf("redis ping: %v", err)
+	}
 	log.Println("Redis connected")
 
-	// 5. Initialize store
+	// 6. Clean up stale "running" tasks
+	db.Model(&model.TaskLog{}).
+		Where("status = ?", "running").
+		Updates(map[string]any{
+			"status":      "failed",
+			"detail":      "服务重启,任务中断",
+			"finished_at": time.Now(),
+		})
+
+	// 7. Initialize store
 	s := store.New(db)
 
-	// 6. Initialize external clients
+	// 8. Initialize external clients
 	var llmClient *llm.Client
 	if cfg.LLM.APIKey != "" {
 		llmClient = llm.New(cfg.LLM.BaseURL, cfg.LLM.APIKey, cfg.LLM.Model, 30*time.Second)
@@ -74,35 +145,107 @@ func main() {
 		serperClient = search.NewSerperClient(cfg.Serper.APIKey, cfg.Serper.ResultsPerPage, cfg.Serper.MaxPages)
 	}
 
-	tgAccounts := make([]telegram.Account, 0, len(cfg.Telegram.Accounts))
-	for _, a := range cfg.Telegram.Accounts {
-		tgAccounts = append(tgAccounts, telegram.Account{
-			Phone:       a.Phone,
-			SessionFile: a.SessionFile,
-			AppID:       cfg.Telegram.AppID,
-			AppHash:     cfg.Telegram.AppHash,
-		})
+	// 8b. Construct TG crypto helper (required, fails fast on missing/invalid key).
+	tgCrypto, err := telegram.NewCrypto(cfg.Telegram.SecretKey)
+	if err != nil {
+		log.Fatalf("TG_SECRET_KEY invalid: %v — set a 32-byte base64 value in env", err)
+	}
+	sessionsDir := cfg.Telegram.SessionsDir
+	if sessionsDir == "" {
+		sessionsDir = "/app/sessions"
+	}
+	if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+		log.Fatalf("create sessions dir %s: %v", sessionsDir, err)
+	}
+
+	// 9. Load TG accounts from DB (fall back to config for backward compatibility)
+	var dbTgAccounts []model.TgAccount
+	db.Where("enabled = ?", true).Find(&dbTgAccounts)
+
+	tgAccounts := make([]telegram.Account, 0)
+	if len(dbTgAccounts) > 0 {
+		for _, a := range dbTgAccounts {
+			tgAccounts = append(tgAccounts, telegram.Account{
+				Phone:       a.Phone,
+				SessionFile: a.SessionFile,
+				AppID:       a.AppID,
+				AppHash:     a.AppHash,
+			})
+		}
+		log.Printf("Loaded %d TG accounts from database", len(tgAccounts))
+	} else {
+		// Fallback: load from config.yaml
+		for _, a := range cfg.Telegram.Accounts {
+			tgAccounts = append(tgAccounts, telegram.Account{
+				Phone:       a.Phone,
+				SessionFile: a.SessionFile,
+				AppID:       cfg.Telegram.AppID,
+				AppHash:     cfg.Telegram.AppHash,
+			})
+		}
+		if len(tgAccounts) > 0 {
+			log.Printf("Loaded %d TG accounts from config", len(tgAccounts))
+		}
 	}
 	tgManager := telegram.NewAccountManager(tgAccounts, rdb)
 
-	// 7. Register plugins
+	// 10. Register plugins
 	registry := plugin.NewRegistry()
 	registry.Register(webcollector.New(serperClient))
 	registry.Register(tgcollector.New(tgManager, llmClient, s))
 	registry.Register(githubcollector.New(cfg.GitHub.Token, s))
 
-	// 8. Initialize processor
+	// 11. Initialize processor, notifier & task manager
 	proc := processor.NewProcessor(s)
-
-	// 9. Initialize task manager
+	notifMgr := notification.NewManager(db)
 	taskMgr := task.NewManager(db, rdb, registry, s, proc)
+	taskMgr.SetNotifier(notifMgr)
 
-	// 10. Start HTTP server
-	r := handler.SetupRouter(s, taskMgr)
+	// 12. Setup scheduler
+	scheduler := task.NewScheduler(db, taskMgr)
+	scheduler.Start()
+	defer scheduler.Stop()
+
+	// 13. Setup HTTP server
+	tgAccountHandler := handler.NewTgAccountHandler(s, tgManager, tgCrypto, sessionsDir)
+	r := handler.SetupRouter(s, taskMgr, rdb, tgManager, tgAccountHandler)
+
+	// Wire scheduler into the schedule handler
+	if sh := handler.GetScheduleHandler(); sh != nil {
+		sh.SetScheduler(scheduler)
+	}
 
 	addr := handler.ServerAddr(cfg.Server.Port)
-	log.Printf("Server starting on %s", addr)
-	if err := r.Run(addr); err != nil {
-		log.Fatalf("gin run: %v", err)
+	srv := &http.Server{
+		Addr:         addr,
+		Handler:      r,
+		ReadTimeout:  30 * time.Second,
+		WriteTimeout: 60 * time.Second,
+		IdleTimeout:  120 * time.Second,
 	}
+
+	go func() {
+		log.Printf("Server starting on %s", addr)
+		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			log.Fatalf("listen: %v", err)
+		}
+	}()
+
+	// Graceful shutdown
+	quit := make(chan os.Signal, 1)
+	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+	<-quit
+	log.Println("Shutting down server...")
+
+	taskMgr.StopAll()
+
+	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	if err := srv.Shutdown(shutdownCtx); err != nil {
+		log.Fatalf("server forced to shutdown: %v", err)
+	}
+
+	rdb.Close()
+	sqlDB.Close()
+	log.Println("Server exited")
 }

+ 282 - 17
internal/handler/router.go

@@ -1,51 +1,316 @@
 package handler
 
 import (
+	"context"
 	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/gin-gonic/gin"
+	"github.com/redis/go-redis/v9"
 
+	"spider/internal/config"
+	"spider/internal/notification"
 	"spider/internal/store"
 	"spider/internal/task"
+	"spider/internal/telegram"
 )
 
+// rateLimitMiddleware limits requests per IP using Redis.
+// maxRequests per window duration.
+func rateLimitMiddleware(rdb *redis.Client, prefix string, maxRequests int64, window time.Duration) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		key := fmt.Sprintf("spider:ratelimit:%s:%s", prefix, c.ClientIP())
+		ctx := context.Background()
+
+		count, _ := rdb.Incr(ctx, key).Result()
+		if count == 1 {
+			rdb.Expire(ctx, key, window)
+		}
+
+		if count > maxRequests {
+			c.AbortWithStatusJSON(http.StatusTooManyRequests, Response{
+				Code:    429,
+				Message: "请求过于频繁,请稍后再试",
+			})
+			return
+		}
+		c.Next()
+	}
+}
+
+// scheduleHandler is a package-level reference so main.go can set the scheduler after creation.
+var scheduleHandler *ScheduleHandler
+
+// GetScheduleHandler returns the package-level ScheduleHandler so main.go
+// can call SetScheduler after the scheduler is created.
+func GetScheduleHandler() *ScheduleHandler {
+	return scheduleHandler
+}
+
 // SetupRouter builds and returns the Gin engine with all routes registered.
-func SetupRouter(s *store.Store, taskMgr *task.Manager) *gin.Engine {
+func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager, tgAccountHandler *TgAccountHandler) *gin.Engine {
 	r := gin.Default()
 
+	// CORS middleware
+	cfg := config.Get()
+	var corsOrigins []string
+	if cfg != nil {
+		corsOrigins = cfg.Server.CORSOrigins
+	}
+	r.Use(corsMiddleware(corsOrigins))
+
+	// Upload size limit
+	maxUpload := 10
+	if cfg != nil && cfg.Server.MaxUploadMB > 0 {
+		maxUpload = cfg.Server.MaxUploadMB
+	}
+	r.MaxMultipartMemory = int64(maxUpload) << 20
+
+	// Health check (public, no auth)
 	r.GET("/ping", func(c *gin.Context) {
-		c.JSON(http.StatusOK, gin.H{"message": "pong"})
+		health := gin.H{"status": "ok"}
+		httpStatus := http.StatusOK
+
+		sqlDB, err := s.DB.DB()
+		if err != nil {
+			health["mysql"] = "error: " + err.Error()
+			health["status"] = "degraded"
+			httpStatus = http.StatusServiceUnavailable
+		} else {
+			ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
+			defer cancel()
+			if err := sqlDB.PingContext(ctx); err != nil {
+				health["mysql"] = "error: " + err.Error()
+				health["status"] = "degraded"
+				httpStatus = http.StatusServiceUnavailable
+			} else {
+				health["mysql"] = "ok"
+			}
+		}
+
+		ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
+		defer cancel()
+		if err := rdb.Ping(ctx).Err(); err != nil {
+			health["redis"] = "error: " + err.Error()
+			health["status"] = "degraded"
+			httpStatus = http.StatusServiceUnavailable
+		} else {
+			health["redis"] = "ok"
+		}
+
+		c.JSON(httpStatus, health)
 	})
 
 	api := r.Group("/api/v1")
 
-	// Keywords (unified: search keywords + seeds)
+	// Set Redis for token blacklist and DB for permissions
+	SetAuthRedis(rdb)
+	setPermissionDB(s.DB)
+
+	// ── Public routes (no auth) ──
+	auth := &AuthHandler{store: s}
+	api.POST("/auth/login", rateLimitMiddleware(rdb, "login", 10, time.Minute), auth.Login)
+
+	// App info (public, needed for branding)
+	api.GET("/app/info", func(c *gin.Context) {
+		appCfg := config.Get()
+		name := "商户采集系统"
+		if appCfg != nil && appCfg.App.Name != "" {
+			name = appCfg.App.Name
+		}
+		OK(c, gin.H{"name": name})
+	})
+
+	// Level map (needed by login page for display)
+	sh := &SettingHandler{store: s}
+	api.GET("/settings/level-map", sh.GetLevelMap)
+
+	// ── Protected routes (require login) ──
+	protected := api.Group("")
+	protected.Use(JWTAuth())
+
+	// Dashboard & System Health
+	dh := &DashboardHandler{store: s, rdb: rdb}
+	protected.GET("/dashboard", dh.Get)
+	protected.GET("/system/health", dh.Health)
+
+	// Auth - self
+	protected.GET("/auth/profile", auth.GetProfile)
+	protected.PUT("/auth/profile", auth.UpdateProfile)
+	protected.PUT("/auth/password", auth.ChangePassword)
+	protected.POST("/auth/logout", auth.Logout)
+
+	// User's own permissions
+	ph := &PermissionHandler{store: s}
+	protected.GET("/auth/permissions", ph.GetMyPermissions)
+
+	// Keywords
 	kw := &KeywordHandler{store: s}
-	api.GET("/keywords", kw.List)
-	api.POST("/keywords", kw.Create)
-	api.PUT("/keywords/:id", kw.Update)
-	api.DELETE("/keywords/:id", kw.Delete)
+	protected.GET("/keywords", kw.List)
+	protected.POST("/keywords", RequireRole("admin", "operator"), RequireAction("keyword_manage"), kw.Create)
+	protected.POST("/keywords/import", RequireRole("admin", "operator"), RequireAction("keyword_manage"), kw.ImportCSV)
+	protected.PUT("/keywords/:id", RequireRole("admin", "operator"), RequireAction("keyword_manage"), kw.Update)
+	protected.DELETE("/keywords/:id", RequireRole("admin", "operator"), RequireAction("keyword_manage"), kw.Delete)
 
 	// Merchants
 	mc := &MerchantHandler{store: s}
-	api.GET("/merchants/stats", mc.Stats)
-	api.GET("/merchants/raw", mc.ListRaw)
-	api.GET("/merchants/clean", mc.ListClean)
-	api.GET("/merchants/clean/export", mc.ExportCSV)
-	api.GET("/merchants/:id", mc.GetByID)
+	protected.GET("/merchants/stats", mc.Stats)
+	protected.GET("/merchants/raw", mc.ListRaw)
+	protected.GET("/merchants/clean", mc.ListClean)
+	protected.GET("/merchants/clean/export", mc.ExportCSV)
+	protected.GET("/merchants/clean/users", mc.ListUsers)
+	protected.GET("/merchants/clean/industry-tags", mc.ListIndustryTags)
+	protected.POST("/merchants/clean/import", RequireRole("admin", "operator"), RequireAction("merchant_import"), mc.ImportCSV)
+	protected.GET("/merchants/:id", mc.GetByID)
+	protected.PUT("/merchants/clean/:id", RequireRole("admin", "operator"), RequireAction("merchant_edit"), mc.UpdateClean)
+	protected.PUT("/merchants/clean/:id/follow-status", RequireRole("admin", "operator"), RequireAction("merchant_edit"), mc.UpdateFollowStatus)
+	protected.PUT("/merchants/clean/:id/assign", RequireRole("admin", "operator"), RequireAction("merchant_assign"), mc.AssignMerchant)
+	protected.POST("/merchants/clean/:id/recheck", RequireRole("admin", "operator"), RequireAction("merchant_edit"), mc.RecheckMerchant)
+	protected.GET("/merchants/clean/:id/notes", mc.ListNotes)
+	protected.POST("/merchants/clean/:id/notes", RequireRole("admin", "operator"), mc.AddNote)
+	protected.DELETE("/merchants/raw/batch", RequireRole("admin", "operator"), RequireAction("merchant_delete"), mc.BatchDeleteRaw)
+	protected.DELETE("/merchants/clean/batch", RequireRole("admin", "operator"), RequireAction("merchant_delete"), mc.BatchDeleteClean)
+	protected.PUT("/merchants/clean/batch-assign", RequireRole("admin", "operator"), mc.BatchAssign)
+	protected.PUT("/merchants/clean/batch-follow-status", RequireRole("admin", "operator"), mc.BatchFollowStatus)
+	protected.PUT("/merchants/clean/batch-level", RequireRole("admin", "operator"), mc.BatchLevel)
+	protected.POST("/merchants/clean/merge", RequireRole("admin", "operator"), mc.MergeMerchants)
+	protected.POST("/merchants/clean/batch-recheck", RequireRole("admin", "operator"), mc.BatchRecheck)
+	protected.POST("/merchants/archive", RequireRole("admin"), mc.ArchiveMerchants)
+	protected.GET("/merchants/archived", mc.ListArchived)
+	protected.POST("/merchants/archived/:id/restore", RequireRole("admin"), mc.RestoreArchived)
 
 	// Tasks
 	th := &TaskHandler{store: s, taskMgr: taskMgr}
-	api.GET("/tasks", th.List)
-	api.POST("/tasks/start", th.Start)
-	api.GET("/tasks/:id", th.Get)
-	api.POST("/tasks/:id/stop", th.Stop)
-	api.GET("/tasks/:id/logs", th.Logs)
+	protected.GET("/tasks", th.List)
+	protected.POST("/tasks/start", RequireRole("admin", "operator"), RequireAction("task_start"), th.Start)
+	protected.GET("/tasks/:id", th.Get)
+	protected.POST("/tasks/:id/stop", RequireRole("admin", "operator"), RequireAction("task_stop"), th.Stop)
+	protected.POST("/tasks/:id/retry", RequireRole("admin", "operator"), RequireAction("task_start"), th.Retry)
+	protected.GET("/tasks/:id/logs", th.Logs)
+	protected.GET("/tasks/:id/details", th.Details)
+	protected.GET("/plugins", th.Plugins)
+
+	// Analytics
+	an := &AnalyticsHandler{store: s}
+	protected.GET("/analytics/funnel", an.Funnel)
+	protected.GET("/analytics/source-efficiency", an.SourceEfficiency)
+	protected.GET("/analytics/trends", an.Trends)
+	protected.GET("/analytics/trends/export", an.ExportTrends)
+
+	// Proxies
+	px := &ProxyHandler{store: s, taskMgr: taskMgr}
+	protected.GET("/proxies", px.List)
+	protected.GET("/proxies/enabled", px.ListEnabled)
+	protected.GET("/proxies/pool-status", px.PoolStatus)
+	protected.POST("/proxies", RequireRole("admin"), px.Create)
+	protected.POST("/proxies/test-all", RequireRole("admin"), px.TestAll)
+	protected.PUT("/proxies/:id", RequireRole("admin"), px.Update)
+	protected.DELETE("/proxies/:id", RequireRole("admin"), px.Delete)
+	protected.POST("/proxies/:id/test", RequireRole("admin"), px.Test)
+
+	// Channels
+	ch := &ChannelHandler{store: s}
+	protected.GET("/channels", ch.List)
+	protected.GET("/channels/stats", ch.Stats)
+	protected.PUT("/channels/:id/status", RequireRole("admin", "operator"), ch.UpdateStatus)
+	protected.DELETE("/channels/:id", RequireRole("admin"), ch.Delete)
+
+	// Groups
+	gh := &GroupHandler{store: s, tgManager: tgMgr}
+	protected.GET("/groups", gh.ListGroups)
+	protected.GET("/groups/:username/members", gh.ListMembers)
+	protected.GET("/groups/:username/members/export", gh.ExportMembers)
+	protected.GET("/member-groups/:username", gh.ListMemberGroups)
+	protected.GET("/members/search", gh.SearchMembers)
+	protected.POST("/groups/:username/clone-members", RequireRole("admin", "operator"), gh.CloneMembers)
+
+	// Settings (admin only)
+	protected.GET("/settings/grading", sh.GetGrading)
+	protected.PUT("/settings/grading", RequireRole("admin"), sh.UpdateGrading)
+	protected.POST("/settings/grading/reset", RequireRole("admin"), sh.ResetGrading)
+
+	// Audit logs (admin only)
+	ah := &AuditHandler{store: s}
+
+	// User management (admin only)
+	uh := &UserHandler{store: s}
+	adminOnly := protected.Group("", RequireRole("admin"))
+	adminOnly.GET("/audit-logs", ah.List)
+
+	// Permissions management (admin only)
+	adminOnly.GET("/permissions", ph.ListAll)
+	adminOnly.PUT("/permissions/:role", ph.Update)
+	adminOnly.POST("/permissions/reset", ph.Reset)
+
+	// Notification configs (admin only)
+	notifMgr := notification.NewManager(s.DB)
+	nh := &NotificationHandler{store: s, manager: notifMgr}
+	adminOnly.GET("/notification-configs", nh.List)
+	adminOnly.POST("/notification-configs", nh.Create)
+	adminOnly.PUT("/notification-configs/:id", nh.Update)
+	adminOnly.DELETE("/notification-configs/:id", nh.Delete)
+	adminOnly.POST("/notification-configs/:id/test", nh.Test)
+	adminOnly.GET("/users", uh.List)
+	adminOnly.POST("/users", uh.Create)
+	adminOnly.PUT("/users/:id", uh.Update)
+	adminOnly.POST("/users/:id/reset-password", uh.ResetPassword)
+	adminOnly.POST("/users/:id/force-logout", uh.ForceLogout)
+
+	// Backup (admin only)
+	bh := &BackupHandler{store: s}
+	adminOnly.GET("/backup/export", bh.ExportJSON)
+	adminOnly.GET("/backup/stats", bh.Stats)
+	adminOnly.DELETE("/users/:id", uh.Delete)
+
+	// Schedules (admin only)
+	scheduleHandler = &ScheduleHandler{store: s, taskMgr: taskMgr}
+	adminOnly.GET("/schedules", scheduleHandler.List)
+	adminOnly.POST("/schedules", scheduleHandler.Create)
+	adminOnly.PUT("/schedules/:id", scheduleHandler.Update)
+	adminOnly.DELETE("/schedules/:id", scheduleHandler.Delete)
+	adminOnly.POST("/schedules/:id/run", scheduleHandler.RunNow)
+
+	// TG account management
+	ta := tgAccountHandler
+	protected.GET("/tg-accounts", ta.List) // all logged-in users can view
+	adminOnly.POST("/tg-accounts", ta.Create)
+	adminOnly.POST("/tg-accounts/import", ta.Import)
+	adminOnly.PUT("/tg-accounts/:id", ta.Update)
+	adminOnly.POST("/tg-accounts/:id/test", ta.Test)
+	adminOnly.POST("/tg-accounts/:id/reveal-2fa", ta.RevealTwoFA)
+	adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
 
 	return r
 }
 
+// corsMiddleware handles Cross-Origin Resource Sharing.
+func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		origin := c.GetHeader("Origin")
+		if len(allowedOrigins) == 0 {
+			c.Header("Access-Control-Allow-Origin", "*")
+		} else {
+			for _, o := range allowedOrigins {
+				if o == origin || o == "*" {
+					c.Header("Access-Control-Allow-Origin", origin)
+					break
+				}
+			}
+		}
+		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
+		c.Header("Access-Control-Max-Age", "86400")
+
+		if c.Request.Method == "OPTIONS" {
+			c.AbortWithStatus(http.StatusNoContent)
+			return
+		}
+
+		c.Next()
+	}
+}
+
 // ServerAddr returns the listen address string for the given port.
 func ServerAddr(port int) string {
 	return fmt.Sprintf(":%d", port)