package main import ( "log" "net/http" "os" "strings" "time" "github.com/flatrender/gateway/internal/middleware" "github.com/flatrender/gateway/internal/proxy" "github.com/flatrender/gateway/internal/ws" "github.com/gin-gonic/gin" ) // isPublicPaymentPath reports whether a /payments/* sub-path is a public callback or // webhook (no auth) rather than an authenticated payments API call. c.Param("path") // includes the leading slash, e.g. "/callback/zarinpal". func isPublicPaymentPath(c *gin.Context) bool { p := c.Param("path") return strings.HasPrefix(p, "/callback/") || strings.HasPrefix(p, "/webhook/") } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func main() { // ── Upstream addresses ──────────────────────────────────────────────────── identityURL := getEnv("IDENTITY_URL", "http://localhost:5010") contentURL := getEnv("CONTENT_URL", "http://localhost:5011") fileURL := getEnv("FILE_URL", "http://localhost:5012") studioURL := getEnv("STUDIO_URL", "http://localhost:5013") renderURL := getEnv("RENDER_URL", "http://localhost:5014") renderWSURL := getEnv("RENDER_WS_URL", "ws://localhost:5014") notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:5015") jwtSecret := getEnv("JWT_SECRET", "change-me") port := getEnv("PORT", "8080") // ── Upstreams ───────────────────────────────────────────────────────────── identity := proxy.New("identity", identityURL) content := proxy.New("content", contentURL) file := proxy.New("file", fileURL) studio := proxy.New("studio", studioURL) render := proxy.New("render", renderURL) notification := proxy.New("notification", notificationURL) // ── Auth middlewares ────────────────────────────────────────────────────── auth := middleware.JWTAuth(jwtSecret) optionalAuth := middleware.OptionalJWTAuth(jwtSecret) // ── Rate limiters (per-IP sliding window) ───────────────────────────────── // Auth endpoints: strict — prevents brute-force attacks authRL := middleware.NewRateLimiter(20, time.Minute).Middleware() // Upload / render submission: moderate heavyRL := middleware.NewRateLimiter(60, time.Minute).Middleware() // General API: generous apiRL := middleware.NewRateLimiter(300, time.Minute).Middleware() // ── Router ──────────────────────────────────────────────────────────────── r := gin.Default() // CORS r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) // Health r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "gateway"}) }) // ── WebSocket: render progress ──────────────────────────────────────────── r.GET("/ws/v1/render/:job_id", ws.RenderProgressProxy(renderWSURL, jwtSecret)) v1 := r.Group("/v1") // ── Identity Service ────────────────────────────────────────────────────── // Auth: strict rate limit (brute-force protection) v1.Any("/auth/*path", authRL, identity.Handler()) // Plans: public list, auth required for purchase v1.Any("/plans/*path", apiRL, optionalAuth, identity.Handler()) v1.Any("/discounts/*path", apiRL, optionalAuth, identity.Handler()) // Authenticated identity endpoints v1.Any("/users/*path", apiRL, auth, identity.Handler()) v1.Any("/tenants/*path", apiRL, auth, identity.Handler()) v1.Any("/admin/*path", apiRL, auth, identity.Handler()) v1.Any("/quests/*path", apiRL, auth, identity.Handler()) v1.Any("/gifts/*path", apiRL, auth, identity.Handler()) // Payments: /callback/* and /webhook/* are public (browser redirects + provider // webhooks); all other /payments/* require auth. A single catch-all is required — // gin rejects registering /payments/callback/* alongside a /payments/* catch-all. v1.Any("/payments/*path", apiRL, middleware.JWTAuthSkip(jwtSecret, isPublicPaymentPath), identity.Handler()) // ── Content Service ─────────────────────────────────────────────────────── v1.Any("/categories/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/tags/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/fonts/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/music/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/templates/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/projects/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/blogs/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/slides/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/home-events/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/routes/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/comments/*path", apiRL, auth, content.Handler()) v1.Any("/favorites/*path", apiRL, auth, content.Handler()) v1.Any("/ai/*path", apiRL, auth, content.Handler()) v1.Any("/scenes/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/scene-elements/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/preset-stories/*path", apiRL, optionalAuth, content.Handler()) // ── File Service ───────────────────────────────────────────────────────── v1.Any("/files/*path", apiRL, auth, file.Handler()) v1.Any("/folders/*path", apiRL, auth, file.Handler()) v1.Any("/upload/*path", heavyRL, auth, file.Handler()) // upload: moderate limit v1.Any("/quotas/*path", apiRL, auth, file.Handler()) // ── Studio Service ──────────────────────────────────────────────────────── v1.Any("/saved-projects/*path", apiRL, auth, studio.Handler()) // ── Render Service ──────────────────────────────────────────────────────── v1.Any("/renders/*path", heavyRL, auth, render.Handler()) // submission: moderate limit v1.Any("/snapshots/*path", heavyRL, auth, render.Handler()) v1.Any("/exports/*path", apiRL, auth, render.Handler()) v1.Any("/nodes/*path", apiRL, auth, render.Handler()) v1.Any("/node-fonts/*path", apiRL, auth, render.Handler()) v1.Any("/admin-exports/*path", apiRL, auth, render.Handler()) v1.Any("/admin-renders", apiRL, auth, render.Handler()) v1.Any("/template-bundles/*path", apiRL, auth, render.Handler()) v1.Any("/scene-snapshots/*path", apiRL, auth, render.Handler()) v1.Any("/template-scans/*path", apiRL, auth, render.Handler()) v1.Any("/template-scan-jobs/*path", apiRL, auth, render.Handler()) v1.Any("/node-updates/*path", apiRL, auth, render.Handler()) // ── Notification Service ────────────────────────────────────────────────── v1.Any("/notifications/*path", apiRL, auth, notification.Handler()) v1.Any("/notification-templates/*path", apiRL, auth, notification.Handler()) v1.Any("/channels/*path", apiRL, auth, notification.Handler()) v1.Any("/sms/*path", apiRL, auth, notification.Handler()) v1.Any("/email/*path", apiRL, auth, notification.Handler()) v1.Any("/campaigns/*path", apiRL, auth, notification.Handler()) log.Printf("gateway listening on :%s", port) if err := r.Run(":" + port); err != nil { log.Fatalf("gateway: %v", err) } }