package middleware import ( "fmt" "net/http" "net/url" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/config" ) // CORSConfig holds CORS middleware configuration. type CORSConfig struct { AllowedOrigins []string AllowedMethods []string AllowedHeaders []string ExposeHeaders []string AllowCredentials bool MaxAge int // seconds DevMode bool // when true and AllowedOrigins is empty, fall back to Allow-Origin: * } // Default CORS values for production. var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID"} var defaultCORSExposeHeaders = []string{"Content-Length"} var defaultCORSMaxAge = 86400 // 24 hours // CORS adds Cross-Origin Resource Sharing headers. // Corresponds to Chatwoot's rack-cors middleware configuration. // When DevMode is true and no AllowedOrigins are configured, it falls back // to Allow-Origin:* for development convenience. // Otherwise, it checks the request Origin against the whitelist using // exact match or wildcard subdomain matching (*.example.com). func CORS(cfg CORSConfig) gin.HandlerFunc { // Apply defaults for empty fields methods := cfg.AllowedMethods if len(methods) == 0 { methods = defaultCORSMethods } headers := cfg.AllowedHeaders if len(headers) == 0 { headers = defaultCORSHeaders } exposeHeaders := cfg.ExposeHeaders if len(exposeHeaders) == 0 { exposeHeaders = defaultCORSExposeHeaders } maxAge := cfg.MaxAge if maxAge == 0 { maxAge = defaultCORSMaxAge } return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") if cfg.DevMode && len(cfg.AllowedOrigins) == 0 { c.Header("Access-Control-Allow-Origin", "*") } else if origin != "" && isOriginAllowed(origin, cfg.AllowedOrigins) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") if cfg.AllowCredentials { c.Header("Access-Control-Allow-Credentials", "true") } } c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", ")) c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", ")) c.Header("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", ")) c.Header("Access-Control-Max-Age", fmt.Sprintf("%d", maxAge)) if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } // isOriginAllowed checks whether the request origin matches any entry in the // AllowedOrigins whitelist. Supports: // - Exact match: "https://app.example.com" matches exactly // - Wildcard subdomain: "*.example.com" matches any subdomain of example.com // (e.g. https://foo.example.com, https://bar.example.com) func isOriginAllowed(origin string, allowedOrigins []string) bool { for _, pattern := range allowedOrigins { if matchOrigin(origin, pattern) { return true } } return false } // matchOrigin matches an origin against a pattern. // pattern can be: // - An exact URL: "https://app.example.com" → only matches that URL // - A wildcard domain: "*.example.com" → matches any subdomain of example.com // with any scheme (http or https) func matchOrigin(origin, pattern string) bool { // Exact match if pattern == origin { return true } // Wildcard subdomain matching: pattern like "*.example.com" if strings.HasPrefix(pattern, "*.") { // Strip the wildcard prefix to get the base domain: ".example.com" baseDomain := pattern[1:] // ".example.com" // Extract host from origin URL host := extractHost(origin) if host == "" { return false } // Host must end with the base domain and have at least one subdomain character // e.g. "foo.example.com" ends with ".example.com" → match // but "example.com" does not end with ".example.com" → no match if strings.HasSuffix(host, baseDomain) && len(host) > len(baseDomain) { return true } } return false } // extractHost parses a URL string and returns the host portion (without port). func extractHost(origin string) string { // Origin is always a full URL like "https://foo.example.com:8080" // Use standard library parsing u, err := url.Parse(origin) if err != nil { // Fallback: try manual extraction for malformed origins // Strip scheme afterScheme := origin if idx := strings.Index(origin, "://"); idx >= 0 { afterScheme = origin[idx+3:] } // Strip path if idx := strings.Index(afterScheme, "/"); idx >= 0 { afterScheme = afterScheme[:idx] } // Strip port if idx := strings.LastIndex(afterScheme, ":"); idx >= 0 { afterScheme = afterScheme[:idx] } return afterScheme } return u.Hostname() } // CORSConfigFromAppConfig creates a middleware CORSConfig from the application config. func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig { devMode := cfg.Server.Mode == "debug" return CORSConfig{ AllowedOrigins: cfg.Server.CORS.AllowedOrigins, AllowedMethods: cfg.Server.CORS.AllowedMethods, AllowedHeaders: cfg.Server.CORS.AllowedHeaders, ExposeHeaders: cfg.Server.CORS.ExposeHeaders, AllowCredentials: cfg.Server.CORS.AllowCredentials, MaxAge: cfg.Server.CORS.MaxAge, DevMode: devMode, } }