- 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
210 lines
12 KiB
Markdown
210 lines
12 KiB
Markdown
# 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.
|
|
|
|
```go
|
|
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`:
|
|
|
|
```sh
|
|
git submodule add git@git.golem15.com:golem15/sm-journal-plugin.git plugins/golem15/journal
|
|
```
|
|
|
|
Add it to the application's `go.work`:
|
|
|
|
```text
|
|
use (
|
|
.
|
|
./plugins/golem15/journal
|
|
)
|
|
```
|
|
|
|
Require it in the application's `go.mod` with a local replace:
|
|
|
|
```text
|
|
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`:
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```http
|
|
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.
|
|
|
|
```json
|
|
{"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.
|
|
|
|
```json
|
|
{"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`:
|
|
|
|
```json
|
|
{"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):
|
|
|
|
```json
|
|
{"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`):
|
|
|
|
```http
|
|
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:
|
|
|
|
```go
|
|
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. |
|
|
|
|
```sh
|
|
./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
|
|
|
|
```sh
|
|
go test ./...
|
|
```
|
|
|
|
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|