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:
@@ -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)) + "%"
|
||||
}
|
||||
|
||||
95
controllers/api/history.go
Normal file
95
controllers/api/history.go
Normal 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
35
controllers/api/issues.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
247
controllers/api/redirects.go
Normal file
247
controllers/api/redirects.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
207
controllers/api/rules.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
120
controllers/api/settings.go
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user