diff --git a/.env.example b/.env.example index 381284f..d50e257 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,6 @@ JWT_REFRESH_TTL=720h # Server HTTP_ADDR=:8080 -APP_BASE_URL=http://localhost:8080 # SSE SSE_HEARTBEAT_INTERVAL=25s diff --git a/README.md b/README.md index 603b8ff..6908c62 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ cp .env.example .env | Переменная | Значение по умолчанию | Описание | | --- | --- | --- | | `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера | -| `APP_BASE_URL` | `http://localhost:8080` | Внешний базовый URL приложения (для ссылок и SSE) | | **База данных (метаданные)** | | | | `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` | | `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) | diff --git a/backend/internal/app/health_test.go b/backend/internal/app/health_test.go index b6b733f..04ff7f5 100644 --- a/backend/internal/app/health_test.go +++ b/backend/internal/app/health_test.go @@ -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) } diff --git a/backend/internal/app/objects_test.go b/backend/internal/app/objects_test.go index a0fee96..566125e 100644 --- a/backend/internal/app/objects_test.go +++ b/backend/internal/app/objects_test.go @@ -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) } diff --git a/backend/internal/app/share_flow_test.go b/backend/internal/app/share_flow_test.go index ef7592b..f8cb0b8 100644 --- a/backend/internal/app/share_flow_test.go +++ b/backend/internal/app/share_flow_test.go @@ -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) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index af8d90a..bd868fe 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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, diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go index 95bf5ff..feed2f8 100644 --- a/backend/internal/e2e/sqlite_test.go +++ b/backend/internal/e2e/sqlite_test.go @@ -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) } diff --git a/backend/internal/photos/helpers_test.go b/backend/internal/photos/helpers_test.go index 6209e53..bbfb646 100644 --- a/backend/internal/photos/helpers_test.go +++ b/backend/internal/photos/helpers_test.go @@ -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) } diff --git a/backend/internal/storage/fs_test.go b/backend/internal/storage/fs_test.go index b75777b..0cb9af4 100644 --- a/backend/internal/storage/fs_test.go +++ b/backend/internal/storage/fs_test.go @@ -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) } diff --git a/backend/internal/storage/s3_test.go b/backend/internal/storage/s3_test.go index 0b3ec65..36fa360 100644 --- a/backend/internal/storage/s3_test.go +++ b/backend/internal/storage/s3_test.go @@ -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) } diff --git a/backend/internal/storage/signer.go b/backend/internal/storage/signer.go index 297c024..4ec4524 100644 --- a/backend/internal/storage/signer.go +++ b/backend/internal/storage/signer.go @@ -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 diff --git a/backend/internal/storage/signer_test.go b/backend/internal/storage/signer_test.go index 5b432df..c89abe2 100644 --- a/backend/internal/storage/signer_test.go +++ b/backend/internal/storage/signer_test.go @@ -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) + } +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index f4277e3..ffa3fdf 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -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 } diff --git a/openspec/changes/host-agnostic-object-urls/.openspec.yaml b/openspec/changes/host-agnostic-object-urls/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/host-agnostic-object-urls/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/host-agnostic-object-urls/design.md b/openspec/changes/host-agnostic-object-urls/design.md new file mode 100644 index 0000000..b37c4f0 --- /dev/null +++ b/openspec/changes/host-agnostic-object-urls/design.md @@ -0,0 +1,69 @@ +# Design + +## Context + +See `proposal.md` - Why. Relevant current state: + +- `storage.Signer.URL` builds `baseURL + "/api/objects/?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/?e=&s="`. 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. diff --git a/openspec/changes/host-agnostic-object-urls/proposal.md b/openspec/changes/host-agnostic-object-urls/proposal.md new file mode 100644 index 0000000..008193b --- /dev/null +++ b/openspec/changes/host-agnostic-object-urls/proposal.md @@ -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/?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 + + + +### 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. diff --git a/openspec/changes/host-agnostic-object-urls/specs/storage-backends/spec.md b/openspec/changes/host-agnostic-object-urls/specs/storage-backends/spec.md new file mode 100644 index 0000000..5dcd0a2 --- /dev/null +++ b/openspec/changes/host-agnostic-object-urls/specs/storage-backends/spec.md @@ -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** подпись, срок действия и кэширование объекта работают как прежде diff --git a/openspec/changes/host-agnostic-object-urls/tasks.md b/openspec/changes/host-agnostic-object-urls/tasks.md new file mode 100644 index 0000000..d9c0b7d --- /dev/null +++ b/openspec/changes/host-agnostic-object-urls/tasks.md @@ -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/?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`).