Files
sm-journal-plugin/README.md
Jakub Zych 528d2d8451 fix(classes): keep raw HTML in content_html behind the safety gate
- FormatHTML renders with goldmark html.WithUnsafe(), as Winter's
  Markdown::parse passes raw HTML through, so an admin save, an import or
  an API write no longer turns imported HTML into raw HTML omitted
- rejectUnsafe still runs after the render and refuses script, iframe,
  object and embed tags, event handlers and javascript, vbscript and data
  URLs; API response shapes are unchanged
- TestFormatHTMLKeepsRawHTML covers HTML paragraphs and a raw footer
2026-10-09 20:58:49 +02:00

12 KiB

golem15.journal

The compiled journal plugin for SummerCMS applications: translatable posts and categories, tags, a settings singleton, and the tables a host migrate creates for a blog.

import _ "git.golem15.com/golem15/sm-journal-plugin"

Module git.golem15.com/golem15/sm-journal-plugin, package journal, plugin ID golem15.journal.

Overview

The plugin is a compiled SummerCMS plugin. Its init registers it with the framework, so an application loads it by listing it in summer.yaml; summer build then emits the blank import above into the application's plugins.gen.go.

It requires golem15.translate. Post and Category store default-locale values on host columns and other locales in golem15_translate_attributes. MorphName is the PHP class string (Golem15\Journal\Models\Post, Golem15\Journal\Models\Category); it is never reflect.Type.String().

WinterCMS theme components such as journalPost and journalPosts are a later successor, not implemented here.

Features

  • Posts. models.Post maps golem15_journal_posts. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include redactor_id, content_html, or user_id. Attachments featured_images and content_images use system_files under the same MorphName. YAML admin uses mltext/mlmarkdown. classes.FormatHTML regenerates content_html on admin save. It passes raw HTML in content through unchanged (Winter parity), so imported HTML bodies survive a save, and its gate still refuses script, iframe, object and embed tags, event handlers and javascript, vbscript and data URLs on every write path (admin save, journal:import-posts, Journal API writes). The API response shapes are unchanged.
  • 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 as a textarea. 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.
  • 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.
  • Public HTTP. Anonymous GET /_journal/api/v1/posts, /posts/{slug}, /categories, /tags, /rss, /authors/{author}, and /authors/{author}/posts. Writes under the same prefix require a cabana backend JWT (aud=backend).
  • 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. Eight gormigrate steps create the six journal tables, the author-slug column and the category content column. AutoMigrate is not used. Table names are golem15_journal_* only.

Usage

Mount the plugin in an application as a git submodule at plugins/golem15/journal:

git submodule add git@git.golem15.com:golem15/sm-journal-plugin.git plugins/golem15/journal

Add it to the application's go.work:

use (
	.
	./plugins/golem15/journal
)

Require it in the application's go.mod with a local replace:

require git.golem15.com/golem15/sm-journal-plugin v0.0.0

replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal

List it in summer.yaml after translate and run summer build:

module: git.golem15.com/golem15/acme
binary: acme
plugins:
  - id: golem15.translate
    module: git.golem15.com/golem15/sm-translate-plugin
  - id: golem15.journal
    module: git.golem15.com/golem15/sm-journal-plugin

The plugin's own go.mod replaces the framework with ../summercms.go for a sibling checkout next to summercms.go. A host application that mounts the plugin at plugins/golem15/journal should replace the framework from the host go.work.

Requires() is exactly golem15.translate.

A blog frontend lists published posts and fetches one by slug without a token:

GET /_journal/api/v1/posts
GET /_journal/api/v1/posts/hello-world
GET /_journal/api/v1/categories
GET /_journal/api/v1/tags
GET /_journal/api/v1/rss
GET /_journal/api/v1/authors/jane-doe
GET /_journal/api/v1/authors/jane-doe/posts?page=2

Public categories API

GET /_journal/api/v1/categories returns {"data":[...]}, a tree of category rows. Each row has the keys id, name, slug, description, content, parent_id, nest_depth and post_count, plus children only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds include_empty=1.

content is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is null when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first.

The admin Categories form refuses content that fails classes.CheckHTML with a 422 on content, and stores blank content as null. Rows written by SQL or by import commands bypass that gate, so consumers keep their own gate.

{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"<h2>Acme news</h2><p>Pillar copy</p>","parent_id":null,"nest_depth":0,"post_count":3}]}

Public author API

GET /_journal/api/v1/authors/{author} returns one author's public profile without a token. {author} resolves in this order:

  1. backend_users.golem15_bloghub_author_slug, the public author slug. A user with a slug resolves even before their first post.
  2. backend_users.login, but only for a user with at least one published post. That login is already public through the post author object, so other backend accounts cannot be enumerated.

Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post author object carries the login and no slug.

{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}

The author object has exactly these four keys. slug is the canonical identifier: the author slug when set, otherwise the login, so a consumer that links by login sets its canonical URL from slug. Missing names are empty strings. The object carries no contact, credential, permission or role data and has no login key; slug falls back to the login only for an author whose login is already public through a published post.

An unknown author, a login without published posts, a soft-deleted user and a malformed value (empty, over 255 bytes, invalid UTF-8 or containing a control character) all answer 404:

{"error":"Author not found"}

GET /_journal/api/v1/authors/{author}/posts lists that author's published posts (published true and published_at in the past), resolving {author} the same way. Rows have the same shape as GET /_journal/api/v1/posts rows. Bearer tokens are ignored, so an editor also sees published posts only. Query keys:

Key Default Notes
page 1 Values below 1 or non-numeric fall back to page 1.
per_page 9 Clamped to 1..30.
sort published_at desc Same columns as /posts: published_at, created_at, updated_at, title, id, each asc or desc.

The response uses the /posts pagination envelope (rows trimmed here):

{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}

An author that does not resolve answers the same 404 {"error":"Author not found"} as the profile route.

Editors create a post with a cabana backend JWT (audience backend):

POST /_journal/api/v1/posts
Authorization: Bearer <backend-access-token>
Content-Type: application/json

{"title":"Hello","content":"A blog post"}

A blog post model already declares translatable fields:

func (Post) Translatable() []string {
	return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}
}
func (Post) TranslatableIndexes() []string { return []string{"slug"} }
func (Post) MorphName() string             { return `Golem15\Journal\Models\Post` }

API reference

Identifier Description
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.Migrations gormigrate steps registered by this plugin.
journal.Plugin.Models Models registered by this plugin.
journal.Plugin.Commands Registers journal:export-posts and journal:import-posts.
journal.Plugin.Routes Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under /_journal/api/v1.
journal.Plugin.Buckets Limiter buckets journal-public-api and journal-api (Max 120).
models.Post Post row; TableName is golem15_journal_posts.
models.Post.MorphName PHP class string Golem15\Journal\Models\Post.
models.Post.Fillable Allow-list excluding redactor_id, content_html, and user_id.
models.Post.AttachRelations featured_images and content_images attachMany.
models.Post.SearchableAs Index name golem15_journal_posts.
models.Post.ShouldBeSearchable False when unpublished or search_use_typesense is off.
models.Post.ToSearchableArray Typesense document built after a committed save.
models.Post.CanEdit Owner or golem15.journal.access_other_posts.
models.Category Category row; TableName is golem15_journal_categories; Content holds the HTML body served as content.
models.Category.MorphName PHP class string Golem15\Journal\Models\Category.
models.Category.Fillable name, slug, code, description, content, parent_id.
models.Tag Tag row; TableName is golem15_journal_tags.
models.Tag.Fillable name, slug, description.
models.Settings Singleton settings row; TableName is golem15_journal_settings.
models.All Models registered by this plugin.
updates.All gormigrate steps registered by this plugin.
classes.CheckHTML The FormatHTML safety gate for HTML stored as is, such as category content.

CLI commands

Both commands write JSON files (one per post slug), matching the PHP journal:export-posts / journal:import-posts tools. They are not a CSV ImportExport framework. A blog application binary registers them through Plugin.Commands.

Command Arguments and flags Effect
journal:export-posts --path (default journal-export), --dry-run Writes one JSON file per post, including non-default-locale translations. Attachments are omitted.
journal:import-posts --path (default journal-export), --force, --dry-run Creates posts from those files. Existing slugs are skipped unless --force updates them.
./bin/acme journal:export-posts --path journal-export
./bin/acme journal:import-posts --path journal-export --dry-run
./bin/acme journal:import-posts --path journal-export --force

Dependencies

  • SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
  • Compiled plugin: golem15.translate.
  • Third-party: gorm.io/gorm, github.com/go-gormigrate/gormigrate/v2, github.com/yuin/goldmark, gocloud.dev. Test-only: github.com/testcontainers/testcontainers-go.

Testing

go test ./...

Postgres integration smokes start Docker unless -short. Missing Docker is a failure, not a skip, unless -short.