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:
37
admin.go
Normal file
37
admin.go
Normal 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
39
admin_navigation.go
Normal 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
54
admin_permissions.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
assets/images/journal-icon.svg
Normal file
30
assets/images/journal-icon.svg
Normal 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
51
classes/format_html.go
Normal 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
|
||||
}
|
||||
29
controllers/admin_registry.go
Normal file
29
controllers/admin_registry.go
Normal 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
208
controllers/posts.go
Normal 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"}
|
||||
}
|
||||
14
controllers/posts/config_filter.yaml
Normal file
14
controllers/posts/config_filter.yaml
Normal 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
|
||||
19
controllers/posts/config_form.yaml
Normal file
19
controllers/posts/config_form.yaml
Normal 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
|
||||
26
controllers/posts/config_list.yaml
Normal file
26
controllers/posts/config_list.yaml
Normal 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
4
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
127
models/post.go
127
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{})
|
||||
}
|
||||
|
||||
25
models/post/columns.yaml
Normal file
25
models/post/columns.yaml
Normal 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
67
models/post/fields.yaml
Normal 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
392
posts_admin_smoke_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user