Files
sm-journal-plugin/controllers/posts.go
Jakub Zych 2a3afb5107 feat(16.2-07): purge the host site cache on every admin post and category write
- posts: FormAfterCreate/Update purge after the content_html translations,
  new FormAfterDelete; categories: FormAfterCreate and FormAfterDelete
- NewSiteCachePurger refuses half configuration, non-http(s) URLs, user
  info, fragments, http to a non-loopback host and tokens under 32 chars;
  Boot logs the endpoint host and path once
- TestPostsAdminPurgesSiteCache smoke test
- README: Configuration section (keys, contract, timing, scope), API rows
2026-10-10 18:27:12 +02:00

383 lines
12 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"},
}}
}
// 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), then registers the host site cache purge
// for after the commit.
func (c postsAdminController) FormAfterCreate(ctx context.Context, model any) error {
return c.afterPostWrite(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. It then registers the host site cache purge for after
// the commit; an untouched save purges too, which is harmless.
func (c postsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
return c.afterPostWrite(ctx, model)
}
// FormAfterDelete registers the host site cache purge for after the delete
// commits.
func (c postsAdminController) FormAfterDelete(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")
}
purger, tx := siteCachePurger(ctx, c.app)
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
return nil
}
func (c postsAdminController) afterPostWrite(ctx context.Context, model any) error {
if err := c.writeContentHTMLTranslations(ctx, model); err != nil {
return err
}
if post, ok := model.(*models.Post); ok && post != nil {
purger, tx := siteCachePurger(ctx, c.app)
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
}
return nil
}
// 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"}
}