Feature: The media library holds every uploaded image as a library-level asset As a verified RenewCMS user I want to upload, browse, rename, delete, merge and inspect images that belong to the library and not to any article So that one image can be used in many articles, deleting an article never deletes an image, and duplicates can be merged without leaving a broken image behind Background: Given internal/domain/image.Image carries ID, Path, Filename, Size, ContentType, Width, Height (0 for SVG), SHA256, UploadedByID, CreatedAt and UpdatedAt, and no article id And image.ImageInput gains Width, Height, SHA256 and UploadedByID And internal/domain/image defines the sentinels ErrImageUnreadable, ErrInvalidFilename, ErrMergeCopyMissing and ErrMergeHashMismatch, plus the existing ErrImageNotFound, ErrImageTypeNotAllowed and ErrImageTooLarge, all asserted with errors.Is And image.Repository exposes Create, Get, Delete, List(query), FindByHash, Rename, ListByHash groups and Counts, and article.Repository exposes FindByBodyContaining(filename) and the existing UpdateBody, and no longer AddImage And CreateImageUseCase reads the bytes fully, rejects a type outside image/png, image/jpeg, image/webp and image/svg+xml with ErrImageTypeNotAllowed, rejects more than 10485760 bytes actually read with ErrImageTooLarge, computes SHA-256 hex and, for raster images, width and height from the bytes, and rejects bytes that cannot be decoded (or an SVG without an svg root) with ErrImageUnreadable, all before image.Repository.Create is called And an image's "used in" is the count of articles, online or draft, whose body contains the image's url, read from article bodies each time And /v1/media is mounted in api/router.go behind jwtauth.Verifier, jwtauth.Authenticator, middleware.RequireCurrentToken and middleware.RequireVerifiedUser And every media item is JSON with snake_case fields: {"id","filename","url","size","content_type","width","height","sha256","uploaded_by", "created_at","used_in","duplicate"}, where url is the stored path "/static/uploadedImages/", width and height are null for SVG, uploaded_by is the uploader's display name (username when empty, "" when unknown) and duplicate is true when another image has the same sha256 And request bodies are validated with validate tags and validator.Validate.Struct, and errors are plain text from http.Error Scenario Outline: Every media route refuses callers that are not logged in and verified When the client sends Then the response status is And no image or article is read or changed Examples: | method | path | credentials | status | | GET | /v1/media/ | no cookie | 401 | | POST | /v1/media/ | no cookie | 401 | | PATCH | /v1/media/1 | no cookie | 401 | | DELETE | /v1/media/1 | no cookie | 401 | | POST | /v1/media/merge | no cookie | 401 | | GET | /v1/media/1/usage | no cookie | 401 | | GET | /v1/media/ | a stale token_version cookie | 401 | | GET | /v1/media/ | cookie of an unverified user | 403 | | DELETE | /v1/media/1 | cookie of an unverified user | 403 | Rule: Listing Background: Given user 1 has display name "Inès Duarte" and user 2 has no display name and username "marta" And the library holds, uploaded in this order (oldest first): | id | filename | size | content_type | width | height | sha256 | uploaded by | | 1 | north-wall.jpg | 4089446 | image/jpeg | 3600 | 2400 | A | 1 | | 2 | floorboards.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 1 | | 3 | floorboards-copy.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 2 | | 4 | window-frame.jpg | 812345 | image/jpeg | 1200 | 800 | C | 2 | | 5 | logo.svg | 1204 | image/svg+xml | 0 | 0 | D | 1 | And article 1 "Kitchen Renovation" is online and its body contains the urls of images 1 and 2 And article 2 "Backyard Notes" is a draft and its body contains the url of image 1 And article 3 "Empty" has a body with no image url Scenario: The default list is newest first with used_in, duplicate and tab counts When the client sends GET /v1/media/ with a valid cookie Then the response status is 200 And the "items" ids are 5, 4, 3, 2, 1 in that order And item 1 has used_in 2, duplicate false and uploaded_by "Inès Duarte" And items 2 and 3 have duplicate true, and item 3 has uploaded_by "marta" And item 5 has width null, height null and used_in 0 And "next_cursor" is null And "counts" is {"all": 5, "unused": 3, "duplicates": 1} Scenario: An empty library answers an empty list, not null Given the library holds no image When the client sends GET /v1/media/ with a valid cookie Then the response status is 200 And "items" is an empty array and "counts" is {"all": 0, "unused": 0, "duplicates": 0} Scenario Outline: sort orders the list When the client sends GET /v1/media/?sort= with a valid cookie Then the "items" ids are in that order Examples: | sort | ids | | newest | 5, 4, 3, 2, 1 | | oldest | 1, 2, 3, 4, 5 | | name | 2, 3, 5, 1, 4 | | largest | 1, 2, 3, 4, 5 | Scenario Outline: filter narrows the list and the counts stay library-wide When the client sends GET /v1/media/?filter= with a valid cookie Then the "items" ids, as a set, are And "counts" is still {"all": 5, "unused": 3, "duplicates": 1} Examples: | filter | ids | | all | 1,2,3,4,5 | | unused | 3, 4, 5 | | duplicates | 2, 3 | Scenario Outline: q matches the filename case-insensitively When the client sends GET /v1/media/?q= with a valid cookie Then the "items" ids are in that order And "counts" is still {"all": 5, "unused": 3, "duplicates": 1} Examples: | q | ids | | FLOORBOARD | 3, 2 | | wall | 1 | | zzz | | Scenario: q, filter and sort combine When the client sends GET /v1/media/?q=floorboards&filter=unused&sort=oldest with a valid cookie Then the "items" ids are 3 Scenario: hash returns only the images with that content When the client sends GET /v1/media/?hash= with a valid cookie Then the "items" ids, as a set, are 2, 3 Scenario: hash of unknown content returns no item When the client sends GET /v1/media/?hash=<64 hex zeros> with a valid cookie Then the response status is 200 and "items" is empty Scenario: limit and cursor page through the list When the client sends GET /v1/media/?limit=2 with a valid cookie Then the "items" ids are 5, 4 and "next_cursor" is a non-empty string When the client sends GET /v1/media/?limit=2&cursor= Then the "items" ids are 3, 2 and "next_cursor" is a non-empty string When the client sends GET /v1/media/?limit=2&cursor= Then the "items" ids are 1 and "next_cursor" is null 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 | | filter=bogus | | sort=bogus | | limit=0 | | limit=101 | | limit=abc | | cursor=%%% | | hash=notahash | Rule: Uploading Scenario Outline: An allowed image is always stored, with its metadata and hash Given a valid cookie for user 1 whose display name is "Inès Duarte" When the client sends POST /v1/media/ with multipart field "file" named , content type and real bytes Then the response status is 201 And the body is a media item whose filename is , content_type is , size is the byte count, width and height are and , sha256 is the SHA-256 hex of the bytes, uploaded_by is "Inès Duarte", used_in is 0 and duplicate is false And the item's url matches "/static/uploadedImages/" And the image repository holds one new image with UploadedByID 1 Examples: | name | content_type | width | height | | north-wall.png | image/png | 3 | 2 | | IMG_2208.jpg | image/jpeg | 3 | 2 | | hero.webp | image/webp | 1 | 1 | | logo.svg | image/svg+xml | null | null | Scenario: Uploading the same bytes twice stores both and marks both as duplicates Given the library holds image 1 with the same bytes When the client sends POST /v1/media/ with those bytes named "north-wall-again.png" Then the response status is 201 and the item has duplicate true And the library now holds two images with the same sha256 and different urls And "counts".duplicates is 1 when listed Scenario: A filename without extension gets the canonical one, a path is stripped When the client uploads real PNG bytes named "C:\\photos\\north wall" with content type "image/png" Then the item filename is "north wall.png" Scenario: A file exactly at 10485760 bytes is accepted, one byte more is 413 When the client uploads a valid PNG padded to 10485760 bytes Then the response status is 201 When the client uploads a valid PNG padded to 10485761 bytes Then the response status is 413 And the response body is the text "the file exceeds the maximum upload size" And the image repository's Create was called once in total Scenario: A disallowed content type is 415 When the client uploads "notes.pdf" with content type "application/pdf" Then the response status is 415 And the response body is the text "the file must be a PNG, JPEG, WEBP, or SVG image" And the image repository's Create is never called Scenario Outline: Bytes that cannot be read as the declared type are 400 When the client uploads with content type and the bytes "not an image" Then the response status is 400 And the response body is the text "the image could not be read" And the image repository's Create is never called Examples: | name | content_type | | broken.png | image/png | | broken.jpg | image/jpeg | | broken.webp | image/webp | | broken.svg | image/svg+xml | Scenario: A request without the "file" part is 400 When the client sends POST /v1/media/ with a multipart body that has no "file" field Then the response status is 400 And the image repository's Create is never called Scenario: A repository failure is a generic 500 Given the image repository's next Create fails When the client uploads a valid PNG Then the response status is 500 and the body is "Internal server error" Rule: Renaming Background: Given image 1 is "north-wall.jpg" with url "/static/uploadedImages/aaa.jpeg" and article 1 body contains that url Scenario: Rename changes the display name, keeps the extension and the url When the client sends PATCH /v1/media/1 with {"filename": "north-wall-01"} Then the response status is 200 And the body is the media item with filename "north-wall-01.jpg" and url "/static/uploadedImages/aaa.jpeg" And article 1's body is unchanged And a following GET /v1/media/?q=north-wall-01 returns image 1 Scenario: Surrounding whitespace is trimmed and unicode letters are allowed When the client sends PATCH /v1/media/1 with {"filename": " Été_2026-a "} Then the response status is 200 and the filename is "Été_2026-a.jpg" Scenario Outline: An invalid new name is 400 and nothing changes When the client sends PATCH /v1/media/1 with Then the response status is 400 And the response body is the text "Enter a filename." for every row except "not json" And the image's filename is still "north-wall.jpg" Examples: | body | | {"filename": ""} | | {"filename": " "} | | {"filename": "***"} | | {"filename": "a.b"} | | {"filename": "a b"} | | {"filename": "<101 letters>"} | | {} | | not json | Scenario: Renaming an unknown image is 404 When the client sends PATCH /v1/media/9999 with {"filename": "x"} Then the response status is 404 and the body is "Image not found" Scenario: A malformed id is 400 When the client sends PATCH /v1/media/abc with {"filename": "x"} Then the response status is 400 and the body is apperr.IdUint32ErrorMessage Rule: Deleting Scenario: An image in use can be deleted and article bodies are left alone Given image 1 is used in article 1's body When the client sends DELETE /v1/media/1 with a valid cookie Then the response status is 204 and the body is empty And image 1 is gone from the repository and its file from UPLOAD_DIR And article 1's body is byte-for-byte unchanged Scenario: A file already missing from disk still deletes the row Given image 4's file is not in UPLOAD_DIR When the client sends DELETE /v1/media/4 Then the response status is 204 and image 4 is gone from the repository Scenario: Deleting an unknown image is 404 When the client sends DELETE /v1/media/9999 Then the response status is 404 and the body is "Image not found" Scenario: A malformed id is 400 When the client sends DELETE /v1/media/abc Then the response status is 400 and the body is apperr.IdUint32ErrorMessage Scenario: A repository failure is a generic 500 and the image stays Given the image repository's Delete fails When the client sends DELETE /v1/media/1 Then the response status is 500 and image 1 is still in the repository Rule: Usage Scenario: Usage lists the articles whose body contains the url Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not When the client sends GET /v1/media/1/usage with a valid cookie Then the response status is 200 And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}] Scenario: An unused image has an empty array, not null When the client sends GET /v1/media/4/usage Then the response status is 200 and the body is [] Scenario: An absolute url still counts as used Given article 3's body contains "https://cms.example.com/static/uploadedImages/aaa.jpeg" for image 1 Then GET /v1/media/1/usage includes article 3 Scenario Outline: Unknown or malformed ids When the client sends GET /v1/media//usage Then the response status is and the body is Examples: | id | status | body | | 9999 | 404 | Image not found | | abc | 400 | apperr.IdUint32ErrorMessage | Rule: Merging Background: Given images 2, 3 and 6 share sha256 B, image 1 has sha256 A And article 1 body contains the urls of images 2 and 3, article 2 body contains image 3's url twice, article 3 body contains image 2's url only, and article 4 has none of them Scenario: Merge switches bodies to the kept url and deletes the copies When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 6]} Then the response status is 200 And the body is {"articles_updated": 2, "merged": 2} And article 1's body has image 3's url replaced by image 2's url And article 2's body has both occurrences of image 3's url replaced by image 2's url And article 3 and article 4 are unchanged And images 3 and 6 are gone from the repository and from UPLOAD_DIR, and image 2 remains Scenario: Merging copies that no article uses updates zero articles When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [6]} Then the response status is 200 and the body is {"articles_updated": 0, "merged": 1} Scenario: A copy that is gone answers 409 and changes nothing When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 9999]} Then the response status is 409 and the body is apperr.MediaMergeConflictErrorMessage And no article body changed and images 2 and 3 still exist Scenario: A copy that does not share the hash answers 409 and changes nothing When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 1]} Then the response status is 409 And no article body changed and images 1, 2 and 3 still exist Scenario: A kept image that is gone answers 404 When the client sends POST /v1/media/merge with {"keep_id": 9999, "merge_ids": [3]} Then the response status is 404 and the body is "Image not found" And nothing changed Scenario Outline: An invalid merge request is 400 and nothing changes When the client sends POST /v1/media/merge with Then the response status is 400 and the body is apperr.MediaMergeRequestErrorMessage Examples: | body | | {"keep_id": 2, "merge_ids": []} | | {"keep_id": 2} | | {"merge_ids": [3]} | | {"keep_id": 2, "merge_ids": [2, 3]} | | {"keep_id": 2, "merge_ids": [3, 3]} | | {"keep_id": 0, "merge_ids": [3]} | | not json | Scenario: A failure rewriting a body restores the bodies already rewritten Given the article repository's UpdateBody fails for article 2 When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]} Then the response status is 500 And article 1's body is back to its original text, article 2 is unchanged And images 2 and 3 still exist Scenario: A failure deleting a copy after the rewrite is 500 and the bodies stay switched Given the image repository's Delete fails for image 3 When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]} Then the response status is 500 And article 1's and article 2's bodies use image 2's url and image 3 still exists @manual Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md) Scenario: Existing rows get metadata and hash from the files on disk Given the images table has rows created before this slice, with a path and no filename, size, content_type, width, height or sha256, and their files are in UPLOAD_DIR When the server boots Then each row gets filename equal to the stored file name, size, content_type, width and height (0 for SVG) and sha256 read from its file, and UploadedByID stays 0 And the old article_id column and the article_images table are kept and not written Scenario: A row whose file is missing does not stop the boot Given a row whose file is not in UPLOAD_DIR When the server boots Then the boot completes, the row is left without metadata, and the server serves requests Scenario: The backfill is idempotent When the server boots a second time Then no row is changed again Scenario: A row without metadata still lists Then GET /v1/media/ returns it with size 0, width null, height null, sha256 "" and duplicate false