From 4f5bd190e51e3e08d3a2c60c5e6ba3936c299055 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 17:05:16 +0100 Subject: [PATCH] feat: image variants job and variants in the post API - golem15_journal_image_variants (migration 202610280002): a thumbnail per width of golem15.journal.image_widths (480, 800, 1200 by default, never wider than the original) and its WebP copy, with the original's size - conga job golem15.journal.image_variants (three attempts), queued after a featured image upload and after an admin save of a post whose images lack a width; the upload request never encodes - journal:image-variants [--all] backfills - featured_images[].variants {width, height, url, webp_url, source_width, source_height} on the post answers (additive key) --- README.md | 46 ++- classes/image_variants.go | 284 ++++++++++++++++++ config/config.yaml | 10 + console/image_variants.go | 67 +++++ controllers/api/posts.go | 41 +++ controllers/posts.go | 3 + go.mod | 1 + go.sum | 2 + jobs.go | 42 +++ models/image_variant.go | 27 ++ plugin.go | 1 + related_media_test.go | 224 ++++++++++++++ ...2_create_golem15_journal_image_variants.go | 41 +++ 13 files changed, 784 insertions(+), 5 deletions(-) create mode 100644 classes/image_variants.go create mode 100644 console/image_variants.go create mode 100644 jobs.go create mode 100644 models/image_variant.go create mode 100644 updates/202610280002_create_golem15_journal_image_variants.go diff --git a/README.md b/README.md index 6c25d23..74a10be 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links). +- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants). - **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links). - **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search). - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. @@ -30,7 +31,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. -- **Schema.** Nine gormigrate steps create the six journal tables, the author-slug column, the category `content` column and the settings columns `content_h1_to_h2` and `generic_anchors`. AutoMigrate is not used. Table names are `golem15_journal_*` only. +- **Schema.** Ten gormigrate steps create the six journal tables, the author-slug column, the category `content` column, the settings columns `content_h1_to_h2` and `generic_anchors`, and `golem15_journal_image_variants`. AutoMigrate is not used. Table names are `golem15_journal_*` only. ## Usage @@ -194,6 +195,26 @@ Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50) Visitors only ever see published posts with a past publish time; an editor who may see drafts also gets drafts among the scored related posts, as the default mode does. The neighbours are published posts only. +### Image variants + +Every entry of `featured_images` (on `GET /posts`, `GET /posts/{slug}` and the author lists) carries `variants`, the image's sizes by width, `[]` until they are made (additive key; the other keys are unchanged): + +```json +{"id": 12, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg", "file_name": "cover.jpg", "file_size": 183402, + "variants": [ + {"width": 480, "height": 288, "url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.jpg", "webp_url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.webp", "source_width": 1000, "source_height": 600}, + {"width": 800, "height": 480, "url": "…_800_480_0_0_exact.jpg", "webp_url": "…_800_480_0_0_exact.webp", "source_width": 1000, "source_height": 600} + ]} +``` + +`url` is the thumbnail in the original's format (a `.webp` original's thumbnail holds JPEG bytes), `webp_url` its WebP copy at quality 80, and `source_width` and `source_height` the original's size, so a page can list the original in a `srcset` too. One query loads the variants of a post's images. + +- **When they are made.** `POST /_journal/api/v1/posts/{id}/featured-images` stores the original and queues the conga job `golem15.journal.image_variants` (`classes.ImageVariantsArgs`, three attempts) after the row is written; the request itself never resizes or encodes, and a queue that cannot take the job is logged without failing the upload. An admin save of a post queues the job, after the save commits, for its featured images that lack a width. +- **What is made.** For each width of `golem15.journal.image_widths` no larger than the original, `classes.GenerateImageVariants` writes the thumbnail (`attach.File.ThumbKey`, mode `exact`, the original's aspect ratio) and its WebP copy (`webp.Variant` of the framework's `modules/lagoon/attach/webp`). A width that has its row is skipped, so the job and the command can run any number of times. A missing or undecodable original, or one over the thumbnailer's 4096 by 4096 pixel limit, is skipped and logged. +- **Backfill.** `journal:image-variants` takes the images that lack a width no larger than their original; `--all` checks every featured image. +- **Not made.** AVIF (deferred). Images inserted into a post body keep their original file. +- **Server.** WebP is encoded in Go. With the system libwebp installed (`libwebp.so` and `libwebpdemux.so`, Debian `libwebp-dev`) it is about five times faster. A binary that mounts this plugin is dynamically linked against glibc, unless it is built with `-tags nodynamic` (see the framework's attachments documentation). + ### Personal API tokens The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`): @@ -393,6 +414,12 @@ The request is a `POST` to the URL with `Authorization: Bearer `, `Conten | `golem15.journal.related_mode` | `SUMMER_GOLEM15__JOURNAL__RELATED_MODE` | `category` (default, `config/config.yaml`): related posts as before. `scored`: ranked by shared tags and categories, plus `previous_in_category` and `next_in_category` on the post answer. See [related posts](#related-posts). | | `golem15.journal.related_count` | `SUMMER_GOLEM15__JOURNAL__RELATED_COUNT` | The number of scored related posts, default `3`, clamped to 3 to 6. Read in `scored` mode only. | +### Image variants + +| Key | Environment variable | Value | +|-----|----------------------|-------| +| `golem15.journal.image_widths` | `SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS` | The widths in pixels of the featured image variants, default `[480, 800, 1200]` (`config/config.yaml`); the environment takes a comma-separated list such as `480,960`. Each is 16 to 4096; an invalid list falls back to the default. See [image variants](#image-variants). | + ### Author pages | Key | Environment variable | Value | @@ -412,16 +439,24 @@ The request is a `POST` to the URL with `Authorization: Bearer `, `Conten | `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. | | `journal.Plugin.Requires` | Returns `golem15.translate` only. | | `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. | -| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`, `golem15.journal.related_mode`, `golem15.journal.related_count`). | +| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`, `golem15.journal.related_mode`, `golem15.journal.related_count`, `golem15.journal.image_widths`). | | `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. | | `journal.Plugin.Models` | Models registered by this plugin. | -| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts`, `journal:reindex` and `journal:fix-headings`. | +| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts`, `journal:reindex`, `journal:fix-headings` and `journal:image-variants`. | +| `journal.Plugin.Jobs` | Registers the image variants job `golem15.journal.image_variants` (`pact.HasJobs`). | | `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. | | `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools). | | `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). | | `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. | | `journal.Plugin.MenuItemTypes` | Registers the Journal menu item types (`pact.HasMenuItemTypes`). | | `console.FixHeadings` | `journal:fix-headings`. | +| `console.ImageVariants` | `journal:image-variants`. | +| `classes.ImageVariantsArgs`, `classes.ImageVariantsKind` | The image variants job's arguments (`FileIDs`) and kind `golem15.journal.image_variants`. | +| `classes.GenerateImageVariants`, `classes.VariantsResult` | Writes the thumbnails and WebP copies of files at the widths no larger than their originals and records them; what one run did. | +| `classes.ImageWidths`, `classes.ImageWidthsKey`, `classes.DefaultImageWidths`, `classes.MinImageWidth`, `classes.MaxImageWidth` | `golem15.journal.image_widths` read from the config (a list or a comma-separated text, 16 to 4096, sorted), its default 480, 800 and 1200. | +| `classes.ImageVariantsMode`, `classes.FeaturedImagesField` | The variants' thumbnail mode `exact` and the `featured_images` field. | +| `classes.FeaturedImageIDs`, `classes.DispatchImageVariants`, `classes.DispatchPostImageVariantsAfterCommit` | The featured images to backfill, and queueing the job for files or, after a commit, for a post's images that lack a width. | +| `models.ImageVariant` | A row of `golem15_journal_image_variants`: file, width, height, thumbnail and WebP keys, the original's size. | | `console.ImportFromDirConfig`, `console.ImportStats.H1Converted` | The import with the config the H1 setting falls back to, and the number of H1 headings it stored as H2. | | `controllers.PermissionManageSettings` | `golem15.journal.manage_settings`. | | `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). | @@ -493,6 +528,7 @@ The export and import commands write JSON files (one per post slug), matching th |---------|---------------------|--------| | `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: `. | | `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post; while the H1 setting is on, H1 headings are stored as H2 and the count is printed. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. | +| `journal:image-variants` | `--all` | Writes the thumbnails and WebP copies of featured images at `golem15.journal.image_widths` that are missing (with `--all`, checks every featured image) and prints `Checked N featured images, wrote N variants, skipped N.`, or `Every featured image has its variants.` | | `journal:fix-headings` | `--apply`, `--dry-run` (the default) | Converts H1 to H2 in stored post content (every locale) and category pillar text while the H1 setting is on, firing a record event per changed post and category. Prints `Would convert N H1 headings in N posts and N categories. Run with --apply to save.`, `Converted N H1 headings in N posts and N categories.` or `No H1 headings found in post or category content.`; refuses with exit code 1 while the setting is off. | | `journal:reindex` | `--fresh` | Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. `--fresh` first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or `search_use_typesense` is off. Ends with `Reindexed: N synced, N removed, N failed` and exits non-zero on any failure. | @@ -508,7 +544,7 @@ The export and import commands write JSON files (one per post slug), matching th ## Dependencies -- SummerCMS v0.6.1 modules (the content facts and `pact.FormFieldType.RequestOptions` are new in v0.6.1): backpack, beachcomber, bonfire, cabana, compass, cooler (site-cache purge), diary, lagoon/attach, pact, party, phrasebook, signpost (content descriptors), surf. +- SummerCMS v0.6.1 modules (the content facts and `pact.FormFieldType.RequestOptions` are new in v0.6.1): backpack, beachcomber, bonfire, cabana, compass, conga (the image variants job), cooler (site-cache purge), diary, lagoon/attach, lagoon/attach/webp (WebP variants, through `github.com/gen2brain/webp` v0.6.4), pact, party, phrasebook, signpost (content descriptors), surf. - Compiled plugin: `golem15.translate`. - Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`. - Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`. @@ -529,7 +565,7 @@ Headings, links and media (Phase 33): `TestHeadingConversionTracer`, `TestHeadin `TestJournalMCPSmoke` (root package, Docker-free) pins the eleven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it. -Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. +Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. `TestImageVariants` (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's `variants`, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width. `TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule. diff --git a/classes/image_variants.go b/classes/image_variants.go new file mode 100644 index 0000000..6705a4d --- /dev/null +++ b/classes/image_variants.go @@ -0,0 +1,284 @@ +package classes + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "io" + "slices" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/conga" + "git.golem15.com/golem15/summercms/modules/diary" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/lagoon/attach/webp" + "gocloud.dev/blob" + "gocloud.dev/gcerrors" + "gorm.io/gorm" +) + +// The image variants job and its configuration (2026-10-28 image formats, +// D-85, P33-43). +const ( + // ImageVariantsKind is the conga job kind golem15.journal.image_variants. + ImageVariantsKind = "golem15.journal.image_variants" + // ImageWidthsKey is the config key of the variant widths, in pixels. + ImageWidthsKey = "golem15.journal.image_widths" + // ImageVariantsMode is the thumbnail mode of a variant: the exact size + // of the original's aspect ratio at the variant's width. + ImageVariantsMode = "exact" + // FeaturedImagesField is the system_files field of a post's featured + // images. + FeaturedImagesField = "featured_images" + // MinImageWidth and MaxImageWidth bound a configured width. + MinImageWidth = 16 + MaxImageWidth = 4096 +) + +// variantsLog logs the image variants as component journal. +var variantsLog = diary.For("journal") + +// DefaultImageWidths are the variant widths without configuration. +var DefaultImageWidths = []int{480, 800, 1200} + +// ImageVariantsArgs are the arguments of the image variants job: the +// system_files ids of the featured images to make variants of. +type ImageVariantsArgs struct { + FileIDs []uint `json:"file_ids"` +} + +// Kind is the job kind, golem15.journal.image_variants. +func (ImageVariantsArgs) Kind() string { return ImageVariantsKind } + +// ImageWidths reads golem15.journal.image_widths: a list of widths or a +// comma-separated text (an environment override), each 16 to 4096, sorted +// and once each. An unset, empty or invalid value answers +// DefaultImageWidths. +func ImageWidths(cfg *compass.Config) []int { + if cfg == nil { + return slices.Clone(DefaultImageWidths) + } + raw, ok := cfg.Lookup(ImageWidthsKey) + if !ok || raw == nil { + return slices.Clone(DefaultImageWidths) + } + var parts []string + switch v := raw.(type) { + case string: + parts = strings.Split(v, ",") + case []any: + for _, item := range v { + parts = append(parts, fmt.Sprint(item)) + } + case []int: + for _, n := range v { + parts = append(parts, strconv.Itoa(n)) + } + default: + parts = []string{fmt.Sprint(v)} + } + var out []int + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + n, err := strconv.Atoi(p) + if err != nil || n < MinImageWidth || n > MaxImageWidth { + return slices.Clone(DefaultImageWidths) + } + out = append(out, n) + } + if len(out) == 0 { + return slices.Clone(DefaultImageWidths) + } + slices.Sort(out) + return slices.Compact(out) +} + +// VariantsResult is what one run of GenerateImageVariants did. +type VariantsResult struct { + Files int + Written int + Skipped int +} + +// maxHeaderBytes caps the bytes read for an original's size. +const maxHeaderBytes = 1 << 20 + +// sourceSize reads an original's size from its header; ok is false for a +// missing original, one that does not decode, or one over the thumbnailer's +// pixel limit (attach would store the broken-image picture for it). +func sourceSize(ctx context.Context, bucket *blob.Bucket, f attach.File) (w, h int, ok bool, err error) { + r, err := bucket.NewReader(ctx, attach.BlobKey(f.DiskName), nil) + if gcerrors.Code(err) == gcerrors.NotFound { + return 0, 0, false, nil + } + if err != nil { + return 0, 0, false, err + } + raw, err := io.ReadAll(io.LimitReader(r, maxHeaderBytes)) + closeErr := r.Close() + if err != nil { + return 0, 0, false, err + } + if closeErr != nil { + return 0, 0, false, closeErr + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { + return 0, 0, false, nil + } + if int64(cfg.Width)*int64(cfg.Height) > attach.MaxImagePixels { + return 0, 0, false, nil + } + return cfg.Width, cfg.Height, true, nil +} + +// GenerateImageVariants writes the variants of the files ids (D-85): for +// each width of widths no larger than the original's, the thumbnail at that +// width (attach.File.ThumbKey, mode exact, the original's aspect ratio) and +// its WebP copy (webp.Variant), recorded in golem15_journal_image_variants. +// A width that already has its row is skipped, so a second run writes +// nothing. A missing, undecodable or oversized original is skipped and +// logged. A storage or database error is returned (the job retries). +func GenerateImageVariants(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, widths []int, ids []uint) (VariantsResult, error) { + var out VariantsResult + if db == nil || bucket == nil { + return out, errors.New("journal: image variants need the database and the uploads bucket") + } + if len(ids) == 0 { + return out, nil + } + var files []attach.File + if err := db.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&files).Error; err != nil { + return out, err + } + for _, f := range files { + out.Files++ + var have []int + if err := db.WithContext(ctx).Model(&models.ImageVariant{}).Where("file_id = ?", f.ID).Pluck("width", &have).Error; err != nil { + return out, err + } + var todo []int + for _, w := range widths { + if !slices.Contains(have, w) { + todo = append(todo, w) + } + } + if len(todo) == 0 { + continue + } + sw, sh, ok, err := sourceSize(ctx, bucket, f) + if err != nil { + return out, fmt.Errorf("journal: read original %d: %w", f.ID, err) + } + if !ok { + variantsLog.Warn("journal: no image variants for an unusable original", "file", f.ID) + out.Skipped++ + continue + } + for _, w := range todo { + if w > sw { + out.Skipped++ + continue + } + h := max(1, int((int64(sh)*int64(w)+int64(sw)/2)/int64(sw))) + key, err := f.ThumbKey(ctx, bucket, w, h, ImageVariantsMode) + if err != nil { + return out, fmt.Errorf("journal: thumbnail %d at %d: %w", f.ID, w, err) + } + webpKey, err := webp.Variant(ctx, bucket, &f, w, h, ImageVariantsMode) + if err != nil { + return out, fmt.Errorf("journal: webp %d at %d: %w", f.ID, w, err) + } + row := models.ImageVariant{FileID: f.ID, Width: w, Height: h, Key: key, WebPKey: webpKey, SourceWidth: sw, SourceHeight: sh, CreatedAt: time.Now().UTC()} + res := db.WithContext(ctx).Exec(`INSERT INTO golem15_journal_image_variants (file_id, width, height, key, webp_key, source_width, source_height, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (file_id, width) DO NOTHING`, row.FileID, row.Width, row.Height, row.Key, row.WebPKey, row.SourceWidth, row.SourceHeight, row.CreatedAt) + if res.Error != nil { + return out, res.Error + } + out.Written += int(res.RowsAffected) + } + } + return out, nil +} + +// featuredFiles is the featured images of the posts, or of every post when +// postIDs is nil; missing is true to keep only the images without any +// variant row, or without a row for a width no larger than their original. +func featuredFiles(db *gorm.DB, postIDs []uint, widths []int, missing bool) ([]uint, error) { + q := db.Model(&attach.File{}).Where("attachment_type = ? AND field = ?", (models.Post{}).MorphName(), FeaturedImagesField) + if postIDs != nil { + keys := make([]string, 0, len(postIDs)) + for _, id := range postIDs { + keys = append(keys, strconv.FormatUint(uint64(id), 10)) + } + q = q.Where("attachment_id IN ?", keys) + } + if missing { + list := make([]string, 0, len(widths)) + for _, w := range widths { + list = append(list, strconv.Itoa(w)) + } + q = q.Where(`NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id) +OR EXISTS (SELECT 1 FROM unnest(?::int[]) AS w(width) + WHERE w.width <= (SELECT MAX(v.source_width) FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id) + AND NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id AND v.width = w.width))`, "{"+strings.Join(list, ",")+"}") + } + var ids []uint + err := q.Order("id ASC").Pluck("id", &ids).Error + return ids, err +} + +// FeaturedImageIDs lists the featured images of every post (all) or only +// those missing a variant of a configured width no larger than their +// original (or without any variant yet). +func FeaturedImageIDs(db *gorm.DB, cfg *compass.Config, all bool) ([]uint, error) { + return featuredFiles(db, nil, ImageWidths(cfg), !all) +} + +// DispatchImageVariants queues the image variants job for the files ids. +// The upload or save that called it has committed; a queue that cannot +// take the job is logged and never fails that request (D-85). +func DispatchImageVariants(ctx context.Context, app *backpack.App, ids []uint) { + if app == nil || len(ids) == 0 { + return + } + m, err := conga.From(app) + if err == nil { + _, err = m.Dispatch(ctx, nil, ImageVariantsArgs{FileIDs: ids}, conga.DispatchOpts{Label: "Journal image variants"}) + } + if err != nil { + variantsLog.Warn("journal: image variants job not queued", "files", len(ids), "error", err) + } +} + +// DispatchPostImageVariantsAfterCommit queues the image variants job, once +// tx commits, for the post's featured images that lack a variant of some +// width (the admin save: the uploads it binds commit with it). +func DispatchPostImageVariantsAfterCommit(ctx context.Context, tx *gorm.DB, app *backpack.App, postID uint) { + if app == nil || postID == 0 { + return + } + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + return + } + ids, err := featuredFiles(db.WithContext(ctx), []uint{postID}, ImageWidths(app.Config), true) + if err != nil { + variantsLog.Warn("journal: image variants of a saved post", "post", postID, "error", err) + return + } + DispatchImageVariants(ctx, app, ids) + }) +} diff --git a/config/config.yaml b/config/config.yaml index 1b2fc04..1ecfd78 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -18,3 +18,13 @@ content_h1_to_h2: true # neighbours by publish time within its first category by name. related_mode: category related_count: 3 + +# Featured image variants (golem15.journal.image_variants job, +# journal:image-variants): after a featured image upload commits, a +# background job writes a thumbnail at each width (pixels, 16 to 4096, +# never wider than the original) and a WebP copy of each next to it. The +# API lists them as featured_images[].variants; the journal layer serves +# with WebP and the original format as fallback. An environment +# override takes a comma-separated list, e.g. +# SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=480,960. +image_widths: [480, 800, 1200] diff --git a/console/image_variants.go b/console/image_variants.go new file mode 100644 index 0000000..8f25b22 --- /dev/null +++ b/console/image_variants.go @@ -0,0 +1,67 @@ +package console + +import ( + "context" + "errors" + "fmt" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "gocloud.dev/blob" +) + +// ImageVariants is journal:image-variants: it writes the thumbnails and +// WebP copies of the posts' featured images at golem15.journal.image_widths +// in this process (the backfill of the golem15.journal.image_variants job). +// Without --all it takes the images that lack a variant of some width. +func ImageVariants(app *backpack.App) bonfire.Command { + return bonfire.Command{ + Name: "journal:image-variants", + Description: "Write the WebP and resized variants of featured images", + Long: `Writes, for every post's featured image, a thumbnail at each width of +golem15.journal.image_widths (480, 800 and 1200 by default) that is not +wider than the original, and a WebP copy of each next to it, and records +them in golem15_journal_image_variants. The Journal API lists them as +featured_images[].variants and the journal layer serves them through + with the original format as fallback. + +New uploads get their variants from the golem15.journal.image_variants job, +queued after the upload commits. This command is the backfill: without +--all it takes the images that lack a variant of some width (for example +after adding a width), with --all it checks every featured image. A +variant that exists is never written again, so the command can run any +number of times.`, + Flags: []bonfire.Flag{ + {Name: "all", Description: "Check every featured image (default: only those missing a width)", Bare: true}, + }, + Examples: []bonfire.Example{ + {Command: "journal:image-variants", Description: "Write the variants images are missing"}, + {Command: "journal:image-variants --all", Description: "Check every featured image"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + db := mustDB(app) + if db == nil { + return errors.New("journal: database is not configured") + } + bucket, ok := app.Lookup[*blob.Bucket]() + if !ok || bucket == nil { + return errors.New("journal: the uploads bucket is not configured") + } + ids, err := classes.FeaturedImageIDs(db.WithContext(ctx), appConfig(app), flagOn(in, "all")) + if err != nil { + return err + } + if len(ids) == 0 { + out.Info("Every featured image has its variants.") + return nil + } + res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(appConfig(app)), ids) + if err != nil { + return err + } + out.Success(fmt.Sprintf("Checked %d featured images, wrote %d variants, skipped %d.", res.Files, res.Written, res.Skipped)) + return nil + }, + } +} diff --git a/controllers/api/posts.go b/controllers/api/posts.go index edc5928..747c4c6 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -468,6 +468,8 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc { stored.AttachmentID = idStr stored.Field = "featured_images" purgeSiteCache(r, app, post) + // The variants are written by a job, never in this request (D-85). + classes.DispatchImageVariants(r.Context(), app, []uint{stored.ID}) wire.WriteJSON(w, http.StatusCreated, map[string]any{ "data": map[string]any{ "id": stored.ID, @@ -535,6 +537,10 @@ func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } + // The variants' blobs share the thumbnail prefix of BlobKeys. + if err := db.WithContext(r.Context()).Where("file_id = ?", file.ID).Delete(&models.ImageVariant{}).Error; err != nil { + pluginLog.Warn("journal: delete image variants", "file", file.ID, "error", err) + } if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil { _ = attach.DeleteKeys(r.Context(), bucket, keys) } @@ -761,13 +767,48 @@ func featuredImages(db *gorm.DB, p *models.Post) []map[string]any { ).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil { return []map[string]any{} } + variants := imageVariants(db, files) out := make([]map[string]any, 0, len(files)) for i := range files { + list := variants[files[i].ID] + if list == nil { + list = []map[string]any{} + } out = append(out, map[string]any{ "id": files[i].ID, "url": files[i].URL(), "file_name": files[i].FileName, "file_size": files[i].FileSize, + "variants": list, + }) + } + return out +} + +// imageVariants lists the variants of files in one query, by file and +// width (D-85): {width, height, url, webp_url} and, on each, the +// original's source_width and source_height. +func imageVariants(db *gorm.DB, files []attach.File) map[uint][]map[string]any { + out := map[uint][]map[string]any{} + if len(files) == 0 { + return out + } + ids := make([]uint, 0, len(files)) + for _, f := range files { + ids = append(ids, f.ID) + } + var rows []models.ImageVariant + if err := db.Where("file_id IN ?", ids).Order("file_id ASC, width ASC").Find(&rows).Error; err != nil { + return out + } + for _, v := range rows { + out[v.FileID] = append(out[v.FileID], map[string]any{ + "width": v.Width, + "height": v.Height, + "url": attach.PublicURL(v.Key), + "webp_url": attach.PublicURL(v.WebPKey), + "source_width": v.SourceWidth, + "source_height": v.SourceHeight, }) } return out diff --git a/controllers/posts.go b/controllers/posts.go index a5bae9c..190e3f2 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -264,6 +264,9 @@ func (c postsAdminController) afterPostWrite(ctx context.Context, model any) err if post, ok := model.(*models.Post); ok && post != nil { purger, tx := siteCachePurger(ctx, c.app) classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug) + // Featured images uploaded in the form are bound by this save; + // their variants are queued once it commits (D-85). + classes.DispatchPostImageVariantsAfterCommit(ctx, tx, c.application(), post.ID) } return nil } diff --git a/go.mod b/go.mod index 378aca2..419e9a4 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gen2brain/webp v0.6.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/go.sum b/go.sum index bac69f7..d3c8246 100644 --- a/go.sum +++ b/go.sum @@ -169,6 +169,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= +github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..a01244b --- /dev/null +++ b/jobs.go @@ -0,0 +1,42 @@ +package journal + +import ( + "context" + "errors" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/conga" + "git.golem15.com/golem15/summercms/modules/pact" + "gocloud.dev/blob" + "gorm.io/gorm" +) + +var _ pact.HasJobs = (*Plugin)(nil) + +// Jobs registers the image variants job (golem15.journal.image_variants, +// D-85): thumbnails and WebP copies of featured images, outside the upload +// request, three attempts. +func (p *Plugin) Jobs() []pact.Job { + return []pact.Job{conga.Job(p.imageVariants, conga.MaxAttempts(3))} +} + +// imageVariants runs one image variants job. +func (p *Plugin) imageVariants(ctx context.Context, args classes.ImageVariantsArgs) error { + if p.app == nil { + return errors.New("journal: app is not booted") + } + db, ok := p.app.Lookup[*gorm.DB]() + if !ok || db == nil { + return errors.New("journal: database is not configured") + } + bucket, ok := p.app.Lookup[*blob.Bucket]() + if !ok || bucket == nil { + return errors.New("journal: the uploads bucket is not configured") + } + res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(p.app.Config), args.FileIDs) + if err != nil { + return err + } + pluginLog.Info("journal: image variants", "files", res.Files, "written", res.Written, "skipped", res.Skipped) + return nil +} diff --git a/models/image_variant.go b/models/image_variant.go new file mode 100644 index 0000000..988b454 --- /dev/null +++ b/models/image_variant.go @@ -0,0 +1,27 @@ +package models + +import "time" + +// ImageVariant is one size of a featured image on +// golem15_journal_image_variants (P33-43): the thumbnail of the original at +// Width by Height (Key, in the original's format) and its WebP copy +// (WebPKey), both blob keys of the uploads bucket. SourceWidth and +// SourceHeight are the original's size. One row per file and width, so the +// API lists a post's variants with one query instead of storage lookups. +type ImageVariant struct { + ID uint `gorm:"column:id;primaryKey"` + FileID uint `gorm:"column:file_id"` + Width int `gorm:"column:width"` + Height int `gorm:"column:height"` + Key string `gorm:"column:key"` + WebPKey string `gorm:"column:webp_key"` + SourceWidth int `gorm:"column:source_width"` + SourceHeight int `gorm:"column:source_height"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (ImageVariant) TableName() string { return "golem15_journal_image_variants" } + +func init() { + Register(ImageVariant{}) +} diff --git a/plugin.go b/plugin.go index bfb6dbb..a7ae842 100644 --- a/plugin.go +++ b/plugin.go @@ -123,6 +123,7 @@ func (p *Plugin) Commands() []bonfire.Command { console.ImportPosts(p.app), console.Reindex(p.app), console.FixHeadings(p.app), + console.ImageVariants(p.app), } } diff --git a/related_media_test.go b/related_media_test.go index d9394d4..9f47636 100644 --- a/related_media_test.go +++ b/related_media_test.go @@ -8,13 +8,27 @@ package journal_test // and next_in_category within the post's first category by name. import ( + "bytes" "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "io" "net/http" "slices" + "strconv" + "strings" "testing" "time" + journal "git.golem15.com/golem15/sm-journal-plugin" + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/conga" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gocloud.dev/blob" ) // relatedFixture plants the target post and its candidates. Each candidate's @@ -232,3 +246,213 @@ func TestRelatedScoredCountClamped(t *testing.T) { }) } } + +// jpegOf is a JPEG picture of w by h. +func jpegOf(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{uint8(x), uint8(y), 90, 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// bucketKeys lists every key of the uploads bucket. +func bucketKeys(t *testing.T, env *adminEnv) []string { + t.Helper() + bucket, ok := env.app.Lookup[*blob.Bucket]() + if !ok { + t.Fatal("no uploads bucket") + } + var out []string + iter := bucket.List(nil) + for { + obj, err := iter.Next(t.Context()) + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + out = append(out, obj.Key) + } + slices.Sort(out) + return out +} + +// queuedVariantJobs counts the image variants jobs on the queue. +func queuedVariantJobs(t *testing.T, env *adminEnv) int64 { + t.Helper() + var n int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.ImageVariantsKind).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// runVariantsJob runs the Journal's image variants job. +func runVariantsJob(t *testing.T, env *adminEnv, ids ...uint) { + t.Helper() + for _, p := range env.plugins { + if jp, ok := p.(*journal.Plugin); ok { + for _, j := range jp.Jobs() { + if info, ok := conga.Describe(j); ok && info.Kind == classes.ImageVariantsKind { + if err := j.Work(t.Context(), classes.ImageVariantsArgs{FileIDs: ids}); err != nil { + t.Fatalf("image variants job: %v", err) + } + return + } + } + } + } + t.Fatal("the Journal registers no image variants job") +} + +// TestImageVariants covers the featured image variants (D-85, P33-43): an +// upload answers before any encoding and queues the job; the job writes +// the default widths no wider than the original with a WebP copy of each; +// the post API lists them as featured_images[].variants; a second run +// writes nothing; an admin save of a post whose image lacks variants +// queues the job after the commit. +func TestImageVariants(t *testing.T) { + if got := classes.ImageWidths(nil); !slices.Equal(got, []int{480, 800, 1200}) { + t.Fatalf("default widths = %v", got) + } + if testShort() { + t.Skip("Postgres") + } + env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=1200, 480,800,480") + if got := classes.ImageWidths(env.app.Config); !slices.Equal(got, []int{480, 800, 1200}) { + t.Fatalf("widths from the environment = %v", got) + } + token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts) + post := models.Post{Title: "Mushrooms", Slug: "mushrooms", Content: "

x

", Published: true} + at := time.Now().UTC().Add(-time.Hour) + post.PublishedAt = &at + if err := env.gdb.Create(&post).Error; err != nil { + t.Fatal(err) + } + + before := queuedVariantJobs(t, env) + rec := env.callMultipart(t, http.MethodPost, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", post.ID), token, nil, "file", "cover.jpg", jpegOf(t, 1000, 600)) + if rec.Code != http.StatusCreated { + t.Fatalf("upload = %d %s", rec.Code, rec.Body.String()) + } + var up struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil || up.Data.ID == 0 { + t.Fatalf("upload body %s: %v", rec.Body.String(), err) + } + // The request encoded nothing: one key (the original), no rows, one job. + if keys := bucketKeys(t, env); len(keys) != 1 { + t.Fatalf("after the upload the bucket holds %v, want only the original", keys) + } + var rows int64 + env.gdb.Model(&models.ImageVariant{}).Count(&rows) + if rows != 0 || queuedVariantJobs(t, env) != before+1 { + t.Fatalf("after the upload: %d variant rows, %d jobs (was %d)", rows, queuedVariantJobs(t, env), before) + } + + runVariantsJob(t, env, up.Data.ID) + var variants []models.ImageVariant + if err := env.gdb.Where("file_id = ?", up.Data.ID).Order("width ASC").Find(&variants).Error; err != nil { + t.Fatal(err) + } + if len(variants) != 2 || variants[0].Width != 480 || variants[0].Height != 288 || variants[1].Width != 800 || variants[1].Height != 480 { + t.Fatalf("variants = %+v, want 480x288 and 800x480 (1200 is wider than the original)", variants) + } + bucket, _ := env.app.Lookup[*blob.Bucket]() + for _, v := range variants { + raw, err := bucket.ReadAll(t.Context(), v.WebPKey) + if err != nil || len(raw) < 12 || string(raw[0:4]) != "RIFF" || string(raw[8:12]) != "WEBP" { + t.Fatalf("webp %s: err=%v", v.WebPKey, err) + } + if ok, err := bucket.Exists(t.Context(), v.Key); err != nil || !ok || !strings.HasSuffix(v.WebPKey, ".webp") || v.SourceWidth != 1000 || v.SourceHeight != 600 { + t.Fatalf("variant %+v: thumbnail exists %v err=%v", v, ok, err) + } + } + keys := bucketKeys(t, env) + + show := showPost(t, env, "mushrooms") + var images []struct { + ID uint `json:"id"` + Variants []struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + WebPURL string `json:"webp_url"` + SourceWidth int `json:"source_width"` + SourceHeight int `json:"source_height"` + } `json:"variants"` + } + if err := json.Unmarshal(show.Data["featured_images"], &images); err != nil { + t.Fatal(err) + } + if len(images) != 1 || len(images[0].Variants) != 2 { + t.Fatalf("featured_images = %s", show.Data["featured_images"]) + } + v := images[0].Variants[1] + if v.Width != 800 || v.Height != 480 || !strings.HasSuffix(v.URL, "_800_480_0_0_exact.jpg") || !strings.HasSuffix(v.WebPURL, "_800_480_0_0_exact.webp") || v.SourceWidth != 1000 { + t.Fatalf("variant answer = %+v", v) + } + + // A second run writes nothing. + runVariantsJob(t, env, up.Data.ID) + if again := bucketKeys(t, env); !slices.Equal(again, keys) { + t.Fatalf("second run changed the bucket: %v -> %v", keys, again) + } + env.gdb.Model(&models.ImageVariant{}).Count(&rows) + if rows != 2 { + t.Fatalf("variant rows after the second run = %d", rows) + } + + // The admin save of a post whose image has no variants queues the job. + other := models.Post{Title: "Other", Slug: "other", Content: "

y

"} + if err := env.gdb.Create(&other).Error; err != nil { + t.Fatal(err) + } + if err := bucket.WriteAll(t.Context(), attach.BlobKey("abc123def456ghi789jkl0.jpg"), jpegOf(t, 600, 400), nil); err != nil { + t.Fatal(err) + } + file := attach.File{DiskName: "abc123def456ghi789jkl0.jpg", FileName: "b.jpg", ContentType: "image/jpeg", Field: "featured_images", AttachmentType: other.MorphName(), AttachmentID: strconv.FormatUint(uint64(other.ID), 10)} + if err := env.gdb.Create(&file).Error; err != nil { + t.Fatal(err) + } + editor := token + queued := queuedVariantJobs(t, env) + env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, other.ID)), editor, map[string]any{ + "title": map[string]any{"en": "Other"}, "slug": map[string]any{"en": "other"}, "content": map[string]any{"en": "

y

"}, + }) + if got := queuedVariantJobs(t, env); got != queued+1 { + t.Fatalf("admin save queued %d jobs, want 1", got-queued) + } + // Saving the first post again queues nothing: its image has every width + // no wider than the original. + env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, post.ID)), editor, map[string]any{ + "title": map[string]any{"en": "Mushrooms"}, "slug": map[string]any{"en": "mushrooms"}, "content": map[string]any{"en": "

x

"}, + }) + if got := queuedVariantJobs(t, env); got != queued+1 { + t.Fatalf("saving a post whose variants exist queued a job (%d)", got-queued) + } + + // Deleting the image deletes its variant rows and blobs. + env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", post.ID, up.Data.ID), token, nil) + env.gdb.Model(&models.ImageVariant{}).Where("file_id = ?", up.Data.ID).Count(&rows) + if rows != 0 { + t.Fatalf("variant rows after the delete = %d", rows) + } + for _, k := range bucketKeys(t, env) { + if strings.Contains(k, fmt.Sprintf("thumb_%d_", up.Data.ID)) { + t.Fatalf("a variant blob survived the delete: %s", k) + } + } +} diff --git a/updates/202610280002_create_golem15_journal_image_variants.go b/updates/202610280002_create_golem15_journal_image_variants.go new file mode 100644 index 0000000..c6dfb84 --- /dev/null +++ b/updates/202610280002_create_golem15_journal_image_variants.go @@ -0,0 +1,41 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createImageVariants creates golem15_journal_image_variants (P33-43): one +// row per featured image and width with the thumbnail's key, its WebP +// copy's key and the original's size. file_id names a system_files row +// without a foreign key (the plugin's migrations run without the framework +// tables in its tests); the API deletes the rows with the file, and rows +// of a file removed elsewhere are never listed. +var createImageVariants = []*gormigrate.Migration{ + { + ID: "202610280002_create_golem15_journal_image_variants", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec(`CREATE TABLE golem15_journal_image_variants ( + id BIGSERIAL PRIMARY KEY, + file_id BIGINT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + key TEXT NOT NULL, + webp_key TEXT NOT NULL, + source_width INTEGER NOT NULL, + source_height INTEGER NOT NULL, + created_at TIMESTAMP(0) NULL +)`).Error; err != nil { + return err + } + return tx.Exec(`CREATE UNIQUE INDEX golem15_journal_image_variants_file_width ON golem15_journal_image_variants (file_id, width)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_image_variants`).Error + }, + }, +} + +func init() { + Register(createImageVariants...) +}