package controllers import ( "context" "errors" "fmt" "strconv" "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"}, }} } // D-02: the Blog Editor's Decode already stored the policy's output in // content; content_html gets the same HTML, without goldmark. Content the // save does not write (untouched, D-03) leaves content_html as it is. if writes, ok := cabana.FieldWritesFromContext(ctx); ok { for _, w := range writes["content"] { if !w.Default { continue } if html, isString := w.Value.(string); isString { post.ContentHTML = html } } } return nil } // FormAfterCreate writes content_html for every non-default locale whose // content the save wrote (D-02). func (c postsAdminController) FormAfterCreate(ctx context.Context, model any) error { return c.writeContentHTMLTranslations(ctx, model) } // FormAfterUpdate writes content_html for every non-default locale whose // content the save wrote (D-02). Untouched locales are not listed and keep // their content_html. func (c postsAdminController) FormAfterUpdate(ctx context.Context, model any) error { return c.writeContentHTMLTranslations(ctx, model) } // writeContentHTMLTranslations stores each written non-default content // value as that locale's content_html through the published // cabana.TranslationWriter, inside the save transaction. Without a writer // there is nothing to write. func (c postsAdminController) writeContentHTMLTranslations(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") } writes, ok := cabana.FieldWritesFromContext(ctx) if !ok { return nil } var pending []cabana.FieldWrite for _, w := range writes["content"] { if !w.Default && w.Locale != "" { pending = append(pending, w) } } if len(pending) == 0 { return nil } app := c.application() if app == nil { return nil } writer, ok := app.Lookup[cabana.TranslationWriter]() if !ok || writer == nil { return nil } tx := c.db(ctx) for _, w := range pending { html, isString := w.Value.(string) if !isString { return fmt.Errorf("posts admin: content for %s is %T, want a string", w.Locale, w.Value) } if err := writer.WriteTranslated(ctx, tx, post, "content_html", w.Locale, html); err != nil { return err } } 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"} }