fix(storage): emit origin-relative object URLs
Build signed object URLs as path-only (/api/objects/...) instead of prefixing APP_BASE_URL, so media loads from whatever host or reverse proxy served the app. Remove the now-unused base URL from the signer and drop the APP_BASE_URL / Server.BaseURL config.
This commit is contained in:
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func mustSigner(t *testing.T) *storage.Signer {
|
||||
t.Helper()
|
||||
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
|
||||
}
|
||||
|
||||
func TestShareFlowWithFS(t *testing.T) {
|
||||
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func TestShareFlowWithS3(t *testing.T) {
|
||||
if endpoint == "" {
|
||||
t.Skip("S3_ENDPOINT not set")
|
||||
}
|
||||
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Skipf("signer init: %v", err)
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ type Auth struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
BaseURL string
|
||||
Addr string
|
||||
}
|
||||
|
||||
type SSE struct {
|
||||
@@ -227,8 +226,7 @@ func Load() (*Config, error) {
|
||||
RefreshTTL: refreshTTL,
|
||||
},
|
||||
Server: Server{
|
||||
Addr: getEnv("HTTP_ADDR", ":8080"),
|
||||
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
|
||||
Addr: getEnv("HTTP_ADDR", ":8080"),
|
||||
},
|
||||
SSE: SSE{
|
||||
HeartbeatInterval: heartbeat,
|
||||
|
||||
@@ -65,7 +65,7 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
|
||||
}
|
||||
repos := store.NewSQLite(database)
|
||||
hub := sse.NewHub(1)
|
||||
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
|
||||
if endpoint == "" {
|
||||
t.Skip("S3_ENDPOINT not set")
|
||||
}
|
||||
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("test-secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Skipf("signer init: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
|
||||
// storage backend so the full flow can run without external infrastructure.
|
||||
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
||||
t.Helper()
|
||||
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
|
||||
signer, err := storage.NewSigner("test-secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("storage init: %v", err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func newTestFS(t *testing.T) *FS {
|
||||
t.Helper()
|
||||
signer, err := NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
|
||||
signer, err := NewSigner("test-secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func newTestS3(t *testing.T) (*S3, *Signer) {
|
||||
if endpoint == "" {
|
||||
t.Skip("S3_ENDPOINT not set")
|
||||
}
|
||||
signer, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
signer, err := NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -17,19 +17,18 @@ import (
|
||||
// route. It is shared by every storage backend so delivery is identical: the
|
||||
// client always talks to the backend, never to object storage directly.
|
||||
type Signer struct {
|
||||
secret []byte
|
||||
baseURL string
|
||||
ttl time.Duration
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
|
||||
// public base URL of the app; ttl limits URL lifetime (defaults to one hour);
|
||||
// window quantizes the expiry so the same object yields an identical URL within
|
||||
// one window (defaults to ten minutes, and is capped at ttl). A non-positive
|
||||
// window keeps the URL stable for its whole lifetime.
|
||||
func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, error) {
|
||||
// NewSigner builds a signer. secret signs the URLs (required); ttl limits URL
|
||||
// lifetime (defaults to one hour); window quantizes the expiry so the same
|
||||
// object yields an identical URL within one window (defaults to ten minutes, and
|
||||
// is capped at ttl). A non-positive window keeps the URL stable for its whole
|
||||
// lifetime.
|
||||
func NewSigner(secret string, ttl, window time.Duration) (*Signer, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, errors.New("url secret is required")
|
||||
}
|
||||
@@ -43,23 +42,24 @@ func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, erro
|
||||
window = ttl
|
||||
}
|
||||
return &Signer{
|
||||
secret: []byte(secret),
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
ttl: ttl,
|
||||
window: window,
|
||||
now: time.Now,
|
||||
secret: []byte(secret),
|
||||
ttl: ttl,
|
||||
window: window,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// URL returns a signed app URL for the given opaque key. The expiry is
|
||||
// quantized to the signing window so repeated calls within that window return
|
||||
// the identical URL, letting clients reuse cached objects.
|
||||
// URL returns a signed object URL for the given opaque key. The URL is
|
||||
// origin-relative (it has no scheme or host) so the client fetches the blob from
|
||||
// the same origin that served the app, regardless of the deployment host. The
|
||||
// expiry is quantized to the signing window so repeated calls within that window
|
||||
// return the identical URL, letting clients reuse cached objects.
|
||||
func (s *Signer) URL(key string) string {
|
||||
exp := s.expiry()
|
||||
q := url.Values{}
|
||||
q.Set("e", strconv.FormatInt(exp, 10))
|
||||
q.Set("s", s.sign(key, exp))
|
||||
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
|
||||
return fmt.Sprintf("/api/objects/%s?%s", escapeKey(key), q.Encode())
|
||||
}
|
||||
|
||||
// expiry is the quantized Unix expiry shared by all URLs issued in the current
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -23,7 +24,7 @@ func expiryOf(t *testing.T, raw string) int64 {
|
||||
}
|
||||
|
||||
func TestSignerURLStableWithinWindow(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, time.Hour)
|
||||
s, err := NewSigner("secret", time.Hour, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
@@ -35,7 +36,7 @@ func TestSignerURLStableWithinWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSignerURLChangesAcrossWindow(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
s, err := NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func TestSignerURLChangesAcrossWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSignerURLVerifies(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
s, err := NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
@@ -61,3 +62,21 @@ func TestSignerURLVerifies(t *testing.T) {
|
||||
t.Fatalf("verify issued URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerURLIsOriginRelative(t *testing.T) {
|
||||
s, err := NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
raw := s.URL("objects/a/thumb.webp")
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
if u.Scheme != "" || u.Host != "" || u.IsAbs() {
|
||||
t.Fatalf("url is not origin-relative: %q", raw)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/api/objects/") {
|
||||
t.Fatalf("url = %q, want /api/objects/ prefix", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, e
|
||||
if secret == "" {
|
||||
return nil, errors.New("object URL secret is not available")
|
||||
}
|
||||
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL, cfg.Storage.URLSignWindow)
|
||||
signer, err := NewSigner(secret, cfg.Storage.URLTTL, cfg.Storage.URLSignWindow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user