- /_seo/api/v1 routes for the effective SEO of a URL with sources and owner, URL rules, redirects (loops refused with redirect_loop), the 404 log actions, robots.txt and its tester, settings and history - the CMS-data issue report: missing and duplicate titles and descriptions, rules over records, redirect chains, canonicals to noindex or redirected addresses, noindex addresses robots.txt blocks - 24 MCP tools on those routes (backend guard, golem15.seo.manage) - AdminOperations maps every admin operation to its tool; TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift - robots.txt writes go through classes.SaveRobots from the admin too
208 lines
7.3 KiB
Go
208 lines
7.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ListRulesInput is the query of GET /_seo/api/v1/rules.
|
|
type ListRulesInput struct {
|
|
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
|
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
|
|
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address, title or description contains"`
|
|
Match string `json:"match,omitempty" query:"match" jsonschema:"exact or pattern"`
|
|
}
|
|
|
|
// RuleIDInput names one URL rule.
|
|
type RuleIDInput struct {
|
|
ID uint `json:"id" path:"id" jsonschema:"the URL rule id"`
|
|
}
|
|
|
|
// CreateRuleInput is the body of POST /_seo/api/v1/rules.
|
|
type CreateRuleInput struct {
|
|
URL string `json:"url" jsonschema:"the address: a path with an optional query; with match pattern * matches any text"`
|
|
Match *string `json:"match,omitempty" jsonschema:"exact (the default) or pattern"`
|
|
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
|
|
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
|
|
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL"`
|
|
Robots *string `json:"robots,omitempty" jsonschema:"robots directives, for example noindex,follow"`
|
|
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
|
|
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
|
|
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image"`
|
|
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
|
}
|
|
|
|
// UpdateRuleInput is PUT /_seo/api/v1/rules/{id}: only the fields sent are
|
|
// changed.
|
|
type UpdateRuleInput struct {
|
|
ID uint `json:"id" path:"id" jsonschema:"the URL rule id"`
|
|
URL *string `json:"url,omitempty" jsonschema:"the address"`
|
|
Match *string `json:"match,omitempty" jsonschema:"exact or pattern"`
|
|
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
|
|
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
|
|
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL"`
|
|
Robots *string `json:"robots,omitempty" jsonschema:"robots directives"`
|
|
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
|
|
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
|
|
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image"`
|
|
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
|
}
|
|
|
|
// ruleBody is the decoded body of a rule write.
|
|
type ruleBody struct {
|
|
URL *string `json:"url"`
|
|
Match *string `json:"match"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Canonical *string `json:"canonical"`
|
|
Robots *string `json:"robots"`
|
|
OGTitle *string `json:"og_title"`
|
|
OGDescription *string `json:"og_description"`
|
|
OGImage *string `json:"og_image"`
|
|
Note *string `json:"note"`
|
|
}
|
|
|
|
// apply writes the fields sent onto r.
|
|
func (b ruleBody) apply(r *models.Rule) {
|
|
for column, v := range map[string]*string{
|
|
"url": b.URL, "match": b.Match, "title": b.Title, "description": b.Description,
|
|
"canonical": b.Canonical, "robots": b.Robots, "og_title": b.OGTitle,
|
|
"og_description": b.OGDescription, "og_image": b.OGImage, "note": b.Note,
|
|
} {
|
|
if v != nil {
|
|
classes.SetRuleValue(r, column, strings.TrimSpace(*v))
|
|
}
|
|
}
|
|
}
|
|
|
|
// RuleJSON is a rule in an answer.
|
|
func RuleJSON(r models.Rule) map[string]any {
|
|
out := classes.RuleValues(r)
|
|
out["id"] = r.ID
|
|
out["created_at"] = r.CreatedAt
|
|
out["updated_at"] = r.UpdatedAt
|
|
return out
|
|
}
|
|
|
|
// ListRules serves GET /_seo/api/v1/rules: URL rules by address, filtered
|
|
// by search text and match.
|
|
func ListRules(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
db, err := read(ctx, svc)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
in := listParams(r)
|
|
q := db.Model(&models.Rule{}).Order("url ASC, id ASC")
|
|
if in.Search != "" {
|
|
p := like(in.Search)
|
|
q = q.Where("lower(url) LIKE ? OR lower(title) LIKE ? OR lower(description) LIKE ?", p, p, p)
|
|
}
|
|
if m := r.URL.Query().Get("match"); m != "" {
|
|
q = q.Where("match = ?", m)
|
|
}
|
|
var rows []models.Rule
|
|
total, err := paginate(q, in, &rows)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, RuleJSON(row))
|
|
}
|
|
return http.StatusOK, listBody(out, in, total), nil
|
|
})
|
|
}
|
|
|
|
// ShowRule serves GET /_seo/api/v1/rules/{id}.
|
|
func ShowRule(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
db, err := read(ctx, svc)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
rule, err := classes.LoadRule(db, id)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusOK, map[string]any{"data": RuleJSON(rule)}, nil
|
|
})
|
|
}
|
|
|
|
// CreateRule serves POST /_seo/api/v1/rules: 201 with the rule, 422 for
|
|
// an invalid or taken address or invalid values.
|
|
func CreateRule(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
var body ruleBody
|
|
if err := decode(r, maxBody, &body); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
rule := models.Rule{Match: models.MatchExact}
|
|
body.apply(&rule)
|
|
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
return svc.SaveRule(ctx, tx, &rule)
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusCreated, map[string]any{"data": RuleJSON(rule)}, nil
|
|
})
|
|
}
|
|
|
|
// UpdateRule serves PUT /_seo/api/v1/rules/{id}: the fields sent change.
|
|
func UpdateRule(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
var body ruleBody
|
|
if err := decode(r, maxBody, &body); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
var rule models.Rule
|
|
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
var err error
|
|
if rule, err = classes.LoadRule(tx, id); err != nil {
|
|
return err
|
|
}
|
|
body.apply(&rule)
|
|
return svc.SaveRule(ctx, tx, &rule)
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusOK, map[string]any{"data": RuleJSON(rule)}, nil
|
|
})
|
|
}
|
|
|
|
// DeleteRule serves DELETE /_seo/api/v1/rules/{id}.
|
|
func DeleteRule(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
rule, err := classes.LoadRule(tx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return svc.DeleteRule(ctx, tx, rule)
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusOK, map[string]any{"deleted": id}, nil
|
|
})
|
|
}
|