feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+143
View File
@@ -0,0 +1,143 @@
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("/settings/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/comments/*path", apiRL, auth, content.Handler())
v1.Any("/favorites/*path", apiRL, auth, 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-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())
log.Printf("gateway listening on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("gateway: %v", err)
}
}