check / check (push) Waiting to run
A /v1/image/ URL whose last segment is a size with no format, such as 800x600 or orig, is served as JPEG XL and signed as jxl, so it shares the signature of the same URL ending in .jxl. An encrypted URL whose token holds no format is served as JPEG XL, as encurl.DefaultFormat is now jxl. The generator page selects JPEG XL by default, and a form with an empty format, or none, makes a URL whose name ends in .jxl. The image processor no longer takes an empty format as orig: both routes give every request a format, so it refuses a request with none instead of keeping a second default. auto still ends with JPEG. Model: opus-5-5
170 lines
4.4 KiB
Go
170 lines
4.4 KiB
Go
// 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.FormatJXL
|
|
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: jxl
|
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
|
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
|
}
|
|
|
|
// 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
|
|
|
|
err = cbor.Unmarshal(data, &p)
|
|
if err != nil {
|
|
return nil, ErrInvalidFormat
|
|
}
|
|
|
|
// Check expiration (0 = never expires)
|
|
if p.ExpiresAt != 0 && 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. An ExpiresAt of 0, a URL
|
|
// that never expires, gives the zero Expires.
|
|
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
|
|
}
|
|
|
|
var expires time.Time
|
|
if p.ExpiresAt != 0 {
|
|
expires = time.Unix(p.ExpiresAt, 0)
|
|
}
|
|
|
|
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,
|
|
Expires: expires,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|