Files
flatrender/services/gateway/cmd/server/main.go
T
soroush.asadi 7f2f65dd8a
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 48s
feat(render+node-agent+admin): install fonts on all render nodes + verify
Push a font once → every node installs it → admin sees per-node status.

- render-svc: font_requests + node_fonts tables (mig 25); admin GET/POST/DELETE
  /v1/node-fonts (with per-node status matrix); internal (HMAC) GET pending +
  POST status for node-agents
- node-agent: fontSyncLoop polls pending fonts every 60s, downloads, installs
  (Windows Fonts dir + registry / macOS / linux fc-cache), reports Installed/Failed
- gateway: /v1/node-fonts/* → render
- admin /admin/node-fonts: upload a .ttf/.otf → install on all nodes; per-node
  Installed/Pending/Failed badges + counts + delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 06:33:48 +03:30

154 lines
8.4 KiB
Go

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("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/color-presets/*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("/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)
}
}