feat: management API and an MCP tool per SEO admin operation

- /_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
This commit is contained in:
2026-10-27 12:17:36 +01:00
parent cadf6ad805
commit aa0c3bcacf
22 changed files with 2723 additions and 46 deletions

View File

@@ -183,3 +183,57 @@ func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Contex
func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
return svc.DB(ctx)
}
// The page sizes of the list endpoints.
const (
defaultPerPage = 50
maxPerPage = 200
)
// ListInput is the paging and search query every list tool shares.
type ListInput 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 or value contains"`
}
// listParams reads page, per_page and search.
func listParams(r *http.Request) ListInput {
q := r.URL.Query()
in := ListInput{Search: strings.TrimSpace(q.Get("search"))}
in.Page, _ = strconv.Atoi(q.Get("page"))
in.PerPage, _ = strconv.Atoi(q.Get("per_page"))
if in.Page < 1 {
in.Page = 1
}
if in.PerPage < 1 {
in.PerPage = defaultPerPage
}
if in.PerPage > maxPerPage {
in.PerPage = maxPerPage
}
return in
}
// paginate counts q and loads one page of it into dest.
func paginate(q *gorm.DB, in ListInput, dest any) (int64, error) {
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return 0, err
}
if err := q.Offset((in.Page - 1) * in.PerPage).Limit(in.PerPage).Find(dest).Error; err != nil {
return 0, err
}
return total, nil
}
// listBody is a list answer: the rows and the paging.
func listBody(rows any, in ListInput, total int64) map[string]any {
return map[string]any{"data": rows, "page": in.Page, "per_page": in.PerPage, "total": total}
}
// like is a case-insensitive LIKE pattern of text.
func like(text string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return "%" + strings.ToLower(r.Replace(text)) + "%"
}

View File

@@ -0,0 +1,95 @@
package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// ListHistoryInput is the query of GET /_seo/api/v1/history.
type ListHistoryInput 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 description contains"`
Via string `json:"via,omitempty" query:"via" jsonschema:"admin, mcp, import, auto or undo"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"entry, rule, redirect, robots, settings or notfound"`
Batch string `json:"batch,omitempty" query:"batch" jsonschema:"a batch id: the changes of one batch or import"`
}
// HistoryIDInput names one history entry.
type HistoryIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the history entry id"`
}
// RevisionJSON is a history entry in an answer.
func RevisionJSON(r models.Revision) map[string]any {
batch := ""
if r.BatchID != nil {
batch = *r.BatchID
}
return map[string]any{
"id": r.ID, "created_at": r.CreatedAt, "author": r.AuthorLabel, "author_id": r.AuthorID,
"via": r.Via, "kind": r.SubjectKind, "subject": r.SubjectID, "what": r.What,
"before": r.Before, "after": r.After, "batch_id": batch, "undone_at": r.UndoneAt,
}
}
// ListHistory serves GET /_seo/api/v1/history: newest first, filtered by
// writer, subject kind, batch and description text (D-13).
func ListHistory(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.Revision{}).Order("id DESC")
if in.Search != "" {
q = q.Where("lower(what) LIKE ?", like(in.Search))
}
query := r.URL.Query()
if v := query.Get("via"); v != "" {
q = q.Where("via = ?", v)
}
if v := query.Get("kind"); v != "" {
q = q.Where("subject_kind = ?", v)
}
if v := query.Get("batch"); v != "" {
q = q.Where("batch_id = ?", v)
}
var rows []models.Revision
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, RevisionJSON(row))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// ShowHistory serves GET /_seo/api/v1/history/{id}.
func ShowHistory(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
}
var rows []models.Revision
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return 0, nil, err
}
if len(rows) == 0 {
return 0, nil, classes.ErrNoRecord
}
return http.StatusOK, map[string]any{"data": RevisionJSON(rows[0])}, nil
})
}

35
controllers/api/issues.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only: missing_title, missing_description, duplicate_title, duplicate_description, rule_overrides_record, redirect_chain, canonical_noindex, canonical_redirected or noindex_blocked"`
}
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14 CMS part): the
// CMS-data issue report, each issue with its URL, kind, details and the
// tool that fixes it, and a count per kind.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
issues, err := svc.Issues(ctx)
if err != nil {
return 0, nil, err
}
kind := r.URL.Query().Get("kind")
counts := map[string]int{}
out := make([]classes.Issue, 0, len(issues))
for _, issue := range issues {
counts[issue.Kind]++
if kind == "" || issue.Kind == kind {
out = append(out, issue)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil
})
}

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
@@ -8,7 +9,9 @@ import (
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxReportBody caps the 404 report body: the URL and the referer fit
@@ -62,3 +65,151 @@ func NotFound(service func() *classes.Service) http.HandlerFunc {
w.WriteHeader(http.StatusNoContent)
}
}
// ListNotFoundInput is the query of GET /_seo/api/v1/notfound.
type ListNotFoundInput 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 contains"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open (no redirect answers the address yet) or resolved"`
}
// NotFoundIDInput names one 404 log entry.
type NotFoundIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
}
// RedirectNotFoundInput is POST /_seo/api/v1/notfound/{id}/redirect.
type RedirectNotFoundInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
Target string `json:"target" jsonschema:"where to send visitors: a path or an absolute URL"`
}
// NotFoundJSON is a 404 log entry in an answer, with the rule that
// answers it now (resolved) when there is one.
func NotFoundJSON(n models.NotFound, cover *models.Redirect) map[string]any {
out := map[string]any{
"id": n.ID, "url": n.URL, "hits": n.Hits, "first_seen": n.FirstSeen,
"last_hit": n.LastHit, "referer": n.Referer, "resolved": cover != nil,
}
if cover != nil {
out["redirect_id"] = cover.ID
}
return out
}
// ListNotFound serves GET /_seo/api/v1/notfound: the 404 log by hits,
// filtered by search text and open or resolved (an exact redirect or 410
// answers the address).
func ListNotFound(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.NotFound{}).Order("hits DESC, id ASC")
if in.Search != "" {
q = q.Where("lower(url) LIKE ?", like(in.Search))
}
if s := r.URL.Query().Get("status"); s != "" {
q = (models.NotFound{}).FilterScope(models.FilterNotFoundStatus, q, s)
}
var rows []models.NotFound
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
set, err := classes.LoadRedirectSet(db)
if err != nil {
return 0, nil, err
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
cover, err := classes.NotFoundCover(set, row.URL)
if err != nil {
return 0, nil, err
}
out = append(out, NotFoundJSON(row, cover))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// resolveNotFound runs a 404 log action: a 301 to target, or a 410 when
// target is empty. An address a rule already answers is 409.
func resolveNotFound(service func() *classes.Service, gone bool) 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 struct {
Target string `json:"target"`
}
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
target := strings.TrimSpace(body.Target)
if gone {
target = ""
} else if target == "" {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "validation_failed", "errors": map[string][]string{"target": {svc.PhraseCtx(ctx, "golem15.seo::lang.redirects.target_required", nil)}},
}}
}
var rule models.Redirect
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
rule, err = svc.ResolveNotFound(ctx, tx, entry, target)
return err
})
var covered *classes.NotFoundHasRuleError
if errors.As(err, &covered) {
return 0, nil, &apiError{Status: http.StatusConflict, Body: map[string]any{
"error": "has_rule", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.notfound.has_rule", map[string]string{"url": covered.URL}),
}}
}
if err != nil {
return 0, nil, err
}
return http.StatusCreated, map[string]any{"data": RedirectJSON(rule)}, nil
})
}
// RedirectNotFound serves POST /_seo/api/v1/notfound/{id}/redirect: a
// 301 from the logged address to target (origin notfound).
func RedirectNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, false)
}
// GoneNotFound serves POST /_seo/api/v1/notfound/{id}/gone: a 410 for the
// logged address, so search engines drop it.
func GoneNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, true)
}
// DeleteNotFound serves DELETE /_seo/api/v1/notfound/{id}: the entry
// leaves the log until its next 404.
func DeleteNotFound(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 {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
return svc.DeleteNotFound(ctx, tx, entry)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": id}, nil
})
}

View File

@@ -0,0 +1,247 @@
package api
import (
"context"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ListRedirectsInput is the query of GET /_seo/api/v1/redirects.
type ListRedirectsInput 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 source or target contains"`
Status int `json:"status,omitempty" query:"status" jsonschema:"301, 302 or 410"`
Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, slug, import, mcp or notfound"`
}
// RedirectIDInput names one redirect.
type RedirectIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the redirect id"`
}
// CreateRedirectInput is the body of POST /_seo/api/v1/redirects.
type CreateRedirectInput struct {
Source string `json:"source" jsonschema:"the address to redirect: a path (exact), a pattern where * matches any text, or a regular expression"`
Target *string `json:"target,omitempty" jsonschema:"where to send visitors: a path or an absolute URL; empty for 410"`
Status *int `json:"status,omitempty" jsonschema:"301 (the default), 302 or 410"`
Match *string `json:"match,omitempty" jsonschema:"exact (the default), pattern or regex"`
Query *string `json:"query,omitempty" jsonschema:"ignore (the default) or pass the request's query string to the target"`
IgnoreCase *bool `json:"ignore_case,omitempty" jsonschema:"match the source without letter case"`
TrailingSlash *bool `json:"trailing_slash,omitempty" jsonschema:"match the source with and without a trailing slash (default true)"`
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// UpdateRedirectInput is PUT /_seo/api/v1/redirects/{id}: only the fields
// sent are changed.
type UpdateRedirectInput struct {
ID uint `json:"id" path:"id" jsonschema:"the redirect id"`
Source *string `json:"source,omitempty" jsonschema:"the address to redirect"`
Target *string `json:"target,omitempty" jsonschema:"where to send visitors; empty for 410"`
Status *int `json:"status,omitempty" jsonschema:"301, 302 or 410"`
Match *string `json:"match,omitempty" jsonschema:"exact, pattern or regex"`
Query *string `json:"query,omitempty" jsonschema:"ignore or pass"`
IgnoreCase *bool `json:"ignore_case,omitempty" jsonschema:"match without letter case"`
TrailingSlash *bool `json:"trailing_slash,omitempty" jsonschema:"match with and without a trailing slash"`
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// RedirectBody is the decoded body of a redirect write; the batch and the
// import use it too.
type RedirectBody struct {
Source *string `json:"source,omitempty"`
Target *string `json:"target,omitempty"`
Status *int `json:"status,omitempty"`
Match *string `json:"match,omitempty"`
Query *string `json:"query,omitempty"`
IgnoreCase *bool `json:"ignore_case,omitempty"`
TrailingSlash *bool `json:"trailing_slash,omitempty"`
Note *string `json:"note,omitempty"`
}
// Apply writes the fields sent onto r.
func (b RedirectBody) Apply(r *models.Redirect) {
if b.Source != nil {
r.Source = strings.TrimSpace(*b.Source)
}
if b.Target != nil {
r.Target = strings.TrimSpace(*b.Target)
}
if b.Status != nil {
r.Status = models.RedirectStatus(*b.Status)
}
if b.Match != nil {
r.Match = strings.TrimSpace(*b.Match)
}
if b.Query != nil {
r.Query = strings.TrimSpace(*b.Query)
}
if b.IgnoreCase != nil {
r.IgnoreCase = *b.IgnoreCase
}
if b.TrailingSlash != nil {
r.TrailingSlash = *b.TrailingSlash
}
if b.Note != nil {
r.Note = strings.TrimSpace(*b.Note)
}
}
// RedirectJSON is a redirect in an answer.
func RedirectJSON(r models.Redirect) map[string]any {
out := classes.RedirectValues(r)
out["status"] = int(r.Status)
out["id"] = r.ID
out["created_at"] = r.CreatedAt
out["updated_at"] = r.UpdatedAt
return out
}
// redirectAnswer is a write's answer: the redirect and, for a chain, the
// chain warning (D-19: a chain is saved, a loop refused).
func redirectAnswer(ctx context.Context, svc *classes.Service, r models.Redirect, check classes.RedirectCheck) map[string]any {
out := map[string]any{"data": RedirectJSON(r)}
if check.Chain {
notice := classes.ChainNotice(check)[0]
out["warnings"] = []map[string]any{{
"kind": "redirect_chain",
"path": check.Path,
"final": check.Final,
"message": svc.PhraseCtx(ctx, notice.Message, notice.Params),
}}
}
return out
}
// ListRedirects serves GET /_seo/api/v1/redirects: newest change first,
// filtered by search text, status and origin.
func ListRedirects(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.Redirect{}).Order("updated_at DESC NULLS LAST, id DESC")
if in.Search != "" {
p := like(in.Search)
q = q.Where("lower(source) LIKE ? OR lower(target) LIKE ?", p, p)
}
if s, err := strconv.Atoi(r.URL.Query().Get("status")); err == nil && s > 0 {
q = q.Where("status = ?", s)
}
if o := r.URL.Query().Get("origin"); o != "" {
q = q.Where("origin = ?", o)
}
var rows []models.Redirect
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, RedirectJSON(row))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// ShowRedirect serves GET /_seo/api/v1/redirects/{id}.
func ShowRedirect(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
}
row, err := classes.LoadRedirect(db, id)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": RedirectJSON(row)}, nil
})
}
// CreateRedirect serves POST /_seo/api/v1/redirects: 201 with the
// redirect (and a chain warning), 422 redirect_loop for a loop (D-19) or
// validation_failed for invalid fields. The origin is mcp through MCP.
func CreateRedirect(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var body RedirectBody
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
row := models.NewRedirect()
row.Origin = classes.RedirectOrigin(ctx)
body.Apply(&row)
var check classes.RedirectCheck
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
check, err = svc.SaveRedirect(ctx, tx, &row)
return err
})
if err != nil {
return 0, nil, err
}
return http.StatusCreated, redirectAnswer(ctx, svc, row, check), nil
})
}
// UpdateRedirect serves PUT /_seo/api/v1/redirects/{id}: the fields sent
// change; a loop is refused like a create.
func UpdateRedirect(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 RedirectBody
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
var row models.Redirect
var check classes.RedirectCheck
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
if row, err = classes.LoadRedirect(tx, id); err != nil {
return err
}
body.Apply(&row)
check, err = svc.SaveRedirect(ctx, tx, &row)
return err
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, redirectAnswer(ctx, svc, row, check), nil
})
}
// DeleteRedirect serves DELETE /_seo/api/v1/redirects/{id}.
func DeleteRedirect(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 {
row, err := classes.LoadRedirect(tx, id)
if err != nil {
return err
}
return svc.DeleteRedirect(ctx, tx, row)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": id}, nil
})
}

View File

@@ -1,9 +1,13 @@
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"
)
// Robots serves GET /robots.txt (D-22, D-25, P-23) as text/plain. In
@@ -41,3 +45,109 @@ func Robots(service func() *classes.Service) http.HandlerFunc {
_, _ = w.Write([]byte(body))
}
}
// UpdateRobotsInput is the body of PUT /_seo/api/v1/robots.
type UpdateRobotsInput struct {
Content *string `json:"content,omitempty" jsonschema:"the robots.txt rules; Sitemap lines and the AI bot rules are added when the file is served"`
Bots map[string]bool `json:"bots,omitempty" jsonschema:"AI bot switches by user-agent token, for example {\"GPTBot\": false}; a bot switched off is disallowed"`
}
// TestRobotsInput is the query of GET /_seo/api/v1/robots/test.
type TestRobotsInput struct {
URL string `json:"url" query:"url" jsonschema:"the address to test, a path starting with /"`
Agent string `json:"agent,omitempty" query:"agent" jsonschema:"the crawler's user agent token (Googlebot by default)"`
}
// robotsJSON is the robots.txt screen in an answer: the rules, every AI
// bot with its switch, the file crawlers get in production and whether
// this site runs outside production (then crawlers get the lock).
func robotsJSON(ctx context.Context, svc *classes.Service, row models.Robots) (map[string]any, error) {
bots := make([]map[string]any, 0, len(classes.Bots))
for _, b := range classes.Bots {
bots = append(bots, map[string]any{"token": b.Token, "operator": b.Operator, "kind": b.Kind, "allowed": row.Bots.Allowed(b.Token)})
}
production := classes.ComposeRobots(row.Content, row.Bots, svc.RobotsSitemaps())
return map[string]any{
"content": row.Content,
"bots": bots,
"production": production,
"staging": svc.Staging(),
}, nil
}
// ShowRobots serves GET /_seo/api/v1/robots.
func ShowRobots(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
}
row, err := classes.LoadRobots(db)
if err != nil {
return 0, nil, err
}
body, err := robotsJSON(ctx, svc, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}
// UpdateRobots serves PUT /_seo/api/v1/robots: the rules and the bot
// switches sent change (D-22, D-25). The answer lists the noindex
// addresses the new rules keep Googlebot from (robots.blocks_noindex).
func UpdateRobots(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in UpdateRobotsInput
if err := decode(r, classes.MaxRobotsBytes+(64<<10), &in); err != nil {
return 0, nil, err
}
var row models.Robots
var blocked []string
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, blocked, err = svc.SaveRobots(ctx, tx, classes.RobotsWrite{Content: in.Content, Bots: in.Bots})
return err
})
if err != nil {
return 0, nil, err
}
body, err := robotsJSON(ctx, svc, row)
if err != nil {
return 0, nil, err
}
out := map[string]any{"data": body}
if len(blocked) > 0 {
out["warnings"] = []map[string]any{{"kind": "blocks_noindex", "urls": blocked}}
}
return http.StatusOK, out, nil
})
}
// TestRobotsURL serves GET /_seo/api/v1/robots/test?url=&agent=: whether
// the crawler may fetch the address under the saved production file,
// through Google's matcher, with the deciding line (D-99).
func TestRobotsURL(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
path := strings.TrimSpace(r.URL.Query().Get("url"))
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > classes.MaxURLBytes {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "validation_failed", "errors": map[string][]string{"url": {svc.PhraseCtx(ctx, "golem15.seo::lang.robots.url_required", nil)}},
}}
}
agent := strings.TrimSpace(r.URL.Query().Get("agent"))
if agent == "" {
agent = "Googlebot"
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
row, err := classes.LoadRobots(db)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"url": path, "result": svc.TestRobots(row.Content, row.Bots, agent, path)}, nil
})
}

207
controllers/api/rules.go Normal file
View File

@@ -0,0 +1,207 @@
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
})
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"gorm.io/gorm"
@@ -69,3 +70,67 @@ func SetSEO(service func() *classes.Service) http.HandlerFunc {
return http.StatusOK, res, nil
})
}
// GetSEOInput is the query of GET /_seo/api/v1/seo.
type GetSEOInput struct {
URL string `json:"url" query:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
}
// GetSEO serves GET /_seo/api/v1/seo?url= (D-13): the effective SEO of a
// URL with the source of every value (rule, record, template, global,
// content or default), the redirect decision (action redirect or gone with
// the location), the owner a change would be written to, and the P1
// warnings (a URL rule overriding the record, a canonical that redirects
// or points to a noindex page). 422 for a URL that is not a path or
// http(s) URL.
func GetSEO(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
target := r.URL.Query().Get("url")
res, err := svc.Resolver().Resolve(ctx, target)
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
owner, _, err := svc.FindOwner(ctx, db, target, false)
if err != nil {
return 0, nil, err
}
var status classes.StatusView
switch {
case owner.WrittenTo == classes.WrittenToRecord:
if status, err = svc.RecordStatus(ctx, owner.Type, owner.ID); err != nil {
return 0, nil, err
}
case owner.RuleID != 0:
rule, err := classes.LoadRule(db, owner.RuleID)
if err != nil {
return 0, nil, err
}
status = svc.RuleStatus(ctx, rule)
}
warnings := []string{}
for _, c := range status.Callouts {
text := strings.TrimSpace(c.Title + " " + c.Text)
warnings = append(warnings, text)
}
sources := res.Sources
if sources == nil {
sources = map[string]string{}
}
return http.StatusOK, map[string]any{
"url": res.URL,
"action": res.Action,
"status": res.Status,
"location": res.Location,
"locale": res.Locale,
"staging": res.Staging,
"seo": res.SEO,
"sources": sources,
"owner": owner,
"warnings": warnings,
}, nil
})
}

120
controllers/api/settings.go Normal file
View File

@@ -0,0 +1,120 @@
package api
import (
"context"
"encoding/json"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// UpdateSettingsInput is the body of PUT /_seo/api/v1/settings: only the
// fields sent change.
type UpdateSettingsInput struct {
SiteName *string `json:"site_name,omitempty" jsonschema:"the site name (%site% in templates)"`
TwitterSite *string `json:"twitter_site,omitempty" jsonschema:"the site's X/Twitter handle"`
TitleTemplate *string `json:"title_template,omitempty" jsonschema:"the title template: %title%, %description% and %site%"`
HomeTemplate *string `json:"home_template,omitempty" jsonschema:"the home page title template"`
DefaultDescription *string `json:"default_description,omitempty" jsonschema:"the description of pages without one"`
DefaultOGImage *string `json:"default_og_image,omitempty" jsonschema:"the social image of pages without one"`
QueryParams *[]models.QueryParam `json:"query_params,omitempty" jsonschema:"the query parameter list: name (a trailing * matches a prefix), kind content, filter or tracking, and an optional path pattern"`
NoindexPaths *string `json:"noindex_paths,omitempty" jsonschema:"path patterns never indexed, one per line"`
SearchPaths *string `json:"search_paths,omitempty" jsonschema:"search result path patterns (noindex,follow), one per line"`
URLHost *string `json:"url_host,omitempty" jsonschema:"keep, www or bare"`
URLSlash *string `json:"url_slash,omitempty" jsonschema:"keep, add or remove a trailing slash"`
URLLowercase *bool `json:"url_lowercase,omitempty" jsonschema:"lowercase the ASCII letters of addresses"`
VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"`
VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"`
VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"`
VerifyOther *[]models.VerifyMeta `json:"verify_other,omitempty" jsonschema:"other verification meta tags (name, content)"`
VerifyFiles *[]models.VerifyFile `json:"verify_files,omitempty" jsonschema:"verification files served from the site root (filename, body)"`
CSVDelimiter *string `json:"csv_delimiter,omitempty" jsonschema:"the import and export delimiter: , or ;"`
Types map[string]classes.TypeSettingWrite `json:"types,omitempty" jsonschema:"per content type key: inject (show the SEO tab), title_template, description_template, robots"`
}
// settingsJSON is the settings in an answer: the values and, per content
// type, its label and settings.
func settingsJSON(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Settings) (map[string]any, error) {
stored, err := classes.LoadTypeSettings(db)
if err != nil {
return nil, err
}
types := map[string]any{}
if reg := svc.Registry(); reg != nil {
for _, typ := range reg.ContentTypes() {
ts, ok := stored[typ.Key]
if !ok {
ts = models.DefaultTypeSetting(typ.Key)
}
v := classes.TypeSettingValues(ts)
v["label"] = svc.PhraseCtx(ctx, typ.Label, nil)
types[typ.Key] = v
}
}
return map[string]any{"settings": classes.SettingsValues(row), "types": types}, nil
}
// ShowSettings serves GET /_seo/api/v1/settings.
func ShowSettings(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
}
row, err := classes.LoadSettings(db)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}
// UpdateSettings serves PUT /_seo/api/v1/settings: the fields sent change,
// with the checks of the settings screen; 422 validation_failed names the
// refused fields.
func UpdateSettings(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
raw := map[string]json.RawMessage{}
if err := decode(r, maxBody, &raw); err != nil {
return 0, nil, err
}
in := classes.SettingsWrite{Values: map[string]any{}}
for key, value := range raw {
if key == "types" {
if err := json.Unmarshal(value, &in.Types); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
continue
}
var v any
if err := json.Unmarshal(value, &v); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
in.Values[key] = v
}
var row models.Settings
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, err = svc.SaveSettings(ctx, tx, in)
return err
})
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}