Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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", "access-token", "client", "uid", "token-type", "expiry"}
|
||||
var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"}
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user