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:
@@ -41,7 +41,6 @@ JWT_REFRESH_TTL=720h
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
HTTP_ADDR=:8080
|
HTTP_ADDR=:8080
|
||||||
APP_BASE_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# SSE
|
# SSE
|
||||||
SSE_HEARTBEAT_INTERVAL=25s
|
SSE_HEARTBEAT_INTERVAL=25s
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ cp .env.example .env
|
|||||||
| Переменная | Значение по умолчанию | Описание |
|
| Переменная | Значение по умолчанию | Описание |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера |
|
| `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера |
|
||||||
| `APP_BASE_URL` | `http://localhost:8080` | Внешний базовый URL приложения (для ссылок и SSE) |
|
|
||||||
| **База данных (метаданные)** | | |
|
| **База данных (метаданные)** | | |
|
||||||
| `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` |
|
| `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` |
|
||||||
| `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) |
|
| `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
func mustSigner(t *testing.T) *storage.Signer {
|
func mustSigner(t *testing.T) *storage.Signer {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
t.Fatalf("signer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
|
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
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) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
t.Fatalf("signer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func TestShareFlowWithS3(t *testing.T) {
|
|||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
t.Skip("S3_ENDPOINT not set")
|
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 {
|
if err != nil {
|
||||||
t.Skipf("signer init: %v", err)
|
t.Skipf("signer init: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ type Auth struct {
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Addr string
|
Addr string
|
||||||
BaseURL string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SSE struct {
|
type SSE struct {
|
||||||
@@ -228,7 +227,6 @@ func Load() (*Config, error) {
|
|||||||
},
|
},
|
||||||
Server: Server{
|
Server: Server{
|
||||||
Addr: getEnv("HTTP_ADDR", ":8080"),
|
Addr: getEnv("HTTP_ADDR", ":8080"),
|
||||||
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
|
|
||||||
},
|
},
|
||||||
SSE: SSE{
|
SSE: SSE{
|
||||||
HeartbeatInterval: heartbeat,
|
HeartbeatInterval: heartbeat,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
|
|||||||
}
|
}
|
||||||
repos := store.NewSQLite(database)
|
repos := store.NewSQLite(database)
|
||||||
hub := sse.NewHub(1)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
t.Fatalf("signer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
|
|||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
t.Skip("S3_ENDPOINT not set")
|
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 {
|
if err != nil {
|
||||||
t.Skipf("signer init: %v", err)
|
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.
|
// storage backend so the full flow can run without external infrastructure.
|
||||||
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("storage init: %v", err)
|
t.Fatalf("storage init: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
func newTestFS(t *testing.T) *FS {
|
func newTestFS(t *testing.T) *FS {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
t.Fatalf("signer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func newTestS3(t *testing.T) (*S3, *Signer) {
|
|||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
t.Skip("S3_ENDPOINT not set")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("signer: %v", err)
|
t.Fatalf("signer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,18 +18,17 @@ import (
|
|||||||
// client always talks to the backend, never to object storage directly.
|
// client always talks to the backend, never to object storage directly.
|
||||||
type Signer struct {
|
type Signer struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
baseURL string
|
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
window time.Duration
|
window time.Duration
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
|
// NewSigner builds a signer. secret signs the URLs (required); ttl limits URL
|
||||||
// public base URL of the app; ttl limits URL lifetime (defaults to one hour);
|
// lifetime (defaults to one hour); window quantizes the expiry so the same
|
||||||
// window quantizes the expiry so the same object yields an identical URL within
|
// object yields an identical URL within one window (defaults to ten minutes, and
|
||||||
// one window (defaults to ten minutes, and is capped at ttl). A non-positive
|
// is capped at ttl). A non-positive window keeps the URL stable for its whole
|
||||||
// window keeps the URL stable for its whole lifetime.
|
// lifetime.
|
||||||
func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, error) {
|
func NewSigner(secret string, ttl, window time.Duration) (*Signer, error) {
|
||||||
if strings.TrimSpace(secret) == "" {
|
if strings.TrimSpace(secret) == "" {
|
||||||
return nil, errors.New("url secret is required")
|
return nil, errors.New("url secret is required")
|
||||||
}
|
}
|
||||||
@@ -44,22 +43,23 @@ func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, erro
|
|||||||
}
|
}
|
||||||
return &Signer{
|
return &Signer{
|
||||||
secret: []byte(secret),
|
secret: []byte(secret),
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
window: window,
|
window: window,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL returns a signed app URL for the given opaque key. The expiry is
|
// URL returns a signed object URL for the given opaque key. The URL is
|
||||||
// quantized to the signing window so repeated calls within that window return
|
// origin-relative (it has no scheme or host) so the client fetches the blob from
|
||||||
// the identical URL, letting clients reuse cached objects.
|
// 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 {
|
func (s *Signer) URL(key string) string {
|
||||||
exp := s.expiry()
|
exp := s.expiry()
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("e", strconv.FormatInt(exp, 10))
|
q.Set("e", strconv.FormatInt(exp, 10))
|
||||||
q.Set("s", s.sign(key, exp))
|
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
|
// expiry is the quantized Unix expiry shared by all URLs issued in the current
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -23,7 +24,7 @@ func expiryOf(t *testing.T, raw string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSignerURLStableWithinWindow(t *testing.T) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("new signer: %v", err)
|
t.Fatalf("new signer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ func TestSignerURLStableWithinWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSignerURLChangesAcrossWindow(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 {
|
if err != nil {
|
||||||
t.Fatalf("new signer: %v", err)
|
t.Fatalf("new signer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ func TestSignerURLChangesAcrossWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSignerURLVerifies(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 {
|
if err != nil {
|
||||||
t.Fatalf("new signer: %v", err)
|
t.Fatalf("new signer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,3 +62,21 @@ func TestSignerURLVerifies(t *testing.T) {
|
|||||||
t.Fatalf("verify issued URL: %v", err)
|
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 == "" {
|
if secret == "" {
|
||||||
return nil, errors.New("object URL secret is not available")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See `proposal.md` - Why. Relevant current state:
|
||||||
|
|
||||||
|
- `storage.Signer.URL` builds `baseURL + "/api/objects/<key>?e&s"` (`signer.go`), with
|
||||||
|
`baseURL` from `cfg.Server.BaseURL` (`APP_BASE_URL`, default `http://localhost:8080`).
|
||||||
|
- That absolute URL is the only use of `Server.BaseURL`; SSE and API traffic are already
|
||||||
|
root-relative (`/api/...`).
|
||||||
|
- `photos.Service.attachURLs` runs outside an HTTP request, so it cannot see the request
|
||||||
|
host to derive a base.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Photo/preview URLs work on any host or reverse proxy with no per-deployment base URL.
|
||||||
|
- Preserve signing, expiry, quantization and caching behavior exactly.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Serving the app under a URL subpath.
|
||||||
|
- Absolute URL generation for non-browser consumers (no such consumer today).
|
||||||
|
- Changing the object route, storage backends, or delivery semantics.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Emit origin-relative (path-only) object URLs
|
||||||
|
`Signer.URL(key)` returns `"/api/objects/<escaped-key>?e=<exp>&s=<sig>"`. A leading slash
|
||||||
|
makes it origin-relative: the browser resolves it against the page's scheme/host/port,
|
||||||
|
which is exactly the backend that served the page.
|
||||||
|
|
||||||
|
- Alternative considered: keep absolute URLs and require operators to set
|
||||||
|
`APP_BASE_URL`. Rejected - this is the current footgun; a wrong or default value
|
||||||
|
silently breaks all images.
|
||||||
|
- Alternative considered: derive the base from the request (`Host`,
|
||||||
|
`X-Forwarded-Proto`). Rejected - URLs are minted in `attachURLs`/SSE paths that have no
|
||||||
|
request; threading a base through every service is invasive for no benefit once URLs
|
||||||
|
are relative.
|
||||||
|
- Alternative considered: document-relative URLs without a leading slash. Rejected - the
|
||||||
|
app and its API calls already use root-relative paths, so root-relative is consistent.
|
||||||
|
|
||||||
|
### Remove the base URL from the signer and config
|
||||||
|
Drop the `baseURL` field and parameter from `Signer`/`NewSigner`, stop reading
|
||||||
|
`cfg.Server.BaseURL` in `storage.Open`, and remove `Server.BaseURL` from config,
|
||||||
|
`.env.example` and the README. Leaving a now-unused variable would invite the same
|
||||||
|
misconfiguration.
|
||||||
|
|
||||||
|
### Verification unchanged
|
||||||
|
`VerifyRequest` checks the key and query (`e`, `s`) only; it never used the host, so
|
||||||
|
signing and validation are untouched.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Subpath deployment (`https://host/galo/`) would break with root-relative URLs] ->
|
||||||
|
The app already assumes root (`/api/...`); subpath support would be a separate change
|
||||||
|
adding a base-path prefix. Documented as out of scope.
|
||||||
|
- [Non-browser clients need a base to resolve object URLs] -> None consume them today;
|
||||||
|
if one appears, it resolves against the instance base like any HTTP client.
|
||||||
|
- [Tests that assumed an absolute URL] -> They parse the URL and use `Path`/`RawQuery`;
|
||||||
|
update `NewSigner` call sites to drop the base argument.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Remove `APP_BASE_URL` from the server environment and deploy the backend. No data or
|
||||||
|
schema change. Rollback is reverting the deploy.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Object URLs are absolute and built from `APP_BASE_URL`, whose default is
|
||||||
|
`http://localhost:8080`. On a server reached by any other address the browser is told to
|
||||||
|
fetch thumbnails and originals from `localhost`, so no photo or preview loads. This was
|
||||||
|
observed on a deployment served from `http://192.168.77.82:8080/` with the default
|
||||||
|
config: pages, login and album lists work (relative `/api/...`), but every image URL
|
||||||
|
points at the client's own machine.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Emit same-origin object URLs: the signer returns a path-only URL
|
||||||
|
(`/api/objects/<key>?e=...&s=...`) instead of an absolute one.
|
||||||
|
- Remove the base URL from the signer (`Signer`/`NewSigner`) since it is no longer
|
||||||
|
needed; verification is unchanged (it uses the key and query only).
|
||||||
|
- **BREAKING** (config): remove `APP_BASE_URL` / `Server.BaseURL`, which after this has
|
||||||
|
no remaining use (SSE and API calls are already relative). Update `.env.example` and
|
||||||
|
the README.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
<!-- none -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `storage-backends`: add a requirement that issued object URLs are host-independent
|
||||||
|
(same-origin), so the app works behind any host or reverse proxy without calibrated
|
||||||
|
base-URL configuration.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: `backend/internal/storage/signer.go`, `storage.go`
|
||||||
|
(`Open`/`NewSigner` call), `backend/internal/config/config.go` (`Server.BaseURL`).
|
||||||
|
- Docs/config: `.env.example`, `README.md`.
|
||||||
|
- Tests: signer/fs/objects/app/e2e call sites of `NewSigner` / `Server.BaseURL`.
|
||||||
|
- Frontend: unchanged; relative URLs resolve against the page origin. The `sameOrigin`
|
||||||
|
helper in `PanoViewer` becomes a no-op but harmless.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Хост-независимые URL объектов
|
||||||
|
Выдаваемый клиенту подписанный URL объекта SHALL быть относительным к текущему источнику
|
||||||
|
(same-origin) и SHALL не содержать настроенный хост. Просмотр и скачивание медиа SHALL
|
||||||
|
работать за любым хостом или обратным прокси без настройки внешнего базового URL.
|
||||||
|
|
||||||
|
#### Scenario: Другой хост
|
||||||
|
- **WHEN** приложение доступно по адресу, отличному от адреса по умолчанию, и клиент запрашивает фото
|
||||||
|
- **THEN** URL объекта указывает на тот же источник, что и страница, и превью загружается без дополнительной настройки
|
||||||
|
|
||||||
|
#### Scenario: Обратный прокси
|
||||||
|
- **WHEN** приложение доступно за обратным прокси
|
||||||
|
- **THEN** URL объектов сохраняют тот же источник, что и страница приложения
|
||||||
|
|
||||||
|
#### Scenario: Подпись сохраняется
|
||||||
|
- **WHEN** клиент получает относительный URL объекта
|
||||||
|
- **THEN** подпись, срок действия и кэширование объекта работают как прежде
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Make object URLs origin-relative
|
||||||
|
|
||||||
|
- [x] 1.1 In `backend/internal/storage/signer.go`, return a path-only URL (`/api/objects/<key>?e&s`) and drop the `baseURL` field/parameter from `Signer`/`NewSigner`; verify with `go test ./internal/storage/...`.
|
||||||
|
- [x] 1.2 In `backend/internal/storage/storage.go`, stop passing `cfg.Server.BaseURL` to `NewSigner`; verify with `go build ./...`.
|
||||||
|
|
||||||
|
## 2. Remove the base-URL configuration
|
||||||
|
|
||||||
|
- [x] 2.1 Remove `Server.BaseURL` from `backend/internal/config/config.go` and delete the `APP_BASE_URL` line from `.env.example` and the README table; verify `go build ./...` and that no `APP_BASE_URL`/`Server.BaseURL` references remain.
|
||||||
|
- [x] 2.2 Update every other `storage.NewSigner(...)` call site (tests/helpers in `internal/storage`, `internal/photos`, `internal/app`, `internal/e2e`) to drop the base argument; verify with `make test`.
|
||||||
|
|
||||||
|
## 3. Integration verification
|
||||||
|
|
||||||
|
- [x] 3.1 Run `make test` (backend) and `npm run build` (frontend); verify both pass and that an issued object URL has no scheme or host.
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Run `/opsx-apply` to implement this change.
|
||||||
|
- Archive the change after review and verification (sync the delta spec into `storage-backends`).
|
||||||
Reference in New Issue
Block a user