// Package encurl provides encrypted URL generation and parsing for pixa. package encurl import ( "errors" "time" "github.com/fxamacker/cbor/v2" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/seal" ) // Default values for optional fields. const ( DefaultQuality = 85 DefaultFormat = imgcache.FormatOriginal DefaultFitMode = imgcache.FitCover // HKDF salt for URL encryption key derivation urlKeySalt = "pixa-urlenc-v1" ) // Errors returned by encurl operations. var ( ErrExpired = errors.New("encrypted URL has expired") ErrInvalidFormat = errors.New("invalid encrypted URL format") ErrDecryptFailed = errors.New("failed to decrypt URL") ) // Payload contains all image request parameters in a compact format. // Field names are short to minimize encoded size. // Fields with omitempty use sane defaults when absent. type Payload struct { SourceHost string `cbor:"h"` // required SourcePath string `cbor:"p"` // required SourceQuery string `cbor:"q,omitempty"` // optional Width int `cbor:"w,omitempty"` // 0 = original Height int `cbor:"ht,omitempty"` // 0 = original Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig Quality int `cbor:"ql,omitempty"` // default: 85 FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover ExpiresAt int64 `cbor:"e"` // required, Unix timestamp } // Generator creates and parses encrypted URL tokens. type Generator struct { key [seal.KeySize]byte } // NewGenerator creates an encrypted URL generator with a key derived from the signing key. func NewGenerator(signingKey string) (*Generator, error) { key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt) if err != nil { return nil, err } return &Generator{key: key}, nil } // Generate creates an encrypted URL token from the payload. // The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded. func (g *Generator) Generate(p *Payload) (string, error) { // CBOR encode the payload data, err := cbor.Marshal(p) if err != nil { return "", err } // Encrypt and base64url encode return seal.Encrypt(g.key, data) } // Parse decrypts and validates an encrypted URL token. // Returns ErrExpired if the token has expired, or other errors for invalid tokens. func (g *Generator) Parse(token string) (*Payload, error) { // Decrypt data, err := seal.Decrypt(g.key, token) if err != nil { if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) { return nil, ErrDecryptFailed } return nil, err } // CBOR decode var p Payload if err := cbor.Unmarshal(data, &p); err != nil { return nil, ErrInvalidFormat } // Check expiration if time.Now().Unix() > p.ExpiresAt { return nil, ErrExpired } return &p, nil } // ToImageRequest converts the payload to an ImageRequest. // Applies default values for omitted optional fields. func (p *Payload) ToImageRequest() *imgcache.ImageRequest { format := p.Format if format == "" { format = DefaultFormat } quality := p.Quality if quality == 0 { quality = DefaultQuality } fitMode := p.FitMode if fitMode == "" { fitMode = DefaultFitMode } return &imgcache.ImageRequest{ SourceHost: p.SourceHost, SourcePath: p.SourcePath, SourceQuery: p.SourceQuery, Size: imgcache.Size{ Width: p.Width, Height: p.Height, }, Format: format, Quality: quality, FitMode: fitMode, } } // FromImageRequest creates a Payload from an ImageRequest with the given expiration. func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload { p := &Payload{ SourceHost: req.SourceHost, SourcePath: req.SourcePath, SourceQuery: req.SourceQuery, Width: req.Size.Width, Height: req.Size.Height, ExpiresAt: expiresAt.Unix(), } // Only set non-default values to benefit from omitempty if req.Format != "" && req.Format != DefaultFormat { p.Format = req.Format } if req.Quality != 0 && req.Quality != DefaultQuality { p.Quality = req.Quality } if req.FitMode != "" && req.FitMode != DefaultFitMode { p.FitMode = req.FitMode } return p }