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
107 lines
2.9 KiB
Go
107 lines
2.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Signer produces and verifies the signed app URLs used by the /api/objects
|
|
// 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
|
|
}
|
|
|
|
// 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) {
|
|
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. 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())
|
|
}
|
|
|
|
// 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 {
|
|
q := r.URL.Query()
|
|
exp, err := strconv.ParseInt(q.Get("e"), 10, 64)
|
|
if err != nil || !s.verify(key, exp, q.Get("s")) {
|
|
return ErrSignedURLInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Signer) sign(key string, exp int64) string {
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
_, _ = fmt.Fprintf(mac, "%s\n%d", key, exp)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (s *Signer) verify(key string, exp int64, sig string) bool {
|
|
if sig == "" || time.Now().Unix() > exp {
|
|
return false
|
|
}
|
|
return hmac.Equal([]byte(s.sign(key, exp)), []byte(sig))
|
|
}
|
|
|
|
func escapeKey(key string) string {
|
|
parts := strings.Split(key, "/")
|
|
for i, p := range parts {
|
|
parts[i] = url.PathEscape(p)
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|