1ff6e494c0
Build backend images / build content-svc (push) Failing after 19s
Build backend images / build file-svc (push) Failing after 1m53s
Build backend images / build gateway (push) Failing after 16s
Build backend images / build identity-svc (push) Failing after 7m1s
Build backend images / build notification-svc (push) Failing after 7m24s
Build backend images / build render-svc (push) Failing after 3m12s
Build backend images / build studio-svc (push) Failing after 43s
feat: AE template scanner + scene editor + AEP bundle pipeline
Scene editor (admin): per-project Scenes / Shared Colors / Color Presets
manager (ProjectScenes) reachable from each project.
AEP bundle pipeline: upload .aep or .zip → stored once per template at
templates/{project_id}/(bundle.zip|template.aep); render claim probes and
returns is_bundle+md5; node-agent extracts the bundle, locates the .aep
(zip-slip guarded), and caches by md5 so repeated renders extract once.
AE template scanner ("read scenes/colours/configs from the AEP"):
- content-svc importer: POST /v1/projects/{id}/scan/{preview,apply} —
review-diff-then-merge into scenes/elements/colours (manual edits kept).
- render-svc Go quick-scan: stdlib RIFX parser extracts comp names+durations
(no AE) → POST /v1/template-scans/{id}/quick.
- render-svc AE scan jobs + node-agent runner: queue → node runs scan.jsx
(reverse of legacy JSXGenerator conventions: frfinal/frshare/frl_/frd_) →
posts ScanResult back. Migration 26_render_scan_jobs.
- admin UI: "اسکن از افترافکت" with quick/full engines + diff-review modal.
Verified: importer preview/apply, Go quick-scan end-to-end (synthetic .aep →
scene imported), bundle extract unit tests, RIFX parser unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
159 lines
8.7 KiB
Go
159 lines
8.7 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("/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("/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)
|
|
}
|
|
}
|