diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..d2297cd
--- /dev/null
+++ b/admin.go
@@ -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)
+)
diff --git a/admin_navigation.go b/admin_navigation.go
new file mode 100644
index 0000000..b0e7147
--- /dev/null
+++ b/admin_navigation.go
@@ -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,
+ },
+ },
+ },
+ }
+}
diff --git a/admin_permissions.go b/admin_permissions.go
new file mode 100644
index 0000000..068238a
--- /dev/null
+++ b/admin_permissions.go
@@ -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"},
+ },
+ }
+}
diff --git a/assets/images/journal-icon.svg b/assets/images/journal-icon.svg
new file mode 100644
index 0000000..e8abd72
--- /dev/null
+++ b/assets/images/journal-icon.svg
@@ -0,0 +1,30 @@
+
+
diff --git a/classes/format_html.go b/classes/format_html.go
new file mode 100644
index 0000000..58c3274
--- /dev/null
+++ b/classes/format_html.go
@@ -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
+}
diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go
new file mode 100644
index 0000000..42c6e46
--- /dev/null
+++ b/controllers/admin_registry.go
@@ -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{}
+}
diff --git a/controllers/posts.go b/controllers/posts.go
new file mode 100644
index 0000000..01d5642
--- /dev/null
+++ b/controllers/posts.go
@@ -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"}
+}
diff --git a/controllers/posts/config_filter.yaml b/controllers/posts/config_filter.yaml
new file mode 100644
index 0000000..f9af20a
--- /dev/null
+++ b/controllers/posts/config_filter.yaml
@@ -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
diff --git a/controllers/posts/config_form.yaml b/controllers/posts/config_form.yaml
new file mode 100644
index 0000000..3baead9
--- /dev/null
+++ b/controllers/posts/config_form.yaml
@@ -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
diff --git a/controllers/posts/config_list.yaml b/controllers/posts/config_list.yaml
new file mode 100644
index 0000000..fffe8dc
--- /dev/null
+++ b/controllers/posts/config_list.yaml
@@ -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
diff --git a/go.mod b/go.mod
index a09c907..9d6382b 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index 795f276..4be9e74 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -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
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index cd765be..3868e65 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -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
diff --git a/models/post.go b/models/post.go
index fbe6b93..9f68ba9 100644
--- a/models/post.go
+++ b/models/post.go
@@ -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{})
}
diff --git a/models/post/columns.yaml b/models/post/columns.yaml
new file mode 100644
index 0000000..c7992b8
--- /dev/null
+++ b/models/post/columns.yaml
@@ -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
diff --git a/models/post/fields.yaml b/models/post/fields.yaml
new file mode 100644
index 0000000..1b0c40f
--- /dev/null
+++ b/models/post/fields.yaml
@@ -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
diff --git a/posts_admin_smoke_test.go b/posts_admin_smoke_test.go
new file mode 100644
index 0000000..a7b4b7d
--- /dev/null
+++ b/posts_admin_smoke_test.go
@@ -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), "