Feature: Image variants per image: library fields, per-image routes and generate on upload As an owner or admin of a RenewCMS site I want to see, generate and purge the variants of one image, and to have variants generated when I upload So that the media screens show what is cached for each image, and the activity log records when I generate all the variants of an image or purge them Background: Given the spec of record is docs/design/screens/image-variants.md sections 3, 4, 6 and 8 and its "Back-office API errors" table And the routes the design marks "proposed" are fixed here, and features/image-variants.feature is the contract for the variants themselves And this slice is API only: the SPA is in features/image_variants_media_screens.feature and features/image_variants_editor_picker.feature, docs/design is unchanged and the public serving of ?v= is unchanged And it excludes the Settings → Media → Cache routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge) And the stored name of an image is image.StoredName(Path), the public url of one of its variants is "/static/uploadedImages/?v=", and a variant file is generated by the same use case path as a public request (same limits, same render slots, same singleflight key, same index row), so a file generated here is a cache hit for the public url and the other way round And a variant file counts as generated for an image only when its index row exists and carries the variant's current settings fingerprint And internal/domain/image gains the sentinels ErrVariantSourceUnsupported (an SVG source) and ErrVariantSourceUnreadable (the source cannot be decoded), asserted with errors.Is, and reuses ErrSourceTooLarge, ErrVariantNotFound and ErrImageNotFound And internal/domain/image gains one pure function returning the output width and height for a source size and a variant's settings, following the table of features/image-variants.feature "Output geometry never upscales", and both the renderer and the per-image listing use it And image.VariantFileRepository gains one method returning, for every source at once, the number of index rows and their total bytes keyed by source stored name, so a library page never queries per image And internal/domain/audit gains the actions media.variant_generate_all and media.variant_purge_image (category "media", target {"type": "image", "id": "", "name": ""}) and the params generated, failed and causes And the new routes are mounted in api/router.go inside NewMediaRouter, in the group behind middleware.RequireOwnerOrAdmin, so a request with an API key has no user_id claim and answers 401 And request validation, when there is a body, goes through validate tags and validator.Validate.Struct, and error responses are plain text from http.Error And new apperr constants are VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format." and VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated.", and the existing VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant." is reused And a per-image variant is JSON {"name", "width", "height", "fit", "format", "output_width", "output_height", "output_format", "generated", "size", "url"} where width and height are the variant's settings (null when auto), output_* are the dimensions and format the file has or will have, generated is a boolean, size is the file size in bytes or null when not generated, and url is the public url above And a generation failure cause is one of "too_large" (ErrSourceTooLarge), "unsupported_format" (ErrVariantSourceUnreadable) or "failed" (anything else) And every library item gains the integers variant_count and variant_size (the number of index rows of that image whatever the variant's state, and their bytes), both 0 for an image with no file Rule: Only owners and admins reach the per-image variant routes Scenario Outline: Every per-image variant route is guarded When the client sends with Then the response status is And the plain text body is And no variant, index row, blob or activity event is read or changed Examples: | method | path | credentials | status | body | | GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage | | GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | | POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | Scenario Outline: Owners and admins are both allowed When sends GET /v1/media/1/variants with a valid cookie Then the response status is 200 Examples: | role | | the owner | | an admin | Scenario Outline: A malformed id or an unknown image When an admin sends /v1/media//variants Then the response status is and the plain text body is Examples: | method | id | suffix | status | body | | GET | abc | | 400 | apperr.IdUint32ErrorMessage | | POST | abc | | 400 | apperr.IdUint32ErrorMessage | | GET | 9999 | | 404 | Image not found | | POST | 9999 | | 404 | Image not found | | POST | 9999 | /hero | 404 | Image not found | | DELETE | 9999 | /hero | 404 | Image not found | Rule: Listing the variants of one image Background: Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG of 4089446 bytes stored as "a1b2.jpg" And image 2 "logo.svg" is an SVG stored as "e5f6.svg" And the variants, in name order: "card" (inside, 800 x auto, webp, 80), "hero" (cover, 1200 x 630, webp, 82), "og" (cover, 1200 x 630, jpeg, 80), "print" (inside, auto x 2400, png, disabled), "square" (cover, 600 x 600, jpeg, 85), "thumb" (cover, 400 x 300, webp, 80), all enabled unless said And the index holds current rows for a1b2.jpg: thumb 18432 bytes, card 65536, hero 98304, square 83968, and a row for the disabled "print" of 3250586 bytes Scenario: The enabled variants, in name order, with their output and what is cached When an admin sends GET /v1/media/1/variants Then the response status is 200 And "unsupported" is null And "items" are card, hero, og, square, thumb, in that order, and "print" is absent And the items read: | name | width | height | output_width | output_height | output_format | generated | size | | card | 800 | null | 800 | 600 | webp | true | 65536 | | hero | 1200 | 630 | 1200 | 630 | webp | true | 98304 | | og | 1200 | 630 | 1200 | 630 | jpeg | false | null | | square | 600 | 600 | 600 | 600 | jpeg | true | 83968 | | thumb | 400 | 300 | 400 | 300 | webp | true | 18432 | And the url of "thumb" is "/static/uploadedImages/a1b2.jpg?v=thumb" Scenario: A row whose fingerprint is stale is not generated Given the index row of "hero" for a1b2.jpg carries the fingerprint of an older quality When an admin sends GET /v1/media/1/variants Then "hero" has generated false and size null Scenario Outline: A variant in the original format takes the format of the source Given a variant "keep" with format original over a source of content type and webp encoding When an admin sends GET /v1/media//variants Then "keep" has output_format "" Examples: | content_type | webp | output_format | | image/jpeg | available | jpeg | | image/png | available | png | | image/webp | available | webp | | image/webp | unavailable | png | Scenario Outline: The output size never exceeds the source Given a source of x pixels and a variant with width , height and fit When an admin sends GET /v1/media//variants Then the variant has output_width and output_height And they equal the dimensions the renderer produces for the same source and settings Examples: | sw | sh | w | h | fit | ow | oh | | 4032 | 3024 | 800 | | cover | 800 | 600 | | 4032 | 3024 | | 2400 | inside | 3200 | 2400 | | 4032 | 3024 | 8000 | 8000 | cover | 3024 | 3024 | | 300 | 200 | 800 | | cover | 300 | 200 | | 300 | 200 | 400 | 300 | cover | 267 | 200 | | 300 | 200 | 400 | 300 | contain | 400 | 300 | | 300 | 200 | 1200 | 1200 | inside | 300 | 200 | Scenario: A source that no variant can be generated for answers an empty list When an admin sends GET /v1/media/2/variants Then the response status is 200 and the body is {"unsupported": "svg", "items": []} Scenario: No enabled variant answers an empty array, not null Given every variant is disabled When an admin sends GET /v1/media/1/variants Then the response status is 200 and the raw body holds "items":[] and "unsupported":null Scenario: Listing reads nothing it should not change When an admin sends GET /v1/media/1/variants Then no blob is opened, no render happens, no row is written and no activity event is recorded Rule: Generating one variant of one image Background: Given image 1 "north-wall.jpg" is a 1000 x 800 PNG stored as "a1b2.png" And the variants "thumb" (cover 400 x 300 jpeg 80, enabled), "hero" (cover 1200 x 630 jpeg 82, enabled) and "off" (inside 300 x auto png, disabled) And no generated file exists yet Scenario: The first generation renders, stores and indexes the file When an admin sends POST /v1/media/1/variants/thumb Then the response status is 200 And the body is the per-image variant "thumb" with generated true, output 400 x 300, output_format "jpeg" and size equal to the stored blob's size And exactly one blob was put in image.Storage and one index row exists for thumb and a1b2.png And the original blob a1b2.png is unchanged And no activity event is recorded Scenario: The public url then serves that file without rendering Given POST /v1/media/1/variants/thumb answered 200 When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb Then the body is byte-identical to the stored blob and the renderer was called once in total Scenario: Generating a variant that is already generated renders nothing Given POST /v1/media/1/variants/thumb answered 200 When an admin sends POST /v1/media/1/variants/thumb again Then the response status is 200 with the same size and the renderer was called once in total Scenario: A public request and an admin request at the same time render once When the public client and an admin request thumb for a1b2.png concurrently while the render blocks Then the renderer is called once and image.Storage Put is called once Scenario Outline: A request that cannot be served When an admin sends POST /v1/media//variants/ Then the response status is and the plain text body is And nothing is rendered and nothing is stored Examples: | id | name | status | body | | 1 | missing | 404 | Variant not found | | 1 | off | 404 | Variant not found | | 1 | Thumb | 404 | Variant not found | Scenario: An SVG answers 422 Given image 2 is an SVG stored as "e5f6.svg" When an admin sends POST /v1/media/2/variants/thumb Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage And nothing is rendered and nothing is stored Scenario Outline: A source beyond a cap answers 413 Given is lower than the source When an admin sends POST /v1/media/1/variants/thumb Then the response status is 413 and the plain text body is apperr.VariantSourceTooLargeErrorMessage And nothing is stored Examples: | cap | | IMAGE_VARIANT_MAX_SOURCE_BYTES | | IMAGE_VARIANT_MAX_SOURCE_PIXELS | Scenario: A source that cannot be decoded answers 415 Given the stored blob a1b2.png holds bytes that are not an image When an admin sends POST /v1/media/1/variants/thumb Then the response status is 415 and the plain text body is apperr.VariantSourceUnreadableErrorMessage And nothing is stored Scenario: Any other render failure is a generic 500 and is not cached Given the renderer fails for its next call When an admin sends POST /v1/media/1/variants/thumb twice Then the first response is 500 with the body "Internal server error" and no blob or row exists And the second response is 200 Scenario: A generation does not change the original, the other variants or the library list When an admin sends POST /v1/media/1/variants/thumb Then the stored image record, the other variants and the index rows of other images are unchanged Rule: Generating every missing variant of one image Background: Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG stored as "a1b2.jpg" And the enabled variants "card", "hero", "og", "square" and "thumb" and the disabled variant "print" And no generated file exists yet Scenario: Generate all renders every enabled variant and answers the refreshed list When an admin sends POST /v1/media/1/variants Then the response status is 200 And "generated" is 5, "failed" is 0 and "failures" is an empty array And "items" are the five enabled variants in name order, all with generated true and their sizes And exactly five blobs and five index rows exist and "print" has none Scenario: Variants already generated are skipped and not counted Given "card" and "thumb" are already generated When an admin sends POST /v1/media/1/variants Then "generated" is 3, the renderer was called three times in total beyond the earlier two, and all five items have generated true Scenario Outline: A failing variant is reported and the others are still generated Given generating "hero" fails with When an admin sends POST /v1/media/1/variants Then the response status is 200 And "generated" is 4, "failed" is 1 and "failures" is [{"name": "hero", "cause": ""}] And the "hero" item has generated false and the other four have generated true Examples: | failure | cause | | ErrSourceTooLarge | too_large | | ErrVariantSourceUnreadable | unsupported_format | | a renderer error that wraps no sentinel | failed | Scenario: Nothing to generate answers zero Given all five enabled variants are generated When an admin sends POST /v1/media/1/variants Then the response status is 200, "generated" is 0 and "failed" is 0 and the renderer is not called Scenario: No enabled variant answers zero Given every variant is disabled When an admin sends POST /v1/media/1/variants Then the response status is 200, "generated" is 0, "failed" is 0 and "items" is an empty array Scenario: An SVG answers 422 Given image 2 is an SVG When an admin sends POST /v1/media/2/variants Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage Rule: Purging the generated files of one variant of one image Background: Given image 1 stored as "a1b2.jpg" and image 3 stored as "c3d4.jpg" And "thumb" and "hero" are generated for both images, thumb files of 18432 bytes and hero files of 98304 bytes Scenario: Purging a generated variant removes its file and its row When an admin sends DELETE /v1/media/1/variants/thumb Then the response status is 200 and the body is {"purged_files": 1, "purged_bytes": 18432} And the thumb blob of image 1 is deleted from image.Storage and its index row is gone And the hero file of image 1 and both files of image 3 are untouched Scenario: The next public request generates the file again Given DELETE /v1/media/1/variants/thumb answered 200 When the public client sends GET /static/uploadedImages/a1b2.jpg?v=thumb Then the response is 200 and a new blob was put Scenario: Purging a variant that is not generated answers zero Given the "thumb" file of image 1 is not generated When an admin sends DELETE /v1/media/1/variants/thumb Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0} Scenario: A disabled variant keeps its files and they can be purged Given "thumb" is disabled When an admin sends DELETE /v1/media/1/variants/thumb Then the response status is 200 and "purged_files" is 1 Scenario: An unknown variant answers 404 When an admin sends DELETE /v1/media/1/variants/missing Then the response status is 404 and the plain text body is "Variant not found" Scenario: A blob that cannot be deleted keeps its row and the call fails Given image.Storage fails to delete the thumb blob of image 1 When an admin sends DELETE /v1/media/1/variants/thumb Then the response status is 500 with the body "Internal server error" and the index row is kept Rule: Variants flagged "generate on upload" are generated when an image is uploaded Background: Given the variants "thumb" (generate_on_upload true, enabled), "hero" (generate_on_upload true, enabled), "og" (generate_on_upload true, disabled) and "card" (generate_on_upload false, enabled) And image.Storage and the renderer are fakes Scenario: Flagged and enabled variants are generated before the response When the owner sends POST /v1/media with a valid 1000 x 800 PNG Then the response status is 201 And the index holds one row for "thumb" and one for "hero" for the new stored name, and none for "og" or "card" And the item has variant_count 2 and variant_size equal to the two row sizes And exactly one activity event exists, the "media.upload" event, and no variant event Scenario: A failing variant never fails the upload Given the renderer fails when generating "hero" When the owner uploads a valid PNG Then the response status is 201, "thumb" is generated, "hero" is not, and variant_count is 1 And the image exists and the failure is written to the process log with the variant name and the image id Scenario: A source beyond a cap still uploads Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is lower than the new image When the owner uploads it Then the response status is 201 and variant_count is 0 Scenario: An SVG upload generates nothing When the owner uploads a valid SVG Then the response status is 201, the renderer is not called and variant_count is 0 Scenario: No flagged variant means no render Given no variant has generate_on_upload true When the owner uploads a valid PNG Then the renderer is not called Scenario: A refused upload generates nothing When the owner uploads a PDF Then the response status is 415 and the renderer is not called Scenario Outline: Changing the flag does not backfill existing images Given the library already holds image 1 with no generated file When an admin Then the renderer is not called and image 1 has no generated file Examples: | action | | creates the variant "wide" with generate_on_upload true | | sends PUT /v1/image-variants/card with generate_on_upload true | Scenario: Uploading the same bytes again generates files for the new image only Given the library holds image 1 with generated files When the owner uploads the same bytes again Then the new image has its own generated files and image 1's files are unchanged Rule: The library list carries the variant count and size and can filter and sort on them Background: Given the library of features/media_library.feature "Listing" (images 1 to 5, newest is 5) with these sizes and index rows: | id | filename | size | variant files | variant bytes | total | | 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4355686 | | 2 | floorboards.jpg | 2516582 | 1 | 41000 | 2557582 | | 3 | floorboards-copy.jpg | 2516582 | 0 | 0 | 2516582 | | 4 | window-frame.jpg | 812345 | 3 | 3000000 | 3812345 | | 5 | logo.svg | 1204 | 0 | 0 | 1204 | And the rows of image 4 belong to a disabled variant And articles use the images as in that feature, so images 3, 4 and 5 are unused Scenario: Every item carries variant_count and variant_size When the client sends GET /v1/media/ with a valid cookie Then item 1 has variant_count 4 and variant_size 266240 And item 4 has variant_count 3 and variant_size 3000000, although its variant is disabled And items 3 and 5 have variant_count 0 and variant_size 0 And the response order, used_in, duplicate and counts are those of the existing feature Scenario: The list reads the totals of all sources in one call When the client sends GET /v1/media/ with a valid cookie Then the variant file repository's per-source totals method was called once Scenario: A new upload and a rename answer the same fields When the owner renames image 1 to "north-wall-01" Then the response item has variant_count 4 and variant_size 266240 Scenario Outline: variants narrows the list and the counts stay library-wide When the client sends GET /v1/media/?variants= with a valid cookie Then the "items" ids, as a set, are And "counts" is still {"all": 5, "unused": 3, "duplicates": 1} Examples: | variants | ids | | with | 1, 2, 4 | | without | 3, 5 | Scenario: variants, filter, q and sort combine When the client sends GET /v1/media/?variants=with&filter=unused with a valid cookie Then the "items" ids are 4 When the client sends GET /v1/media/?variants=with&q=floorboards with a valid cookie Then the "items" ids are 2 Scenario: sort=total_size orders by original plus variants, largest first, then by id When the client sends GET /v1/media/?sort=total_size with a valid cookie Then the "items" ids are 1, 4, 2, 3, 5 in that order Scenario: sort=largest is unchanged and sorts by the original alone When the client sends GET /v1/media/?sort=largest with a valid cookie Then the "items" ids are 1, 2, 3, 4, 5 in that order Scenario: Equal totals fall back to the lowest id first Given images 2 and 3 have equal totals When the client sends GET /v1/media/?sort=total_size with a valid cookie Then image 2 comes before image 3 Scenario: variants=with and sort=total_size page through the list When the client sends GET /v1/media/?variants=with&sort=total_size&limit=2 with a valid cookie Then the "items" ids are 1, 4 and "next_cursor" is a non-empty string When the client sends the same query with limit=2 and that cursor Then the "items" ids are 2 and "next_cursor" is null Scenario: variants=with on a library with an empty cache answers an empty list Given no variant file exists When the client sends GET /v1/media/?variants=with with a valid cookie Then the response status is 200, "items" is an empty array and "counts" is unchanged Scenario Outline: A malformed query answers 400 When the client sends GET /v1/media/? with a valid cookie Then the response status is 400 and the response body is the text apperr.MediaQueryErrorMessage Examples: | query | | variants=bogus | | variants=all | | variants=With | | variants= | | sort=Total_size | | sort=total | Scenario: The number shown by a delete is the number a delete removes Given image 1 has four index rows and four blobs When the client sends DELETE /v1/media/1 with a valid cookie Then the response status is 204 and exactly those four blobs and four rows are gone Scenario: An article that uses a variant url still counts as using the image Given article 1's body contains "/static/uploadedImages/?v=hero" When the client sends GET /v1/media/1/usage Then article 1 is listed and the list item has used_in 1 Scenario: Merging keeps the variant parameter of the switched url Given article 1's body contains the url of image 3 followed by "?v=hero" When the client merges image 3 into image 2 Then article 1's body contains the url of image 2 followed by "?v=hero" Rule: Generate all and purge by image are recorded in the activity log Background: Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin And the category is "media" and the target is {"type": "image", "id": "", "name": ""} And image 1 "north-wall.jpg" has five enabled variants and none generated Scenario: Generate all is recorded with the counts When the owner sends POST /v1/media/1/variants Then one event exists with action "media.variant_generate_all", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"generated": 5, "failed": 0, "causes": []} Scenario: A partial failure is a success that names its causes Given generating "hero" fails with ErrSourceTooLarge When the owner sends POST /v1/media/1/variants Then one event exists with action "media.variant_generate_all", outcome "success" and params {"generated": 4, "failed": 1, "causes": ["too_large"]} Scenario: Every variant failing is a failed event with a plain reason Given generating every variant fails with ErrSourceTooLarge When the owner sends POST /v1/media/1/variants Then the response status is 200 with "generated" 0 and "failed" 5 And one event exists with action "media.variant_generate_all", outcome "failed" and failure_reason "The source image is too large to generate this variant." Scenario Outline: Nothing was done, nothing is recorded When Then no event is recorded Examples: | request | | the owner sends POST /v1/media/1/variants and all five are already generated | | the owner sends POST /v1/media/1/variants and no variant is enabled | | the owner sends POST /v1/media/1/variants on an SVG | | the owner sends POST /v1/media/1/variants/thumb | | the owner sends POST /v1/media/9999/variants | | the owner sends GET /v1/media/1/variants | | a public GET /static/uploadedImages/a1b2.jpg?v=thumb that generates a file | | an upload that generates files on upload | Scenario: Purging the files of one variant of an image is recorded Given "thumb" is generated for image 1 with a file of 18432 bytes When the owner sends DELETE /v1/media/1/variants/thumb Then one event exists with action "media.variant_purge_image", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"purged_files": 1, "purged_bytes": 18432} Scenario Outline: A purge that removes nothing, or finds nothing, is not recorded When Then no event is recorded Examples: | request | | the owner sends DELETE /v1/media/1/variants/thumb and nothing is generated | | the owner sends DELETE /v1/media/1/variants/missing | | the owner sends DELETE /v1/media/9999/variants/thumb | Scenario: A purge that fails on storage is recorded as failed Given image.Storage fails to delete the thumb blob When the owner sends DELETE /v1/media/1/variants/thumb Then the response is 500 and one event exists with action "media.variant_purge_image", outcome "failed" and failure_reason "Something went wrong on our side. Please try again." Scenario: A failing audit write never changes the response Given the audit repository fails on Append When the owner sends POST /v1/media/1/variants Then the response is 200 with the same body and the files exist Scenario: The new events read back through the existing activity API Given one generate-all and one purge-by-image event were recorded When the owner sends GET /v1/activity?category=media Then both events are listed with their documented shape, params and target Rule: Layering and hygiene Scenario: The dependency rule holds Then internal/domain/image imports only the stdlib and other internal/domain packages And the new use cases (list image variants, generate image variant, generate all, purge image variant, generate on upload) import no persistence, mailer, models, gorm, net/http, chi or api package And the media handler imports no persistence, mailer, models or gorm package And only api/dependecyInjection.go constructs the new use cases and wires them into media.NewHandler And no comment is added to any Go file, test or doc, except tool directives