check / check (push) Waiting to run
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
158 lines
4.3 KiB
Go
158 lines
4.3 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/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
)
|
|
|
|
// Default values for optional fields.
|
|
const (
|
|
DefaultQuality = 85
|
|
DefaultFormat = imagerequest.FormatJXL
|
|
DefaultFitMode = imagerequest.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 imagerequest.Format `cbor:"f,omitempty"` // default: jxl
|
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
|
FitMode imagerequest.Fit `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 request's source and
|
|
// transform, each as the request holds it, and from expires; an empty format,
|
|
// a quality of 0 or an empty fit mode is left out of the token, and a zero
|
|
// expires makes a token that never expires. The token is CBOR-encoded,
|
|
// encrypted with NaCl secretbox, and base64url-encoded.
|
|
func (g *Generator) Generate(
|
|
req *imagerequest.Request, expires time.Time,
|
|
) (string, error) {
|
|
p := &payload{
|
|
SourceHost: req.Source.Host,
|
|
SourcePath: req.Source.Path,
|
|
SourceQuery: req.Source.Query,
|
|
Width: req.Transform.Size.Width,
|
|
Height: req.Transform.Size.Height,
|
|
Format: req.Transform.Format,
|
|
Quality: req.Transform.Quality,
|
|
FitMode: req.Transform.FitMode,
|
|
}
|
|
|
|
if !expires.IsZero() {
|
|
p.ExpiresAt = expires.Unix()
|
|
}
|
|
|
|
// 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 and returns the
|
|
// request it holds, with default values for omitted optional fields, and its
|
|
// expiry. A token that never expires gives the zero time.
|
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
|
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, 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, time.Time{}, ErrDecryptFailed
|
|
}
|
|
|
|
return nil, time.Time{}, err
|
|
}
|
|
|
|
// CBOR decode
|
|
var p payload
|
|
|
|
err = cbor.Unmarshal(data, &p)
|
|
if err != nil {
|
|
return nil, time.Time{}, ErrInvalidFormat
|
|
}
|
|
|
|
// Check expiration (0 = never expires)
|
|
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
|
|
return nil, time.Time{}, ErrExpired
|
|
}
|
|
|
|
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 &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: p.SourceHost,
|
|
Path: p.SourcePath,
|
|
Query: p.SourceQuery,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
|
|
Format: format,
|
|
Quality: quality,
|
|
FitMode: fitMode,
|
|
},
|
|
}, expires, nil
|
|
}
|