JSON CLI matching PHP ExportPosts/ImportPosts ships as HasCommands, and Posts toolbar actions reuse that logic behind access_import_export. Co-authored-by: Cursor <cursoragent@cursor.com>
296 lines
8.6 KiB
Go
296 lines
8.6 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
|
"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"
|
|
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
|
// toolbar actions (D-13).
|
|
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
|
)
|
|
|
|
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")
|
|
}
|
|
if err := refuseOtherAuthor(ctx, post); err != nil {
|
|
return err
|
|
}
|
|
return preparePostWrite(ctx, post)
|
|
}
|
|
|
|
func (c postsAdminController) FormBeforeDelete(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 refuseOtherAuthor(ctx, post)
|
|
}
|
|
|
|
func refuseOtherAuthor(ctx context.Context, post *models.Post) error {
|
|
principal, _ := bouncer.User(ctx)
|
|
if post.CanEdit(principal) {
|
|
return nil
|
|
}
|
|
return &cabana.ForbiddenError{Message: "golem15.journal::lang.journal.access_other_posts"}
|
|
}
|
|
|
|
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 (c postsAdminController) AdminActions() []pact.AdminAction {
|
|
return []pact.AdminAction{
|
|
{
|
|
Name: "exportPosts",
|
|
Label: "golem15.journal::lang.posts.export_post",
|
|
Permissions: []string{PermissionAccessImportExport},
|
|
Run: c.runExportPosts,
|
|
},
|
|
{
|
|
Name: "importPosts",
|
|
Label: "golem15.journal::lang.posts.import_post",
|
|
Permissions: []string{PermissionAccessImportExport},
|
|
Run: c.runImportPosts,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
path, dry, _ := console.IOPayloadPath(in.Payload, "journal-export")
|
|
n, attachments, err := console.ExportToDir(ctx, c.db(ctx), path, dry, nil)
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, err
|
|
}
|
|
msg := fmt.Sprintf("Exported %d posts to: %s", n, path)
|
|
if dry {
|
|
msg = fmt.Sprintf("Would export %d posts to: %s", n, path)
|
|
}
|
|
if attachments {
|
|
msg += " File attachments are not included."
|
|
}
|
|
return pact.AdminActionResult{
|
|
Message: msg,
|
|
Data: map[string]any{"count": n, "path": path, "dry_run": dry},
|
|
}, nil
|
|
}
|
|
|
|
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
|
stats, err := console.ImportFromDir(ctx, c.db(ctx), path, force, dry, nil, func(post *models.Post) error {
|
|
return refuseUnpublishedWrite(ctx, post)
|
|
})
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, err
|
|
}
|
|
msg := fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
|
stats.Created, stats.Updated, stats.Skipped, stats.Failed)
|
|
if dry {
|
|
msg = "Dry run complete (no changes made). " + msg
|
|
}
|
|
return pact.AdminActionResult{
|
|
Message: msg,
|
|
Data: map[string]any{
|
|
"created": stats.Created,
|
|
"updated": stats.Updated,
|
|
"skipped": stats.Skipped,
|
|
"failed": stats.Failed,
|
|
"path": path,
|
|
"dry_run": dry,
|
|
"force": force,
|
|
},
|
|
}, 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"}
|
|
}
|