Files
pixa/internal/encurl/encurl.go
sneak 23506df609
All checks were successful
check / check (push) Successful in 2m3s
chore: update golangci-lint to v2.12.2 with canonical config
Replace .golangci.yml with the canonical v2-schema config
(default: all minus six disabled linters, lll 88, tests included)
and bump every golangci-lint pin to v2.12.2:

- Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned)
- script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new
  linux-amd64/arm64 release-archive sha256 pins

Fix all 747 findings the stricter config surfaces, with no behavior
changes: t.Parallel() throughout the test suite, static sentinel
errors and errors.Is comparisons, checked error returns, context
propagation (contextcheck/noctx), 88-column wrapping, extracted
constants and helpers for goconst/dupl/funlen/cyclop, exhaustive
switch cases replicating existing defaults, and white-box test files
renamed to *_internal_test.go for testpackage. Three
nolint:tagliatelle directives preserve the existing snake_case JSON
wire and on-disk metadata formats.
2026-08-07 17:10:27 +00:00

163 lines
4.2 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.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,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.
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
}