check / check (push) Canceled after 0s
The new package internal/imagerequest holds the request type, its two parts Source and Transform, the Format and Fit types, the dimension and fit checks, the /v1/image/ path parsing and the variant key; it imports no other package of pixa. imgcache.ParsedURL, imgcache.ImageRequest, signature.Request, imageprocessor.Request, the second Size, format and fit types and the conversions between them are gone. The image processor takes a Transform only. encurl keeps a private struct with the token's short CBOR field names. VariantKey moves with the function that makes it. Tests follow the moved and renamed code; the tests of the removed conversions go with them. Model: opus-5-5
157 lines
4.3 KiB
Go
157 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,
|
|
// transform and expiry, each as the request holds it; 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) (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 !req.Expires.IsZero() {
|
|
p.ExpiresAt = req.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. A token
|
|
// that never expires gives the zero Expires.
|
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
|
func (g *Generator) Parse(token string) (*imagerequest.Request, 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
|
|
}
|
|
|
|
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: expires,
|
|
}, nil
|
|
}
|