cd95ca2c6f
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 54s
Build backend images / build gateway (push) Failing after 55s
Build backend images / build identity-svc (push) Failing after 48s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 57s
Build backend images / build studio-svc (push) Failing after 44s
- gateway proxy: trim trailing slash before forwarding upstream. gin's RedirectTrailingSlash adds /nodes → /nodes/ while render-svc redirects /nodes/ → /nodes, forming an infinite redirect loop (admin pages 500'd) - accept is_admin as bool OR string "true" in render/file/notification/gateway auth middleware (identity emits it as a string) — admin endpoints were 403'ing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
2.2 KiB
Go
72 lines
2.2 KiB
Go
package proxy
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Upstream holds a reverse proxy for one backend service.
|
|
type Upstream struct {
|
|
Name string
|
|
rp *httputil.ReverseProxy
|
|
}
|
|
|
|
// New creates an Upstream reverse proxy pointing at target (e.g. "http://localhost:5010").
|
|
func New(name, target string) *Upstream {
|
|
u, err := url.Parse(target)
|
|
if err != nil {
|
|
panic("invalid upstream URL for " + name + ": " + err.Error())
|
|
}
|
|
rp := httputil.NewSingleHostReverseProxy(u)
|
|
rp.Transport = &http.Transport{
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
DisableCompression: false,
|
|
}
|
|
// Custom error handler so we return JSON rather than Go's default HTML
|
|
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte(`{"code":"bad_gateway","message":"upstream unavailable"}`))
|
|
}
|
|
// Rewrite the request before forwarding
|
|
orig := rp.Director
|
|
rp.Director = func(req *http.Request) {
|
|
orig(req)
|
|
// Normalise away a trailing slash (gin's RedirectTrailingSlash adds one when a
|
|
// route is registered as catch-all, e.g. /v1/nodes/*path, but upstream services
|
|
// register the canonical no-slash form and redirect /nodes/ → /nodes — without
|
|
// this the two redirects form an infinite loop).
|
|
if len(req.URL.Path) > 1 && strings.HasSuffix(req.URL.Path, "/") {
|
|
req.URL.Path = strings.TrimRight(req.URL.Path, "/")
|
|
}
|
|
req.Header.Set("X-Forwarded-Host", req.Host)
|
|
req.Header.Del("X-Forwarded-For") // let httputil re-add it properly
|
|
req.Host = u.Host
|
|
}
|
|
return &Upstream{Name: name, rp: rp}
|
|
}
|
|
|
|
// Handler returns a gin.HandlerFunc that proxies the request.
|
|
func (up *Upstream) Handler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
up.rp.ServeHTTP(c.Writer, c.Request)
|
|
}
|
|
}
|
|
|
|
// StripPrefixHandler proxies after stripping a URL prefix (e.g. "/api" → "").
|
|
func (up *Upstream) StripPrefixHandler(prefix string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Request.URL.Path = strings.TrimPrefix(c.Request.URL.Path, prefix)
|
|
if c.Request.URL.Path == "" {
|
|
c.Request.URL.Path = "/"
|
|
}
|
|
up.rp.ServeHTTP(c.Writer, c.Request)
|
|
}
|
|
}
|