Files
sm-seo-plugin/classes/service.go
Jakub Zych cadf6ad805 feat: SEO by URL over the management API and MCP (tracer)
- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale,
  else to an exact URL rule (force_rule writes a rule); the answer says
  where (D-11)
- management routes resolve the backend administrator from a token or
  the MCP dispatch and require golem15.seo.manage (D-10, D-15)
- MCP tool set_url_seo; history records via mcp
- classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
2026-10-27 12:06:31 +01:00

110 lines
3.2 KiB
Go

// Package classes holds the SEO plugin's logic: the service every writer
// goes through, the entry store and the effective-SEO resolver.
package classes
import (
"context"
"errors"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// ErrNotBooted is returned when the service has no booted application.
var ErrNotBooted = errors.New("golem15.seo: app is not booted")
// Service is the plugin's single entry point for reads and writes of SEO
// data. The admin extension, the admin controllers, the listeners, the job
// and the API all call it, so every write goes through the same checks and
// history.
type Service struct {
app *backpack.App
// robots is the last production robots.txt served, answered when the
// store fails (D-22: never a 5xx).
robots atomic.Pointer[string]
}
// NewService returns the service of a booted application. The database
// and the signpost registry are looked up when used, so a binary that runs
// a console command without a database still boots.
func NewService(app *backpack.App) (*Service, error) {
if app == nil {
return nil, ErrNotBooted
}
return &Service{app: app}, nil
}
// App returns the booted application.
func (s *Service) App() *backpack.App {
if s == nil {
return nil
}
return s.app
}
type txKey struct{}
// WithTx returns ctx carrying tx, the transaction an API write or a batch
// runs in, so every read of the service (the resolver, the redirect set,
// the settings) sees the uncommitted changes of that transaction.
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
if tx == nil {
return ctx
}
return context.WithValue(ctx, txKey{}, tx)
}
// DB returns the transaction carried by ctx (WithTx, else the admin write
// transaction of cabana.TxFromContext), else the application's database
// bound to ctx.
func (s *Service) DB(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok && tx != nil {
return tx, nil
}
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx, nil
}
if s == nil || s.app == nil {
return nil, ErrNotBooted
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
return db.WithContext(ctx), nil
}
// Registry returns the menu item type registry, nil when none is published.
func (s *Service) Registry() *signpost.Registry {
if s == nil || s.app == nil {
return nil
}
reg, _ := s.app.Lookup[*signpost.Registry]()
return reg
}
// GormDB returns the application's database without a transaction, the
// handle lagoon.Transaction opens a write on.
func (s *Service) GormDB() (*gorm.DB, error) {
if s == nil || s.app == nil {
return nil, ErrNotBooted
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
return db, nil
}
// Config returns the application config, nil when there is none.
func (s *Service) Config() *compass.Config {
if s == nil || s.app == nil {
return nil
}
return s.app.Config
}