Skip to content

xtandard-image for Go

Typed URL builder, signing and HTTP client for the xtandard-image server.

pkg.go.dev: pending license: MIT

github.com/xantiagoma/xtandard-image/clients/go is the Go client for xtandard-image, an open-source media server that covers your application’s image and video needs, built on libvips and FFmpeg. The module builds and signs the URLs your code sends to the server, and wraps its /info, media and health endpoints; it does not process images itself. To run a server, see Getting started, or start one with signing keys:

Terminal window
export XI_SIGNING_KEY="$(openssl rand -hex 32)" XI_SIGNING_SALT="$(openssl rand -hex 32)"
docker run -d --name xtandard-image -p 8080:8080 \
-e XI_SOURCES=web -e XI_SOURCES_WEB_TYPE=http -e XI_SOURCES_WEB_ALLOW=images.unsplash.com \
-e XI_SIGNING_KEYS="$XI_SIGNING_KEY" -e XI_SIGNING_SALTS="$XI_SIGNING_SALT" \
ghcr.io/xantiagoma/xtandard-image:0.1.1
Terminal window
go get github.com/xantiagoma/xtandard-image/clients/go

Not published yet: publishing to pkg.go.dev is being set up. Until then, install from the repository:

Terminal window
git clone --depth 1 https://github.com/xantiagoma/xtandard-image
go mod edit -replace=github.com/xantiagoma/xtandard-image/clients/go=./xtandard-image/clients/go
go mod tidy

The root package is named xtandard. Releases will be tagged clients/go/vX.Y.Z, at the server’s version.

Build a signed URL with the keys the server was started with:

package main
import (
"fmt"
"log"
"os"
xtandard "github.com/xantiagoma/xtandard-image/clients/go"
"github.com/xantiagoma/xtandard-image/clients/go/imgproxy"
)
func main() {
key, err := xtandard.ParseSigningKey(os.Getenv("XI_SIGNING_KEY"), os.Getenv("XI_SIGNING_SALT"), xtandard.DefaultSignatureSize)
if err != nil {
log.Fatal(err)
}
img, err := imgproxy.New(imgproxy.Config{BaseURL: "http://localhost:8080", Signing: &key})
if err != nil {
log.Fatal(err)
}
url, err := img.Image("https://images.unsplash.com/photo-1506744038136-46273834b3fb").
Resize("fill", 1200, 630).Gravity("sm").Extension("jpg").Build()
if err != nil {
log.Fatal(err)
}
fmt.Println(url) // http://localhost:8080/<signature>/rs:fill:1200:630/g:sm/aHR0cHM6….jpg
}

Ask the server about the source with the HTTP client:

xi, err := client.New(client.Config{URLs: imgproxy.Config{BaseURL: "http://localhost:8080", Signing: &key}})
if err != nil {
log.Fatal(err)
}
in, err := xi.Info(context.Background(), "https://images.unsplash.com/photo-1506744038136-46273834b3fb", imgproxy.InfoParams{})
if err != nil {
log.Fatal(err)
}
fmt.Println(*in.Format, *in.Width, *in.Height) // jpeg 7360 4912

Sign URLs on a server only: the signing key signs any URL, and the encryption key decrypts every enc/ source.

The module has the root package xtandard (keys, signing, encryption, sources, errors, response types), the builders imgproxy and query, the typed options processing, info and video, and client. Builders and clients are safe for concurrent use; a fluent chain builds one URL.

There are two equivalent forms, a params struct and a fluent chain with one method per option name and alias:

url, err := img.URL("s3://photos/cat.jpg", imgproxy.Params{
Options: []xtandard.ProcessingOption{
processing.Resize{Type: processing.ResizeTypeFill, Width: new(300), Height: new(400)},
processing.GravitySm{},
xtandard.Opt("q", 80),
},
Extension: "webp",
})
url, err = img.Image("s3://photos/cat.jpg").Resize("fill", 300, 400).G("sm").Quality(80).Extension("webp").Build()
// both: http://localhost:8080/<signature>/rs:fill:300:400/g:sm/q:80/czM6Ly9waG90b3MvY2F0LmpwZw.webp

The packages processing, info and video have one generated struct per option form, so a processing option cannot be passed where a /video option is expected; xtandard.Opt(name, args...) is the untyped form, checked at run time. A nil pointer or "" writes an empty argument. The options themselves are documented in URL dialects §3.2.

imgproxy.Config also takes Separator (default :), UnsignedSignature (insecure, unsafe or _) and OptionNames (short names by default, canonical or as given).

Pipelines in the params form, or Pipeline() in the fluent form, writes /-/ between pipelines:

url, err := img.URL(src, imgproxy.Params{
Options: []xtandard.ProcessingOption{xtandard.Opt("rs", "fit", 500, 500)},
Pipelines: [][]xtandard.ProcessingOption{{xtandard.Opt("bl", 2)}},
})
url, err = img.Image(src).Rs("fit", 500, 500).Pipeline().Bl(2).Build()
SourceEncoding URL
xtandard.SourceEncodingBase64 (default) /…/czM6Ly9waG90b3MvY2F0LmpwZw.webp; SplitSource splits it into segments
xtandard.SourceEncodingPlain /…/plain/https://example.com/a%20b.jpg@webp, escaped for you
xtandard.SourceEncodingEncrypted /…/enc/<token>.webp; needs Encryption (below)

Set it on the Config, per URL (Params.SourceEncoding) or per chain (.Encoding(…)). xtandard.XiSource("photos", "2026/cat.jpg") writes a named source (xi://photos/2026/cat.jpg) and xtandard.SourceURL("s3", "bucket", "cat.jpg") a storage URL; both return an error for an invalid name.

url, err := img.InfoURL(src, imgproxy.InfoParams{Options: []xtandard.InfoOption{info.Size{Size: new(true)}}})
url, err = img.Info(src).Size(true).Format(true).Build()

/video URLs use the video option catalogue; the extension is the container (mp4, webm, mp3, png for a waveform, …). StreamURL builds HLS and DASH manifests, whose segments a player fetches without any key:

url, err := img.Video("s3://media/talk.mov").Resize("fill", 640, 360).Start(12).Duration(5).Extension("webm").Build()
url, err = img.StreamURL("s3://media/talk.mov", imgproxy.StreamParams{
Protocol: xtandard.StreamProtocolHLS, Renditions: []int{360, 720}, SegmentDuration: 4,
})

The server needs the full or gpl image for video. Options and containers: URL dialects §3.8 and §3.10.

Package query builds /q/ URLs (native keys plus imgproxy options in o=), /q/video and imaginary endpoints, signed with xi_sig. The server needs XI_DIALECTS_QUERY_ENABLED=true.

q, err := query.New(query.Config{BaseURL: "https://img.example.com", Signing: &key})
url, err := q.URL("s3://photos/cat.jpg", query.Params{
Params: []query.Param{query.P(xtandard.QueryKeyW, 300), query.P(xtandard.QueryKeyFit, "cover")},
Options: []xtandard.ProcessingOption{xtandard.Opt("bl", 2)},
Extension: "webp",
})
// https://img.example.com/q/?url=s3://photos/cat.jpg&w=300&fit=cover&o=bl:2&format=webp&xi_sig=…
url, err = q.ImaginaryURL(xtandard.ImaginaryEndpointSmartcrop, "s3://photos/cat.jpg",
query.P(xtandard.ImaginaryParamWidth, 300), query.P(xtandard.ImaginaryParamHeight, 300))

query.MethodRequest(url) turns a query URL into an HTTP QUERY request, and client.Config{QueryMethod: true} sends one. See URL dialects §4.

xtandard.ParseSigningKey takes the server’s hex key and salt (XI_SIGNING_KEYS / XI_SIGNING_SALTS, or imgproxy’s IMGPROXY_KEY / IMGPROXY_SALT) and the signature size. xtandard.ParseEncryptionKey takes the first XI_ENCRYPTION_KEYS entry, for enc/ sources and password-protected PDFs:

ek, err := xtandard.ParseEncryptionKey(os.Getenv("XI_ENCRYPTION_KEY"), "") // "": a random IV per token
img, err := imgproxy.New(imgproxy.Config{BaseURL: base, Signing: &key, Encryption: &ek})
url, err := img.URL("s3://private/cat.jpg", imgproxy.Params{SourceEncoding: xtandard.SourceEncodingEncrypted})
url, err = img.URL("s3://docs/contract.pdf", imgproxy.Params{
Options: []xtandard.ProcessingOption{xtandard.Opt("rs", "fit", 800, 0)}, PDFPassword: new("s3cret"),
})

Sign, SignImgproxyPath, SignXiURL, SignXiQuery, EncryptSource and EncryptPDFPassword are exported too; SignXiURL adds an xi_sig to a URL of any other dialect. Schemes: URL dialects §2.

client.New wraps net/http (Config.HTTPClient, http.DefaultClient by default) with a context.Context on every call: Healthz, Readyz, Info, Image, Video, Stream, Media and PlaygroundMeta. Media calls return the *http.Response with the body unread; close it:

res, err := xi.Image(ctx, src, imgproxy.Params{Options: opts}, client.Accept("image/avif,image/webp"))
defer res.Body.Close()
part, err := xi.Video(ctx, "s3://media/talk.mov", vp, client.WithRange(xtandard.ByteRange{Start: 0, End: 65535})) // 206

Builders return a *xtandard.BuildError whose code (xtandard.ErrUnknownOption, ErrInvalidArgument, ErrInvalidSource, …) matches with errors.Is. The client returns a *client.HTTPError for a non-2xx answer, with the server’s Status, Kind and RequestID, and a *client.TransportError when no answer came:

_, err := img.Image(src).Option("nope", 1).Build()
errors.Is(err, xtandard.ErrUnknownOption) // true
_, err = xi.Info(ctx, "https://example.com/a.jpg", imgproxy.InfoParams{})
if he, ok := errors.AsType[*client.HTTPError](err); ok {
log.Print(he.Status, he.Kind, he.RequestID) // 403 forbidden …
}

An HTTPError also unwraps to its kind: errors.Is(err, xtandard.ErrorKindNotFound).

  • Go 1.26 or later (the two Go releases supported upstream). Standard library only: no third-party modules and no cgo.
  • The module version is the server version: clients/go/v0.2.1 matches ghcr.io/xantiagoma/xtandard-image:0.2.1. Versions follow ZeroVer: a minor bump may break the API, and a patch does not.

A personal project, provided as is under the MIT licence: see the disclaimer.

Design: every official client is generated from one spec and passes one conformance fixture (ADR 0013).