Compare commits
3
Commits
14bde63d0d
...
a0c6412587
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c6412587 | ||
|
|
7fb3569029 | ||
|
|
0f3700f7f5 |
@@ -125,8 +125,9 @@ Where:
|
|||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — Unix timestamp when signature expires
|
- `expiration` — Unix timestamp when signature expires
|
||||||
- `quality` — the URL's `q` query parameter (1-100), or `85` when the URL
|
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
|
||||||
has no `q`
|
or `85` when the URL has no `q`; a request whose `q` is anything else is
|
||||||
|
refused with 400
|
||||||
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
||||||
outside), or `cover` when the URL has no `fit`
|
outside), or `cover` when the URL has no `fit`
|
||||||
|
|
||||||
@@ -161,7 +162,11 @@ process was started with and the file's own key. A variable's value is
|
|||||||
parsed as the same text in the file would be. The three lists take
|
parsed as the same text in the file would be. The three lists take
|
||||||
comma-separated entries, with the spaces around each trimmed; an empty
|
comma-separated entries, with the spaces around each trimmed; an empty
|
||||||
variable is an empty list. A value that does not parse or is invalid aborts
|
variable is an empty list. A value that does not parse or is invalid aborts
|
||||||
startup, naming the variable.
|
startup, naming the variable. A variable whose name starts with `PIXA_` but
|
||||||
|
is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
|
||||||
|
startup naming it, as an unknown config key does. The one other accepted
|
||||||
|
name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
|
||||||
|
variables set by the file's `env:` section are checked the same way.
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
|
||||||
|
that is not a whole number from 1 to 100 is a 400 naming `q` and the
|
||||||
|
value, instead of being served at the default 85; the route reads `q`
|
||||||
|
with the generator's quality check (`parseFormInt` with `minQuality`
|
||||||
|
and `maxQuality`); an absent or empty `q` is still 85; `README.md`
|
||||||
|
states the range.
|
||||||
|
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
|
||||||
|
#133): a variable whose name starts with `PIXA_` but is neither a
|
||||||
|
setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
|
||||||
|
an unknown config key does, and `PIXA_PORT` is named with a pointer to
|
||||||
|
`PORT`; the check runs after the config file loads, so the variables
|
||||||
|
the file's `env:` section sets are checked too; documented in
|
||||||
|
`README.md`.
|
||||||
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
|
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
|
||||||
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
|
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
|
||||||
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
|
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ var (
|
|||||||
errValueRequired = errors.New("a value is required")
|
errValueRequired = errors.New("a value is required")
|
||||||
errValueEmpty = errors.New("value must not be empty")
|
errValueEmpty = errors.New("value must not be empty")
|
||||||
errUnknownConfigKeys = errors.New("unknown config keys")
|
errUnknownConfigKeys = errors.New("unknown config keys")
|
||||||
|
errUnknownEnvVars = errors.New("unknown environment variables")
|
||||||
errNotAString = errors.New("not a string")
|
errNotAString = errors.New("not a string")
|
||||||
errNotAnInteger = errors.New("not an integer")
|
errNotAnInteger = errors.New("not an integer")
|
||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
@@ -154,6 +155,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading the config file sets the variables in its env section,
|
||||||
|
// so this also checks their names.
|
||||||
|
err = validateKnownEnvVars()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
log.Info("no config file found, using environment variables and defaults")
|
log.Info("no config file found, using environment variables and defaults")
|
||||||
}
|
}
|
||||||
@@ -377,6 +385,43 @@ func envVarNames() map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateKnownEnvVars rejects environment variables whose names start
|
||||||
|
// with PIXA_ but that are neither a setting's variable nor
|
||||||
|
// PIXA_CONFIG_PATH, so a misspelled variable fails at startup instead of
|
||||||
|
// being silently ignored, as validateKnownKeys does for config file keys.
|
||||||
|
// New calls it after loading the config file, so the variables the
|
||||||
|
// file's env section sets are checked too.
|
||||||
|
func validateKnownEnvVars() error {
|
||||||
|
known := map[string]bool{"PIXA_CONFIG_PATH": true}
|
||||||
|
|
||||||
|
for _, name := range envVarNames() {
|
||||||
|
known[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var unknown []string
|
||||||
|
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !strings.HasPrefix(name, "PIXA_") || known[name]:
|
||||||
|
continue
|
||||||
|
case name == "PIXA_PORT":
|
||||||
|
unknown = append(unknown, name+" (use PORT for the port)")
|
||||||
|
default:
|
||||||
|
unknown = append(unknown, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unknown) > 0 {
|
||||||
|
sort.Strings(unknown)
|
||||||
|
|
||||||
|
return fmt.Errorf("%w: %s", errUnknownEnvVars, strings.Join(unknown, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// lookupValue returns the value set for key and whether one is set. The
|
// lookupValue returns the value set for key and whether one is set. The
|
||||||
// key's environment variable wins when it is present, even when empty;
|
// key's environment variable wins when it is present, even when empty;
|
||||||
// its value is a string, read exactly as the same text quoted in the
|
// its value is a string, read exactly as the same text quoted in the
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package config
|
|||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain unsets PORT and every PIXA_ environment variable before the
|
// TestMain unsets PORT and every PIXA_ environment variable before the
|
||||||
@@ -95,6 +99,114 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that
|
||||||
|
// is not a setting's variable, such as a misspelled one, aborts startup
|
||||||
|
// naming it, as an unknown config key does, instead of being ignored.
|
||||||
|
func TestUnknownPixaVariableAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey)
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts
|
||||||
|
// startup with a message saying to use PORT, which sets the port.
|
||||||
|
func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_PORT", "9090")
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_PORT", "use PORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||||
|
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||||
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||||
|
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||||
|
// A config file's env section loaded by another test can leave a
|
||||||
|
// PIXA_ variable set for the whole process, so every one is unset
|
||||||
|
// here first; t.Setenv restores each when the test ends.
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if !strings.HasPrefix(name, "PIXA_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(name, "")
|
||||||
|
|
||||||
|
err := os.Unsetenv(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||||
|
|
||||||
|
for _, name := range envVarNames() {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+
|
||||||
|
"must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromNew writes yamlContent to a temporary config file, points
|
||||||
|
// PIXA_CONFIG_PATH at it, and runs New, as the server does at startup.
|
||||||
|
// The state directory is a temporary one and the disk cache is off, so
|
||||||
|
// New succeeds unless something in the test is wrong.
|
||||||
|
func configFromNew(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", configPath)
|
||||||
|
t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state"))
|
||||||
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "0")
|
||||||
|
|
||||||
|
testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(nil, Params{Logger: testLogger})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsNew checks that New, which the server
|
||||||
|
// calls at startup, aborts on a misspelled PIXA_ variable.
|
||||||
|
func TestUnknownPixaVariableAbortsNew(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
|
||||||
|
_, err := configFromNew(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts
|
||||||
|
// on a misspelled PIXA_ name in the config file's env section, which
|
||||||
|
// loading the file sets as an environment variable.
|
||||||
|
func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) {
|
||||||
|
// The variable must be absent until the file loads. t.Setenv makes
|
||||||
|
// sure the one the file sets is removed when the test ends.
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "")
|
||||||
|
|
||||||
|
err := os.Unsetenv("PIXA_TRUSTED_PROXY")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = configFromNew(t, signingKeyLine+
|
||||||
|
"env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n")
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
||||||
// the port in the config file.
|
// the port in the config file.
|
||||||
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import (
|
|||||||
// response can name it.
|
// response can name it.
|
||||||
var errInvalidFormField = errors.New("invalid")
|
var errInvalidFormField = errors.New("invalid")
|
||||||
|
|
||||||
// Bounds for the generator's quality and ttl fields. maxTTL is in seconds:
|
// Bounds for the generator's quality and ttl fields; the quality bounds also
|
||||||
// the expiry calculation time.Duration(ttl) * time.Second overflows above it.
|
// apply to the q parameter of /v1/image/. maxTTL is in seconds: the expiry
|
||||||
|
// calculation time.Duration(ttl) * time.Second overflows above it.
|
||||||
const (
|
const (
|
||||||
minQuality = 1
|
minQuality = 1
|
||||||
maxQuality = 100
|
maxQuality = 100
|
||||||
@@ -248,9 +249,9 @@ func parseFormDimension(form url.Values, field string) (int, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormInt reads an optional integer form field, returning def when the
|
// parseFormInt reads an optional integer form field or URL query parameter,
|
||||||
// field is empty and an error naming the field when the value is non-numeric
|
// returning def when the field is empty and an error naming the field when the
|
||||||
// or outside minValue to maxValue.
|
// value is non-numeric or outside minValue to maxValue.
|
||||||
func parseFormInt(
|
func parseFormInt(
|
||||||
form url.Values, field string, def, minValue, maxValue int,
|
form url.Values, field string, def, minValue, maxValue int,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -291,3 +292,54 @@ func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidQuality_Returns400 verifies that the plain image
|
||||||
|
// route answers a q that is not a whole number from 1 to 100 with 400 naming
|
||||||
|
// q and the value, instead of serving the image at the default quality 85.
|
||||||
|
func TestHandleImage_InvalidQuality_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
q, wantError string
|
||||||
|
}{
|
||||||
|
{"banana", `invalid q: not a number, got "banana"`},
|
||||||
|
{"0", `invalid q: must be from 1 to 100, got "0"`},
|
||||||
|
{"101", `invalid q: must be from 1 to 100, got "101"`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("q="+tt.q, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?q="+tt.q, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Error != tt.wantError {
|
||||||
|
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-11
@@ -2,12 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
@@ -100,23 +102,22 @@ func (s *Handlers) parseImageRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional quality and fit params
|
// Parse optional quality and fit params. An absent q is 85; a q that is
|
||||||
if qStr := query.Get("q"); qStr != "" {
|
// not a whole number from 1 to 100 is refused, checked as the generator
|
||||||
q, parseErr := strconv.Atoi(qStr)
|
// checks its quality field.
|
||||||
if parseErr == nil && q > 0 && q <= 100 {
|
req.Quality, err = parseFormInt(query, "q",
|
||||||
req.Quality = q
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
}
|
if err != nil {
|
||||||
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, query.Get("q")),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
if fit := query.Get("fit"); fit != "" {
|
||||||
req.FitMode = imgcache.FitMode(fit)
|
req.FitMode = imgcache.FitMode(fit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default quality if not set
|
|
||||||
if req.Quality == 0 {
|
|
||||||
req.Quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
if req.FitMode == "" {
|
if req.FitMode == "" {
|
||||||
req.FitMode = imgcache.FitCover
|
req.FitMode = imgcache.FitCover
|
||||||
|
|||||||
Reference in New Issue
Block a user