feat: image variants engine

This commit is contained in:
fsylvain committed 2026-10-08 02:33:32 +02:00
1 parent f0845de643
commit f1d56ef50e
80 files changed
+7312 -84

No files matched your search

+29 -3
View File
@@ -62,12 +62,13 @@ never changes the response or the change itself.
| Category | Actions |
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the
Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes`, with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
@@ -89,6 +90,31 @@ codes, article bodies, file bytes, storage paths and asset urls are never record
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose |
| --- | --- |
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
field (name, width and height, fit, format, quality).
## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
+6
View File
@@ -69,6 +69,12 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
### Image metadata backfill
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
+3
View File
@@ -34,6 +34,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_SOURCE_PIXELS` | `50000000` | Largest declared width x height, in pixels, that an [image variant](image-variants.md) may be generated from. It is read from the image header before any pixel buffer is allocated; a bigger source answers 413 with the same plain-text body as the byte cap. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` | `4` | Number of [image variant](image-variants.md) renders that may run at once. Other requests wait for a free slot until their client goes away; cached files never wait. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
+102
View File
@@ -0,0 +1,102 @@
# Image variants
An owner or admin defines named variants of the stored images. A request for
`/static/uploadedImages/<storedName>?v=<name>` generates the variant on first use, caches it and
serves the cached file afterwards. The original is never changed. Only a variant that exists and is
enabled is served. The visual spec is `docs/design/screens/image-variants.md`; the management routes
are in [api.md](api.md#image-variants); the layering is in [architecture.md](architecture.md#image-variants).
## Public contract
| Request | Result |
| --- | --- |
| `…/a1b2.png` | The stored file, exactly as before this feature. |
| `…/a1b2.png?v=thumb` | 200, the cached variant. Other query parameters (`t=123`) are ignored. |
| `?v=` with an unknown, disabled, deleted or badly written name | 400 `{"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"<name>"}`. |
| `width`, `height`, `format` or `quality` | 400 `unsupported_parameter` with the parameter as `detail`. It wins over `unknown_variant`. |
| A missing source (also `..` and nested paths) | 404, after the parameter checks. |
| A blob that has no image record (a generated variant file, an orphan) | 404 for `?v=`, after the parameter checks and before any storage read. Without `?v=` the blob is served as before. |
| A source bigger than `IMAGE_VARIANT_MAX_SOURCE_BYTES` | 413 `The source image is too large to generate this variant.` (plain text). The original is never capped. |
| A source whose header declares more than `IMAGE_VARIANT_MAX_SOURCE_PIXELS` (width x height) | The same 413 and body. The header is read before any pixel buffer is allocated, so a small file declaring 30000 x 30000 is refused without being decoded. |
| A source that cannot be decoded, a header that cannot be read, or a failing render | 500, nothing is cached, the next request tries again. |
The 400 body is JSON with `Content-Type: application/json`. Every other error is plain text.
SVG sources and animated GIFs are served as they are, with the variant parameters validated but no
generation, even above the size cap. A one-frame GIF is a normal raster source.
The response carries an `ETag` derived from the variant settings (width, height, fit, format, quality)
and the source stored name. It is an opaque quoted value. `If-None-Match` answers 304 before the cache
is read. Changing `generate_on_upload` or `enabled` does not change it; changing any rendering
setting does, so a client holding an old value gets a fresh 200. `Cache-Control` stays
`public, no-cache`.
## Cache
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
(`singleflight` keyed by variant, settings and source); different variants or different images are
generated in parallel.
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
are never rendered. At most `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` (default 4) renders run at once:
the slot is taken inside the shared generation, so identical concurrent requests use one slot, and a
request waiting for a slot stops waiting when its client goes away, without rendering. A cached
file never takes a slot. The pixel cap defaults to 50000000 pixels.
- Editing a rendering setting (`width`, `height`, `fit`, `format`, `quality`) purges the variant's
files; the next request regenerates. Switching `generate_on_upload` or `enabled` keeps them, so
re-enabling a variant serves from its kept cache.
- A row whose fingerprint differs from the variant (an edit raced a generation) or whose blob is gone
is regenerated and replaced, and the old blob is removed.
- Deleting a variant, purging it, deleting a source image or merging copies removes the blobs first
and the rows second. A blob that cannot be deleted keeps its row, the call answers 500 and a retry
finishes the job. A blob that is already missing does not stop a purge.
- `generate_on_upload` is stored and returned only. Generating at upload time is not part of this
release.
## Geometry
Images are never enlarged.
- With one dimension, the image keeps its aspect ratio and the output is at most that size.
- `inside` fits the whole image in the box, without padding.
- `cover` fills the box and crops around the centre. A box larger than the source is first scaled
down, keeping its aspect ratio, until it fits inside the source (300 x 200 into 400 x 300 gives
267 x 200).
- `contain` with both width and height scales the image to fit inside the box, never enlarging it, and
centres it on a canvas of exactly width x height: a 300 x 200 image in a 400 x 300 box stays
300 x 200 on a 400 x 300 canvas, and a 40 x 20 image in a 40 x 40 box is padded to 40 x 40. With a
single dimension there is no box, so no padding and no upscaling.
### Padding colour of `contain`
The design does not draw it, so it is decided here: the padding is transparent for `png` and `webp`
outputs and white (`#FFFFFF`) for `jpeg` outputs, which have no alpha. A `jpeg` output also flattens
any transparency of the source onto white (`#FFFFFF`).
## Formats
`original` keeps the source format (a GIF source becomes PNG). `jpeg` and `webp` use `quality`
(default 82); `png` ignores it and the API returns `quality: null`. A WebP source always decodes. The
renderer reports the formats it can encode; the API refuses a format it cannot encode with
`Format must be original, jpeg or png.`.
## Spike: a CGO-free lossy WebP encoder
The standard library and `golang.org/x/image` can decode WebP but not encode it, and the build must
stay `CGO_ENABLED=0` (six release targets, pure-Go SQLite).
| Candidate | Lossy | `CGO_ENABLED=0` | Verdict |
| --- | --- | --- | --- |
| `golang.org/x/image/webp` | decode only | yes | Cannot encode. |
| `github.com/gen2brain/webp` v0.6.4 (libwebp translated to Go with wasm2go; `purego` only for the optional dynamic library) | yes, with alpha | yes | Adopted. |
The spike built `gen2brain/webp` for linux, darwin and windows on amd64 and arm64 with
`CGO_ENABLED=0`, and `go list -deps` shows no package with cgo files. On a noisy 4032 x 3024 image
a lossy encode took about 0.75 s at quality 20 and 1.5 s at quality 95 (a smaller, real photograph is
faster). Output is `RIFF....WEBP`, and a lower quality gives a smaller file. Decision: `webp` is offered
as an output format. Should the dependency ever be dropped, `NewVariantRendererEncoding` already
builds a renderer without the WebP encoder: the API then refuses `webp` with 422, and a WebP source
with format `original` is served as PNG.