1 Commits
Author SHA1 Message Date
clawbot 2c5af66094 Document every route, encrypted URLs and the config file search (closes #75)
check / check (push) Failing after 7m49s
README.md "Routes" lists every route pixa registers with its method,
purpose, what it needs and the status codes it answers with, read from
the handlers, and says q and fit are part of what is cached. A new
"Encrypted URLs" section covers logging in with the signing key, making
a URL on the generator page, how long it lasts, and the 410 once it has
expired. "Configuration" gives the order in which pixa looks for its
config file. config.example.yml now lists db_url and env and gives every
key's default. scripts/manual-test.sh is left to #97.

Model: opus-5-5
2026-10-04 05:48:46 +00:00
5 changed files with 54 additions and 32 deletions
+10 -19
View File
@@ -140,16 +140,8 @@ path under `/v1/` answers 200, in maintenance mode too.
Both `POST` routes accept only a form that pixa's own page served: the page puts Both `POST` routes accept only a form that pixa's own page served: the page puts
a token in the form and sets a cookie to match, and a request without both is a token in the form and sets a cookie to match, and a request without both is
refused with 403, so another site cannot submit the form from a visitor's refused with 403, so another site cannot submit the form from a visitor's
browser. The login and generator pages are meant to be opened over HTTPS: while browser. A form body over 1 MiB is refused with 413. The image routes answer the
`debug` is off, a form sent from a page opened over plain HTTP is refused with errors listed for them with JSON holding `error`, `status` and `timestamp`.
403, and while it is on, so is one sent from a page opened over HTTPS. Plain
HTTP is for development on the browser's own machine: the login session cookie
is always marked `Secure`, and over plain HTTP a browser keeps such a cookie only
for its own machine (`localhost`), if at all. A form is also refused with 403
when the page's host is not the `Host` header pixa receives, so a reverse proxy
in front of pixa must pass that header on unchanged. A form body over 1 MiB is
refused with 413. The image routes answer the errors listed for them with JSON
holding `error`, `status` and `timestamp`.
An image URL has this form: An image URL has this form:
@@ -212,9 +204,8 @@ knows the signing key. It works for any upstream host, allowlisted or not,
without a signature, and whoever gets it can neither read the source URL from it without a signature, and whoever gets it can neither read the source URL from it
nor change what it asks for. nor change what it asks for.
1. Open `/` in a browser over HTTPS (or over plain HTTP while `debug` is on, see 1. Open `/` in a browser and log in with the signing key (`signing_key`). The
Routes) and log in with the signing key (`signing_key`). The login session login session lasts 30 days, or until `/logout`.
lasts 30 days, or until `/logout`.
2. On the generator page, give the source image's URL, the width and height, the 2. On the generator page, give the source image's URL, the width and height, the
format, quality and fit, and how long the URL lasts, then submit the form format, quality and fit, and how long the URL lasts, then submit the form
(`POST /generate`). Width and height both empty or `0` keep the original (`POST /generate`). Width and height both empty or `0` keep the original
@@ -429,12 +420,12 @@ Key settings in more detail:
seconds for one to free up; if none does, and `downstream_timeout` has not seconds for one to free up; if none does, and `downstream_timeout` has not
ended first, it is answered 503 the same way ended first, it is answered 503 the same way
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and - `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header `/v1/e/`) answer every request with 503, a `Retry-After` header and a JSON
and a JSON error body. The health check (`/.well-known/healthcheck.json`) error body. The health check (`/.well-known/healthcheck.json`) still answers
still answers 200 and reports `"maintenance_mode": true`. It stays 200 200 and reports `"maintenance_mode": true`. It stays 200 because the image's
because the image's Docker `HEALTHCHECK` requests it: a 503 there would make Docker `HEALTHCHECK` requests it: a 503 there would make the container
the container unhealthy, and upaas marks a deploy failed when its container unhealthy, and upaas marks a deploy failed when its container is unhealthy.
is unhealthy. The login and URL generator pages and `/metrics` keep working The login and URL generator pages and `/metrics` keep working
See `config.example.yml` for all options with defaults. See `config.example.yml` for all options with defaults.
-5
View File
@@ -37,11 +37,6 @@ P2: security: referer blacklist
"Configuration" gives the order in which pixa looks for its config file; "Configuration" gives the order in which pixa looks for its config file;
`config.example.yml` lists `db_url` and `env` and gives every key's default; `config.example.yml` lists `db_url` and `env` and gives every key's default;
`scripts/manual-test.sh` is left to #97. `scripts/manual-test.sh` is left to #97.
- 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`,
which only returned an error and which nothing called, is no longer part of
the `ImageCache` interface or `Service`; the `SignatureValidator`,
`Allowlist` and `Storage` interfaces, which nothing implemented or used, are
deleted. Nothing else changes.
- 2026-10-04 upstream host semaphores and variant `.meta` files no longer - 2026-10-04 upstream host semaphores and variant `.meta` files no longer
outlive their use (closes #87): the fetcher counts the fetches holding or outlive their use (closes #87): the fetcher counts the fetches holding or
waiting for a slot of each upstream host's semaphore and removes the host's waiting for a slot of each upstream host's semaphore and removes the host's
+5 -5
View File
@@ -22,11 +22,11 @@ port: 8080
debug: false debug: false
# While true, the image routes (/v1/image/ and /v1/e/) answer every request # While true, the image routes (/v1/image/ and /v1/e/) answer every request
# for an image with 503 and a Retry-After header. The health check keeps # with 503 and a Retry-After header. The health check keeps answering 200 and
# answering 200 and reports maintenance_mode as true. It stays 200 because # reports maintenance_mode as true. It stays 200 because the image's Docker
# the image's Docker HEALTHCHECK requests it: a 503 there would make the # HEALTHCHECK requests it: a 503 there would make the container unhealthy, and
# container unhealthy, and upaas marks a deploy failed when its container is # upaas marks a deploy failed when its container is unhealthy.
# unhealthy. (default: false) # (default: false)
maintenance_mode: false maintenance_mode: false
# Data directory for SQLite database and cache files # Data directory for SQLite database and cache files
+30
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net/url"
"time" "time"
) )
@@ -153,6 +154,9 @@ type ImageCache interface {
// Warm pre-fetches and caches an image without returning it // Warm pre-fetches and caches an image without returning it
Warm(ctx context.Context, req *ImageRequest) error Warm(ctx context.Context, req *ImageRequest) error
// Purge removes a cached image
Purge(ctx context.Context, req *ImageRequest) error
// Stats returns cache statistics // Stats returns cache statistics
Stats(ctx context.Context) (*CacheStats, error) Stats(ctx context.Context) (*CacheStats, error)
} }
@@ -172,3 +176,29 @@ type CacheStats struct {
// HitRate is HitCount / (HitCount + MissCount) // HitRate is HitCount / (HitCount + MissCount)
HitRate float64 HitRate float64
} }
// SignatureValidator validates request signatures
type SignatureValidator interface {
// Validate checks if the signature is valid for the request
Validate(req *ImageRequest) error
// Generate creates a signature for a request
Generate(req *ImageRequest) string
}
// Allowlist checks if a URL is allowlisted (no signature required)
type Allowlist interface {
// IsAllowlisted returns true if the URL doesn't require a signature
IsAllowlisted(u *url.URL) bool
}
// Storage handles persistent storage of cached content
type Storage interface {
// Store saves content and returns its hash
Store(ctx context.Context, content io.Reader) (hash string, err error)
// Load retrieves content by hash
Load(ctx context.Context, hash string) (io.ReadCloser, error)
// Delete removes content by hash
Delete(ctx context.Context, hash string) error
// Exists checks if content exists
Exists(ctx context.Context, hash string) (bool, error)
}
+9 -3
View File
@@ -56,10 +56,11 @@ type ServiceConfig struct {
Logger *slog.Logger Logger *slog.Logger
} }
// Static errors for service construction. // Static errors for service construction and unimplemented operations.
var ( var (
errCacheRequired = errors.New("cache is required") errCacheRequired = errors.New("cache is required")
errSigningKeyRequired = errors.New("signing key is required") errSigningKeyRequired = errors.New("signing key is required")
errPurgeNotImplemented = errors.New("purge not implemented")
) )
// NewService creates a new image service. // NewService creates a new image service.
@@ -188,6 +189,11 @@ func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
return err return err
} }
// Purge removes a cached image. Purging is not implemented yet.
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
return errPurgeNotImplemented
}
// Stats returns cache statistics. // Stats returns cache statistics.
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) { func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
return s.cache.Stats(ctx) return s.cache.Stats(ctx)