feat(15-02): add Posts admin with mlmarkdown and FormatHTML

YAML-driven Posts screen stores translatable markdown and regenerates
content_html in the plugin. Publish writes without access_publish are
refused; list and form queries stay on the principal without
access_other_posts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 18:41:10 +02:00
parent 0bf2d39323
commit dea0934950
17 changed files with 1138 additions and 2 deletions

37
admin.go Normal file
View File

@@ -0,0 +1,37 @@
package journal
import (
"embed"
"io/fs"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// adminFS holds the admin YAML and the nav SVG file by file: the controllers
// directory also holds Go sources that are not admin assets.
//
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
//go:embed assets/images/journal-icon.svg
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// AdminControllers hands the controllers a lazy accessor of the booted app,
// so they read the database handle at request time.
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App {
if p == nil {
return nil
}
return p.app
})
}
var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)

39
admin_navigation.go Normal file
View File

@@ -0,0 +1,39 @@
package journal
import (
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Navigation is the plugin's Winter registerNavigation() entry: the main
// item journal (order 300) with its side menu. Categories and tags side
// items are added when those controllers register (Task 2): cabana boot
// refuses a NavigationItem.Controller that is not an AdminController.
func (*Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{
{
Code: "journal",
Label: "golem15.journal::lang.journal.menu_label",
Icon: "pencil",
Permissions: []string{"golem15.journal.*"},
Order: 300,
Controller: controllers.PostsControllerID,
SideMenu: []pact.NavigationItem{
{
Code: "new_post",
Label: "golem15.journal::lang.posts.new_post",
Icon: "plus",
Permissions: []string{controllers.PermissionAccessPosts},
Controller: controllers.PostsControllerID,
},
{
Code: "posts",
Label: "golem15.journal::lang.journal.posts",
Icon: "copy",
Permissions: []string{controllers.PermissionAccessPosts},
Controller: controllers.PostsControllerID,
},
},
},
}
}

54
admin_permissions.go Normal file
View File

@@ -0,0 +1,54 @@
package journal
import "git.golem15.com/golem15/summercms/modules/pact"
const journalPermissionTab = "golem15.journal::lang.journal.tab"
// Permissions is the plugin's Winter registerPermissions() catalog. Codes
// match PHP Plugin.php. Roles default to developer like the user plugin.
func (*Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{
Code: "golem15.journal.manage_settings",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.manage_settings",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_posts",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_posts",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_categories",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_categories",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_other_posts",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_other_posts",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_import_export",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_import_export",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_publish",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_publish",
Roles: []string{"developer"},
},
{
Code: "golem15.journal.access_tags",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.access_tags",
Roles: []string{"developer"},
},
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="64px" height="64px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.4.4 (17249) - http://www.bohemiancoding.com/sketch -->
<title>journal-icon</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="Group-+-Group" sketch:type="MSLayerGroup">
<g id="Group" sketch:type="MSShapeGroup">
<path d="M0.754,3.84 C0.754,1.719 2.473,0 4.594,0 L32.754,0 L48.754,13.44 L48.754,60.16 C48.754,62.281 47.035,64 44.914,64 L4.594,64 C2.473,64 0.754,62.281 0.754,60.16 L0.754,3.84 Z" id="Fill-392" fill="#FFFFFF"></path>
<path d="M32.754,0 L32.754,9.6 C32.754,11.721 34.473,13.44 36.594,13.44 L48.754,13.44 L32.754,0 Z" id="Fill-393" fill="#F0F1F1"></path>
<path d="M38.0416209,23 L12.3559338,23 C11.4141253,23 10.6435547,22.55 10.6435547,22 C10.6435547,21.45 11.4141253,21 12.3559338,21 L38.0416209,21 C38.9834294,21 39.754,21.45 39.754,22 C39.754,22.55 38.9834294,23 38.0416209,23 Z" id="Fill-397" fill="#E2E4E5"></path>
<path d="M29.4797252,27 C29.4797252,27.55 28.7091546,28 27.767346,28 L12.3559338,28 C11.4141253,28 10.6435547,27.55 10.6435547,27 C10.6435547,26.45 11.4141253,26 12.3559338,26 L27.767346,26 C28.7091546,26 29.4797252,26.45 29.4797252,27" id="Fill-398" fill="#E2E4E5"></path>
<path d="M23.767346,11 L12.3559338,11 C11.4141253,11 10.6435547,10.55 10.6435547,10 C10.6435547,9.45 11.4141253,9 12.3559338,9 L23.767346,9 C24.7091546,9 25.4797252,9.45 25.4797252,10 C25.4797252,10.55 24.7091546,11 23.767346,11 Z" id="Fill-398-Copy" fill="#E1332C"></path>
<path d="M39.754,35 C39.754,35.55 38.9834294,36 38.0416209,36 L12.3559338,36 C11.4141253,36 10.6435547,35.55 10.6435547,35 C10.6435547,34.45 11.4141253,34 12.3559338,34 L38.0416209,34 C38.9834294,34 39.754,34.45 39.754,35" id="Fill-399" fill="#E2E4E5"></path>
<path d="M29.4797252,40 C29.4797252,40.55 28.7091546,41 27.767346,41 L12.3559338,41 C11.4141253,41 10.6435547,40.55 10.6435547,40 C10.6435547,39.45 11.4141253,39 12.3559338,39 L27.767346,39 C28.7091546,39 29.4797252,39.45 29.4797252,40" id="Fill-400" fill="#E2E4E5"></path>
<path d="M39.754,48 C39.754,48.55 38.9834294,49 38.0416209,49 L12.3559338,49 C11.4141253,49 10.6435547,48.55 10.6435547,48 C10.6435547,47.45 11.4141253,47 12.3559338,47 L38.0416209,47 C38.9834294,47 39.754,47.45 39.754,48" id="Fill-401" fill="#E2E4E5"></path>
<path d="M29.4797252,53 C29.4797252,53.55 28.7091546,54 27.767346,54 L12.3559338,54 C11.4141253,54 10.6435547,53.55 10.6435547,53 C10.6435547,52.45 11.4141253,52 12.3559338,52 L27.767346,52 C28.7091546,52 29.4797252,52.45 29.4797252,53" id="Fill-402" fill="#E2E4E5"></path>
</g>
<g id="Group" transform="translate(27.000000, 16.000000)" sketch:type="MSShapeGroup">
<path d="M31.8979,11.4983 L10.6949,35.4653 L4.8179,36.6753 C4.6479,35.8573 4.2249,35.0823 3.5509,34.4863 C2.8769,33.8893 2.0559,33.5643 1.2229,33.4953 L1.7069,27.5143 L22.9099,3.5473 L31.8979,11.4983 Z" id="Fill-647" fill="#F4D0A1"></path>
<path d="M31.8692,11.4729 C31.8852,11.4869 11.1512,34.9499 11.1512,34.9499 C11.7392,33.7029 11.5032,32.1759 10.4362,31.2309 C9.3092,30.2349 7.6512,30.2359 6.5312,31.1689 C7.2692,29.8979 7.0682,28.2519 5.9422,27.2549 C4.8622,26.3009 3.2942,26.2619 2.1802,27.0819 C2.1442,27.1089 22.8852,3.5759 22.8852,3.5759 C22.8992,3.5599 31.8692,11.4729 31.8692,11.4729 Z" id="Fill-648" fill="#FC5B55"></path>
<path d="M31.8979,11.4983 L22.9099,3.5473 L24.2349,2.0493 L33.2229,10.0003 L31.8979,11.4983 Z" id="Fill-649" fill="#FACB1B"></path>
<path d="M32.7497,1.5706 L32.6597,1.4916 C30.2027,-0.6824 26.4487,-0.4524 24.2747,2.0046 L24.2357,2.0496 L33.2227,10.0006 L33.2627,9.9556 C35.4367,7.4986 35.2067,3.7446 32.7497,1.5706" id="Fill-650" fill="#F89392"></path>
<path d="M11.1258,34.9796 C11.7438,33.7326 11.5138,32.1846 10.4358,31.2306 C9.3118,30.2366 7.6588,30.2356 6.5388,31.1626 C6.5198,31.1786 27.3788,7.5516 27.3788,7.5516 C27.3928,7.5356 31.8698,11.4736 31.8698,11.4736 C31.8848,11.4866 11.1258,34.9796 11.1258,34.9796 Z" id="Fill-651" fill="#E1332C"></path>
<path d="M4.8181,36.6754 L0.9001,37.4824 L1.2231,33.4954 C2.0561,33.5644 2.8771,33.8894 3.5511,34.4864 C4.2251,35.0824 4.6481,35.8574 4.8181,36.6754" id="Fill-652" fill="#3E3E3F"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

51
classes/format_html.go Normal file
View File

@@ -0,0 +1,51 @@
package classes
import (
"bytes"
"fmt"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
)
var (
markdownEngine = goldmark.New(
goldmark.WithExtensions(extension.Footnote, extension.Table),
goldmark.WithParserOptions(parser.WithAttribute()),
)
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
)
// FormatHTML converts journal post markdown to stored HTML using goldmark
// footnote, table, and attribute extensions, then the same rejectUnsafe gate
// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown.
func FormatHTML(src string) (string, error) {
var buf bytes.Buffer
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
return "", fmt.Errorf("journal: markdown: %w", err)
}
html := buf.String()
if err := rejectUnsafe(html); err != nil {
return "", err
}
return html, nil
}
func rejectUnsafe(html string) error {
if markdownUnsafeTag.MatchString(html) {
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")
}
if markdownEventHandler.MatchString(html) {
return fmt.Errorf("journal: rendered HTML contains event handlers")
}
if markdownDangerousURL.MatchString(html) {
return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme")
}
return nil
}

View File

@@ -0,0 +1,29 @@
package controllers
import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// AdminControllers is the plugin's admin controller list. app returns the
// booted application; it is asked on every call, so a controller never keeps
// a database handle of its own.
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
postsAdminController{app: app},
}
}
func init() {
var _ pact.AdminController = postsAdminController{}
var _ pact.AdminPermissioned = postsAdminController{}
var _ pact.AdminRecordSource = postsAdminController{}
var _ pact.ListExtendQuery = postsAdminController{}
var _ pact.FormExtendQuery = postsAdminController{}
var _ pact.ListRowStates = postsAdminController{}
var _ pact.FilterOptions = postsAdminController{}
var _ pact.FormBeforeCreate = postsAdminController{}
var _ pact.FormBeforeUpdate = postsAdminController{}
var _ cabana.FieldRelationProvider = postsAdminController{}
}

208
controllers/posts.go Normal file
View File

@@ -0,0 +1,208 @@
package controllers
import (
"context"
"errors"
"strconv"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
const (
// PostsControllerID is the cabana controller id of the Posts screen.
PostsControllerID = "golem15.journal.posts"
// PermissionAccessPosts is Posts::$requiredPermissions.
PermissionAccessPosts = "golem15.journal.access_posts"
// PermissionAccessOtherPosts lets an administrator see every author's posts.
PermissionAccessOtherPosts = "golem15.journal.access_other_posts"
// PermissionAccessPublish is required to persist published=true or published_at.
PermissionAccessPublish = "golem15.journal.access_publish"
)
type postsAdminController struct {
app func() *backpack.App
}
func (postsAdminController) ID() string { return PostsControllerID }
func (postsAdminController) ModelName() string { return `Golem15\Journal\Models\Post` }
func (postsAdminController) ConfigDir() string { return "controllers/posts" }
func (postsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessPosts}
}
func (postsAdminController) NewRecord() any { return &models.Post{} }
func (c postsAdminController) application() *backpack.App {
if c.app == nil {
return nil
}
return c.app()
}
func (c postsAdminController) pool() *gorm.DB {
app := c.application()
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
func (c postsAdminController) db(ctx context.Context) *gorm.DB {
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx
}
return c.pool()
}
func (postsAdminController) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return restrictToOwnPosts(ctx, db)
}
func (postsAdminController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return restrictToOwnPosts(ctx, db)
}
func restrictToOwnPosts(ctx context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
principal, ok := bouncer.User(ctx)
if !ok || principal == nil || !principal.Backend {
return db.Where("1 = 0")
}
if cabana.Allows(principal, []string{PermissionAccessOtherPosts}) {
return db
}
return db.Where("user_id = ?", principal.ID)
}
func (postsAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
out := make([][]pact.RowState, len(records))
for i, record := range records {
post, ok := record.(*models.Post)
if !ok || post == nil {
return nil, errors.New("posts admin: list record is not a post")
}
if !post.Published {
out[i] = append(out[i], pact.RowStateDisabled)
}
}
return out, nil
}
func (c postsAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterCategories {
return nil
}
db := c.pool()
if db == nil {
return nil
}
var cats []models.Category
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&cats).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(cats))
for _, cat := range cats {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(cat.ID), 10), Label: cat.Name})
}
return out
}
func (postsAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
{
Field: "user",
Kind: "belongsTo",
NewRelated: func() any { return &cabana.BackendUser{} },
ForeignKey: "user_id",
LabelColumn: "login",
WritableForeignKey: true,
},
{
Field: "categories",
Kind: "belongsToMany",
NewRelated: func() any { return &models.Category{} },
NewPivot: func() any { return &models.PostCategory{} },
ParentForeignKey: "post_id",
RelatedForeignKey: "category_id",
LabelColumn: "name",
},
{
Field: "tags",
Kind: "belongsToMany",
NewRelated: func() any { return &models.Tag{} },
NewPivot: func() any { return &models.PostTag{} },
ParentForeignKey: "post_id",
RelatedForeignKey: "tag_id",
LabelColumn: "name",
},
}
}
func (c postsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
post, ok := model.(*models.Post)
if !ok || post == nil {
return errors.New("posts admin: form model is not a post")
}
if post.UserID == nil || *post.UserID == 0 {
principal, ok := bouncer.User(ctx)
if ok && principal != nil && principal.Backend {
id := principal.ID
post.UserID = &id
}
}
return preparePostWrite(ctx, post)
}
func (c postsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
post, ok := model.(*models.Post)
if !ok || post == nil {
return errors.New("posts admin: form model is not a post")
}
return preparePostWrite(ctx, post)
}
func preparePostWrite(ctx context.Context, post *models.Post) error {
if err := refuseUnpublishedWrite(ctx, post); err != nil {
return err
}
if post.Published && post.PublishedAt == nil {
return &cabana.ValidationError{Details: map[string]any{
"published_at": []string{"golem15.journal::lang.post.published_validation"},
}}
}
html, err := classes.FormatHTML(post.Content)
if err != nil {
return &cabana.ValidationError{Details: map[string]any{
"content": []string{err.Error()},
}}
}
post.ContentHTML = html
return nil
}
func refuseUnpublishedWrite(ctx context.Context, post *models.Post) error {
if !post.Published && post.PublishedAt == nil {
return nil
}
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{PermissionAccessPublish}) {
return nil
}
return &cabana.ForbiddenError{Message: "golem15.journal::lang.journal.access_publish"}
}

View File

@@ -0,0 +1,14 @@
scopes:
category:
label: golem15.journal::lang.posts.filter_category
modelClass: Golem15\Journal\Models\Category
nameFrom: name
scope: FilterCategories
published:
label: golem15.journal::lang.posts.filter_published
type: switch
column: published
published_date:
label: golem15.journal::lang.posts.filter_date
type: daterange
column: created_at

View File

@@ -0,0 +1,19 @@
name: golem15.journal::lang.journal.create_post
form: ~/plugins/golem15/journal/models/post/fields.yaml
modelClass: Golem15\Journal\Models\Post
defaultRedirect: golem15/journal/posts
create:
redirect: golem15/journal/posts/update/:id
redirectClose: golem15/journal/posts
update:
redirect: golem15/journal/posts
redirectClose: golem15/journal/posts
messages:
create: golem15.journal::lang.posts.new_post
update: backend::lang.messages.form.update
saved: backend::lang.messages.form.saved
deleteConfirm: golem15.journal::lang.post.delete_confirm
deleted: backend::lang.messages.form.deleted

View File

@@ -0,0 +1,26 @@
list: ~/plugins/golem15/journal/models/post/columns.yaml
modelClass: Golem15\Journal\Models\Post
title: golem15.journal::lang.post.label_plural
recordUrl: golem15/journal/posts/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 25
showCheckboxes: true
showSetup: true
showSorting: true
filter: config_filter.yaml
defaultSort:
column: published_at
direction: desc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.journal::lang.posts.new_post
empty: backend::lang.list.no_records
deleteConfirm: golem15.journal::lang.post.delete_confirm
deleted: backend::lang.messages.list.deleted
rowStateDisabled: golem15.journal::lang.journal.chart_drafts

4
go.mod
View File

@@ -9,6 +9,8 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
github.com/yuin/goldmark v1.8.6
gocloud.dev v0.46.0
gorm.io/gorm v1.31.2
)
@@ -92,7 +94,6 @@ require (
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/wneessen/go-mail v0.8.1 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
@@ -102,7 +103,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
gocloud.dev v0.46.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.58.0 // indirect

View File

@@ -55,6 +55,15 @@ journal:
rss_image_url_comment: URL to an image/logo displayed in feed readers.
rss_posts_per_feed_label: Posts per feed
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
posts:
list_title: Manage the journal posts
filter_category: Category
filter_published: Published
filter_date: Date
new_post: New post
view_post: View post
export_post: Export posts
import_post: Import posts
post:
label: Post
label_plural: Posts

View File

@@ -32,6 +32,15 @@ journal:
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
search_content_weight_label: Waga treści
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
posts:
list_title: Zarządzaj postami
filter_category: Kategoria
filter_published: Opublikowane
filter_date: Data
new_post: Nowy post
view_post: Zobacz post
export_post: Eksportuj posty
import_post: Importuj posty
post:
title: Tytuł
title_placeholder: Tytuł nowego posta

View File

@@ -1,10 +1,15 @@
package models
import (
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
)
// Post is a journal entry stored on golem15_journal_posts.
@@ -57,6 +62,128 @@ func (Post) AttachRelations() []attach.Relation {
}
}
func (Post) Rules() map[string]string {
return map[string]string{
"title": "required",
"slug": "required",
"content": "required",
}
}
const (
// FilterPublished is the model scope PHP config_filter.yaml expressed as
// conditions. Cabana switch filters use column: published instead; the
// scope remains registered for FilterScopes completeness.
FilterPublished = "FilterPublished"
// FilterCategories is the config_filter.yaml category scope, including
// NestedTree children via nest_left/nest_right.
FilterCategories = "FilterCategories"
)
func (Post) FilterScopes() []string {
return []string{FilterPublished, FilterCategories}
}
func (Post) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
switch name {
case FilterPublished:
on := parseBoolFilter(value)
if on {
return db.Where("published = ?", true)
}
return db.Where("published <> ?", true)
case FilterCategories:
id, ok := parseFilterID(value)
if !ok {
return db.Where("1 = 0")
}
ids := categoryIDsWithChildren(db, uint(id))
if len(ids) == 0 {
return db.Where("1 = 0")
}
return db.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)
default:
return db.Where("1 = 0")
}
}
func parseBoolFilter(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "1" || s == "true" || s == "on"
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
return s == "1" || s == "true" || s == "on"
}
}
func parseFilterID(value any) (uint64, bool) {
text := strings.TrimSpace(fmt.Sprint(value))
if value == nil || text == "" {
return 0, false
}
id, err := strconv.ParseUint(text, 10, 32)
if err != nil || id == 0 {
return 0, false
}
return id, true
}
func categoryIDsWithChildren(db *gorm.DB, id uint) []uint {
var cat Category
if err := db.Session(&gorm.Session{NewDB: true}).Where("id = ?", id).Take(&cat).Error; err != nil {
return nil
}
ids := []uint{cat.ID}
if cat.NestLeft == nil || cat.NestRight == nil {
return ids
}
var kids []Category
if err := db.Session(&gorm.Session{NewDB: true}).
Where("nest_left > ? AND nest_right < ?", *cat.NestLeft, *cat.NestRight).
Find(&kids).Error; err != nil {
return ids
}
for _, kid := range kids {
ids = append(ids, kid.ID)
}
return ids
}
// CanEdit reports whether principal may edit this post: the owner, or an
// administrator with golem15.journal.access_other_posts.
func (p *Post) CanEdit(principal *bouncer.Principal) bool {
if p == nil || principal == nil {
return false
}
if cabana.Allows(principal, []string{"golem15.journal.access_other_posts"}) {
return true
}
return p.UserID != nil && *p.UserID == principal.ID
}
// PostCategory is the golem15_journal_posts_categories pivot.
type PostCategory struct {
PostID uint `gorm:"column:post_id;primaryKey"`
CategoryID uint `gorm:"column:category_id;primaryKey"`
}
func (PostCategory) TableName() string { return "golem15_journal_posts_categories" }
// PostTag is the golem15_journal_posts_tags pivot.
type PostTag struct {
PostID uint `gorm:"column:post_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
}
func (PostTag) TableName() string { return "golem15_journal_posts_tags" }
func init() {
Register(Post{})
}

25
models/post/columns.yaml Normal file
View File

@@ -0,0 +1,25 @@
columns:
title:
label: golem15.journal::lang.post.title
searchable: true
is_pinned:
label: Pinned
type: switch
sortable: true
categories:
label: golem15.journal::lang.post.categories
relation: categories
select: name
searchable: true
sortable: false
created_at:
label: golem15.journal::lang.post.created
type: date
invisible: true
updated_at:
label: golem15.journal::lang.post.updated
type: date
invisible: true
published_at:
label: golem15.journal::lang.post.published
type: date

67
models/post/fields.yaml Normal file
View File

@@ -0,0 +1,67 @@
fields:
title:
label: golem15.journal::lang.post.title
type: mltext
span: left
tab: golem15.journal::lang.post.tab_edit
required: true
slug:
label: golem15.journal::lang.post.slug
type: mltext
span: right
tab: golem15.journal::lang.post.tab_edit
required: true
content:
label: golem15.journal::lang.post.content
type: mlmarkdown
size: huge
tab: golem15.journal::lang.post.tab_edit
required: true
excerpt:
label: golem15.journal::lang.post.excerpt
type: mltext
size: small
tab: golem15.journal::lang.post.tab_manage
categories:
label: golem15.journal::lang.post.categories
type: relation
nameFrom: name
tab: golem15.journal::lang.post.tab_categories
comment: golem15.journal::lang.post.categories_comment
tags:
label: golem15.journal::lang.journal.tags
type: relation
nameFrom: name
tab: golem15.journal::lang.post.tab_categories
comment: golem15.journal::lang.post.tags_comment
published:
label: golem15.journal::lang.post.published
type: switch
span: left
tab: golem15.journal::lang.post.tab_manage
is_pinned:
label: Pinned
comment: Pin this post to appear first in listings
type: checkbox
span: left
tab: golem15.journal::lang.post.tab_manage
user:
label: golem15.journal::lang.post.published_by
type: relation
nameFrom: login
emptyOption: golem15.journal::lang.post.current_user
span: right
tab: golem15.journal::lang.post.tab_manage
published_at:
label: golem15.journal::lang.post.published_on
type: datepicker
mode: datetime
span: left
tab: golem15.journal::lang.post.tab_manage
featured_images:
label: golem15.journal::lang.post.featured_images
type: fileupload
mode: image
imageWidth: 200
imageHeight: 200
tab: golem15.journal::lang.post.tab_manage

392
posts_admin_smoke_test.go Normal file
View File

@@ -0,0 +1,392 @@
package journal_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
_ "git.golem15.com/golem15/sm-journal-plugin"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
_ "git.golem15.com/golem15/sm-translate-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
const (
adminPostsPath = "/golem15/journal/posts"
adminJWTSecret = "journal-admin-test-only-hs256-secret-key"
)
var (
adminPG *postgres.PostgresContainer
adminSQL *sql.DB
adminDSN string
adminPGErr error
adminSeq atomic.Uint64
)
func TestMain(m *testing.M) {
code := 1
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
adminPGErr = startAdminPostgres(ctx)
cancel()
if adminPGErr != nil {
fmt.Fprintf(os.Stderr, "golem15.journal: testcontainers postgres: %v\n", adminPGErr)
stopAdminPostgres()
os.Exit(1)
}
code = m.Run()
stopAdminPostgres()
os.Exit(code)
}
func startAdminPostgres(ctx context.Context) error {
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("golem15_journal_admin"),
postgres.WithUsername("golem15_journal_admin"),
postgres.WithPassword("golem15_journal_admin"),
postgres.BasicWaitStrategies(),
)
if err != nil {
return err
}
adminPG = ctr
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return err
}
adminSQL = db
adminDSN = dsn
return nil
}
func stopAdminPostgres() {
if adminSQL != nil {
_ = adminSQL.Close()
}
if adminPG != nil {
_ = testcontainers.TerminateContainer(adminPG)
}
}
func requirePostgres(t *testing.T) *sql.DB {
t.Helper()
if adminPGErr != nil {
t.Fatalf("postgres unavailable: %v", adminPGErr)
}
if adminSQL == nil {
t.Fatal("postgres unavailable: container was not started")
}
return adminSQL
}
func dedicatedDB(t *testing.T, name string) *sql.DB {
t.Helper()
admin := requirePostgres(t)
ctx := t.Context()
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil && !strings.Contains(err.Error(), "already exists") {
t.Fatalf("create %s: %v", name, err)
}
u, err := url.Parse(adminDSN)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
db, err := sql.Open("pgx", u.String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Close()
_, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
})
if err := db.PingContext(ctx); err != nil {
t.Fatal(err)
}
return db
}
type adminEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
}
func adminAPI(rel string) string {
return cabana.DefaultAdminPrefix + "/api/v1" + rel
}
func newPostsEnv(t *testing.T) *adminEnv {
t.Helper()
sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10))
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret,
},
})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES
('en', 'English', TRUE, TRUE, 1),
('pl', 'Polski', FALSE, TRUE, 2)
ON CONFLICT DO NOTHING`).Error; err != nil {
t.Fatal(err)
}
return &adminEnv{h: h, app: app, gdb: gdb}
}
func (e *adminEnv) admin(t *testing.T, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
login := "admin-" + stamp
password := "admin-secret-" + stamp
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
grants := map[string]int{}
for _, code := range permissions {
grants[code] = 1
}
raw, err := json.Marshal(grants)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("backend role: id=%d err=%v", roleID, err)
}
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
if rec.Code != http.StatusOK {
t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String())
}
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err)
}
return body.Data.AccessToken
}
func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
var reader io.Reader
if raw != nil {
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Host = "example.com"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
rec := e.call(t, method, path, token, body)
if rec.Code != status {
t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
}
return rec
}
type adminEnvelope struct {
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func adminDecode(t *testing.T, rec *httptest.ResponseRecorder) adminEnvelope {
t.Helper()
var env adminEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
}
return env
}
func TestPostsFormCompiles(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t, controllers.PermissionAccessPosts)
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil)
body := rec.Body.String()
if !strings.Contains(body, "mlmarkdown") {
t.Fatalf("form schema missing mlmarkdown: %s", body)
}
if !strings.Contains(body, "mltext") {
t.Fatalf("form schema missing mltext: %s", body)
}
}
func TestPostsAdminCreateSmoke(t *testing.T) {
env := newPostsEnv(t)
privileged := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"},
"content": map[string]any{"en": "Hello **world**", "pl": "Witaj **świecie**"},
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
})
var created struct {
ID uint `json:"id"`
}
envData := adminDecode(t, rec)
if err := json.Unmarshal(envData.Data, &created); err != nil || created.ID == 0 {
t.Fatalf("create data %s: %v", rec.Body.String(), err)
}
var stored models.Post
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Title != "Hello" {
t.Fatalf("host title = %q, want Hello", stored.Title)
}
if stored.Slug != "hello-admin" {
t.Fatalf("host slug = %q, want hello-admin", stored.Slug)
}
if stored.ContentHTML == "" {
t.Fatal("content_html is empty")
}
if strings.Contains(strings.ToLower(stored.ContentHTML), "<script") {
t.Fatalf("content_html contains script: %s", stored.ContentHTML)
}
if !strings.Contains(stored.ContentHTML, "<strong>") && !strings.Contains(stored.ContentHTML, "<p>") {
t.Fatalf("content_html missing markup: %s", stored.ContentHTML)
}
var plCount int64
if err := env.gdb.Table("golem15_translate_attributes").
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl").
Count(&plCount).Error; err != nil {
t.Fatal(err)
}
if plCount == 0 {
t.Fatal("Polish translate attributes were not written")
}
t.Run("publish without access_publish", func(t *testing.T) {
limited := env.admin(t, controllers.PermissionAccessPosts)
denied := env.call(t, http.MethodPost, adminAPI(adminPostsPath), limited, map[string]any{
"title": map[string]any{"en": "Draft", "pl": "Szkic"},
"slug": map[string]any{"en": "draft-admin", "pl": "szkic-admin"},
"content": map[string]any{"en": "Draft body", "pl": "Treść szkicu"},
"published": true,
})
if denied.Code != http.StatusForbidden {
t.Fatalf("publish without access_publish = %d, want 403: %s", denied.Code, denied.Body.String())
}
if adminDecode(t, denied).Error.Code != "forbidden" {
t.Fatalf("error code = %s", denied.Body.String())
}
var n int64
if err := env.gdb.Model(&models.Post{}).Where("slug = ?", "draft-admin").Count(&n).Error; err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatal("published=true persisted without access_publish")
}
})
}