fix(photos): faster large-album loading and album cover lifecycle
Large albums opened slowly and flickered; the default album cover was also lost in the media-objects refactor. Large-album loading: - quantize signed object URLs within a window and send Cache-Control so previews are reused instead of re-fetched - publish photo.updated (not photo.created) when variants are ready, so clients update in place without reordering the grid - worker concurrency + newest-first variant priority; raise default batch Album cover: - first uploaded photo becomes the cover when none is set - clear the cover when the cover photo is trashed or moved out - "set as album cover" in the photo context menu and actions panel OpenSpec changes: fix-large-album-load, fix-album-cover
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func newTestFS(t *testing.T) *FS {
|
||||
t.Helper()
|
||||
signer, err := NewSigner("http://app.test", "test-secret", time.Hour)
|
||||
signer, err := NewSigner("http://app.test", "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)
|
||||
signer, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -20,33 +20,59 @@ type Signer struct {
|
||||
secret []byte
|
||||
baseURL string
|
||||
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).
|
||||
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) {
|
||||
// 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) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, errors.New("url secret is required")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 10 * time.Minute
|
||||
}
|
||||
if window > ttl {
|
||||
window = ttl
|
||||
}
|
||||
return &Signer{
|
||||
secret: []byte(secret),
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
ttl: ttl,
|
||||
window: window,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// URL returns a signed app URL for the given opaque key.
|
||||
// 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.
|
||||
func (s *Signer) URL(key string) string {
|
||||
exp := time.Now().Add(s.ttl).Unix()
|
||||
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())
|
||||
}
|
||||
|
||||
// expiry is the quantized Unix expiry shared by all URLs issued in the current
|
||||
// window: the window start plus the TTL. It is never earlier than now.
|
||||
func (s *Signer) expiry() int64 {
|
||||
now := s.now()
|
||||
exp := now.Truncate(s.window).Add(s.ttl)
|
||||
if exp.Before(now) {
|
||||
exp = now.Add(s.ttl)
|
||||
}
|
||||
return exp.Unix()
|
||||
}
|
||||
|
||||
// VerifyRequest checks the signature and expiry carried by an object request.
|
||||
// It returns ErrSignedURLInvalid when the request is not authorized.
|
||||
func (s *Signer) VerifyRequest(key string, r *http.Request) error {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func expiryOf(t *testing.T, raw string) int64 {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
e, err := strconv.ParseInt(u.Query().Get("e"), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expiry: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestSignerURLStableWithinWindow(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
first := s.URL("objects/a/thumb.webp")
|
||||
second := s.URL("objects/a/thumb.webp")
|
||||
if first != second {
|
||||
t.Fatalf("URL changed within window:\n %q\n %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerURLChangesAcrossWindow(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
base := time.Now()
|
||||
s.now = func() time.Time { return base }
|
||||
first := expiryOf(t, s.URL("objects/a/thumb.webp"))
|
||||
s.now = func() time.Time { return base.Add(s.window) }
|
||||
second := expiryOf(t, s.URL("objects/a/thumb.webp"))
|
||||
if first == second {
|
||||
t.Fatalf("expiry did not advance across window: %d", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerURLVerifies(t *testing.T) {
|
||||
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
key := "objects/a/thumb.webp"
|
||||
raw := s.URL(key)
|
||||
req := httptest.NewRequest(http.MethodGet, raw, nil)
|
||||
if err := s.VerifyRequest(key, req); err != nil {
|
||||
t.Fatalf("verify issued URL: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,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)
|
||||
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL, cfg.Storage.URLSignWindow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user