From dea0934950a290b98feb3dbe66c5b066a5c8b0a9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 18:41:10 +0200 Subject: [PATCH] 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 --- admin.go | 37 +++ admin_navigation.go | 39 +++ admin_permissions.go | 54 ++++ assets/images/journal-icon.svg | 30 ++ classes/format_html.go | 51 ++++ controllers/admin_registry.go | 29 ++ controllers/posts.go | 208 ++++++++++++++ controllers/posts/config_filter.yaml | 14 + controllers/posts/config_form.yaml | 19 ++ controllers/posts/config_list.yaml | 26 ++ go.mod | 4 +- lang/en/lang.yaml | 9 + lang/pl/lang.yaml | 9 + models/post.go | 127 +++++++++ models/post/columns.yaml | 25 ++ models/post/fields.yaml | 67 +++++ posts_admin_smoke_test.go | 392 +++++++++++++++++++++++++++ 17 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 admin.go create mode 100644 admin_navigation.go create mode 100644 admin_permissions.go create mode 100644 assets/images/journal-icon.svg create mode 100644 classes/format_html.go create mode 100644 controllers/admin_registry.go create mode 100644 controllers/posts.go create mode 100644 controllers/posts/config_filter.yaml create mode 100644 controllers/posts/config_form.yaml create mode 100644 controllers/posts/config_list.yaml create mode 100644 models/post/columns.yaml create mode 100644 models/post/fields.yaml create mode 100644 posts_admin_smoke_test.go 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 @@ + + + + journal-icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + 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), "") && !strings.Contains(stored.ContentHTML, "

") { + 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") + } + }) +}