8488acb115
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 30s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 31s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s
Per-scene preview thumbnails for templates. Admin clicks "ساخت پیشنمایش صحنهها" → one single-frame AE render per scene → content.scenes.snapshot_url → shown as a thumbnail in the admin scene list (and available to the studio). - migration 30_render_snapshot_jobs.sql: render.snapshot_jobs (queued|running| done|error, per scene, image_url). - render-svc: db/snapshotjobs.go (EnqueueSceneSnapshots, List, Claim, SetResult -> writes content.scenes.snapshot_url cross-schema, SetError); handlers/ snapshotjobs.go (admin POST/GET /v1/scene-snapshots/:project_id + node-facing internal claim/result/fail); main.go routes; gateway route. - devworker: RunSnapshots — fulfils snapshot jobs with a generated placeholder PNG (data: URL, scene-key-tinted) so the flow is verifiable without an AE node. Gated by RENDER_DEV_SNAPSHOTS (default off; never hijacks real render jobs). - admin UI: ProjectScenes "generate snapshots" button (enqueue + poll + reload) and a thumbnail (snapshot_url || image) per scene row. Verified e2e via the dev mock: enqueue -> jobs run -> content.scenes.snapshot_url populated -> scenes API returns it -> admin renders the thumbnail. Remaining (C2): node-agent real-AE runner — claim snapshot, aerender -s0 -e0 -> ffmpeg still -> upload to a PERMANENT URL (mirror file-svc, not the time-limited export presign) -> post result. Needs a live AE node to build + verify. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
8.9 KiB
Go
162 lines
8.9 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("/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)
|
|
}
|
|
}
|