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:
33
README.md
33
README.md
@@ -36,7 +36,9 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
|
||||
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
|
||||
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; editor values are never changed.
|
||||
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage` and `robotstxt`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor) and `golem15-seo-robotstester` (the tester widget).
|
||||
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings and history.
|
||||
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings and history, in the admin and through MCP.
|
||||
- **Management API and MCP.** Every SEO admin operation has a JSON route under `/_seo/api/v1` and an MCP tool bound to it (pier, prefix `seo_`). Tools address pages by URL: a change goes to the record behind the URL, else to an exact URL rule, and the answer says where. A read grant sees only the read tools. `AdminOperations` maps each admin operation to its tool, and a test fails when one has none.
|
||||
- **Issue report.** Missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to a noindex or redirected address, and noindex addresses robots.txt blocks, each with the tool that fixes it.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -132,6 +134,24 @@ The **robots.txt** screen (a singleton) has the editor, the tester and **What cr
|
||||
|
||||
`GET /_seo/api/v1/verification/{name}` serves a verification file from the settings tab Verification (name `[A-Za-z0-9._-]+\.(html|txt|xml)`) with the content type of its extension, `X-Content-Type-Options: nosniff` and a sandboxing Content-Security-Policy; any other name is `404`. The frontend serves it at the site root; the plugin registers no `/{name}` route, which would capture every one-segment page of a site served at `GET /`. Every `/_seo/api/v1` answer carries `X-Robots-Tag: noindex`.
|
||||
|
||||
### Management API and MCP tools
|
||||
|
||||
The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests a minute per client IP). Each resolves the administrator from a backend JWT, a personal API token (scope `golem15.seo.*`) or pier's in-process dispatch, so an MCP call runs as the backend account linked to the MCP user; each answers `401` without one and `403` without `golem15.seo.manage`. Field refusals are `422 {"error": "validation_failed", "errors": {...}}`; a redirect that would create a loop is `422 {"error": "redirect_loop", "path": [...], "message": "..."}`, so the tool call fails; a missing row is `404`. History records these writes with via `mcp` when they come through MCP, and redirects created through MCP have origin `mcp`.
|
||||
|
||||
| Tool (`seo_` + name) | Route | What it does |
|
||||
|------|-------|--------------|
|
||||
| `get_url_seo` | `GET /seo?url=` | Effective SEO of a URL with the source of every value, the redirect decision, the owner and the SEO tab's warnings. |
|
||||
| `set_url_seo` | `PUT /seo` | Values for a URL: the record behind it in the URL's locale, else an exact URL rule; `force_rule` writes a rule. Answers `written_to` (`record` or `rule`), `type`, `id`, `locale`, `rule_id`. |
|
||||
| `list_rules`, `show_rule`, `create_rule`, `update_rule`, `delete_rule` | `GET/POST /rules`, `GET/PUT/DELETE /rules/{id}` | URL rules. |
|
||||
| `list_redirects`, `show_redirect`, `create_redirect`, `update_redirect`, `delete_redirect` | `GET/POST /redirects`, `GET/PUT/DELETE /redirects/{id}` | Redirects; a chain is saved with a warning. |
|
||||
| `list_not_found`, `redirect_not_found`, `gone_not_found`, `delete_not_found` | `GET /notfound`, `POST /notfound/{id}/redirect`, `POST /notfound/{id}/gone`, `DELETE /notfound/{id}` | The 404 log and its actions; an address a rule answers is `409`. |
|
||||
| `show_robots`, `update_robots`, `test_robots` | `GET/PUT /robots`, `GET /robots/test?url=&agent=` | robots.txt, the AI bot switches and the tester on the saved file. |
|
||||
| `show_settings`, `update_settings` | `GET/PUT /settings` | The SEO settings and, under `types`, the per-type settings. |
|
||||
| `list_history`, `show_history` | `GET /history`, `GET /history/{id}` | History, filtered by `via`, `kind` and `batch`. |
|
||||
| `list_issues` | `GET /issues` | The CMS-data issue report with a count per kind. |
|
||||
|
||||
Lists take `page`, `per_page` (at most 200) and `search`, and answer `{"data": [...], "page", "per_page", "total"}`. GET tools are read-only; the deletes and `gone_not_found` are destructive.
|
||||
|
||||
### Admin
|
||||
|
||||
- Navigation item **SEO** (icon `search`), landing on **Redirects**, with the side menu **Redirects**, **404 log**, **URL rules**, **robots.txt** and **History**.
|
||||
@@ -146,9 +166,14 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr
|
||||
|------------|-------------|
|
||||
| `Plugin` | The `golem15.seo` plugin; `Plugin.Service` returns the booted `classes.Service`. |
|
||||
| `TabSEO`, `TabGeneral`, `TabRobotsFile`, `TabRobotsBots` | The SEO tab key, the settings' General tab key and the robots.txt screen's tab keys. |
|
||||
| `BucketResolve`, `BucketNotFound`, `BucketRobots` | The throttle buckets of resolve (and the verification files), the 404 report and `/robots.txt`. |
|
||||
| `BucketResolve`, `BucketNotFound`, `BucketRobots`, `BucketAPI` | The throttle buckets of resolve (and the verification files), the 404 report, `/robots.txt` and the management API. |
|
||||
| `AdminOperations`, `AdminOperation`, `ContentController` | Every SEO admin operation (controller, kind) and its MCP tool; `ContentController` (`*`) stands for the form of every content type. |
|
||||
| `OpList`, `OpShow`, `OpCreate`, `OpUpdate`, `OpDelete`, `OpExtensionSave`, `OpUpload`, `OpRecordAction`, `OpBulkAction`, `OpWidgetAction` | The kinds of an admin operation. |
|
||||
| `RobotsTestAction` | `robots_test`, the tester's widget action. |
|
||||
| `classes.Service` | The single entry point for SEO reads and writes: `SaveRecord`, `Record` (history), `GenerateEntries`, `EnqueueGenerate`, `RecordDeleted`, `RecordCreated`, `Resolver`, `SiteURL`, `Staging`. Redirects: `SaveRedirect`, `DeleteRedirect`, `CheckRedirect`, `AddressChanges`, `AddressChanged`. 404 log: `ReportNotFound`, `ResolveNotFound`. robots.txt: `ServedFile`, `TestRobots`, `BlockedNoindex`. `VerificationFileByName`. |
|
||||
| `classes.Service` | The single entry point for SEO reads and writes: `SaveRecord`, `Record` (history), `GenerateEntries`, `EnqueueGenerate`, `RecordDeleted`, `RecordCreated`, `Resolver`, `SiteURL`, `Staging`. Redirects: `SaveRedirect`, `DeleteRedirect`, `CheckRedirect`, `AddressChanges`, `AddressChanged`. 404 log: `ReportNotFound`, `ResolveNotFound`. robots.txt: `ServedFile`, `TestRobots`, `BlockedNoindex`, `SaveRobots`. `VerificationFileByName`. Management: `FindOwner`, `SetURLSEO`, `SaveRule`, `DeleteRule`, `SaveSettings`, `DeleteNotFound`, `Issues`. |
|
||||
| `classes.Owner`, `classes.URLSEOWrite`, `classes.URLSEOResult` | The owner of a URL (`classes.WrittenToRecord` or `classes.WrittenToRule`) and a write addressed by URL. |
|
||||
| `classes.Issue` | One finding of the issue report (`classes.IssueMissingTitle` and the other kinds). |
|
||||
| `classes.WithTx` | Puts a transaction on the context so every read of the service sees it. |
|
||||
| `classes.Resolver` | `Resolve(ctx, url)` answers a `classes.Result` (actions `ok`, `not_found`, `redirect`, `gone`) with a source per value in `Result.Sources`; `ResolveRecord` resolves a record at its own address; `Strict` checks a path against the record's address. |
|
||||
| `classes.RedirectSet`, `classes.NewRedirectSet`, `classes.LoadRedirectSet` | A compiled redirect rule set: `Match` and `Follow` (`classes.Chain`, at most `classes.MaxRedirectHops`). |
|
||||
| `classes.RedirectCheck`, `classes.LoopError`, `classes.ProblemsError` | The save-time simulation's answer and the refusals of `SaveRedirect`. |
|
||||
@@ -197,4 +222,4 @@ go test ./... # with Docker: the Postgres smoke tests
|
||||
npm ci && npm run typecheck && bash scripts/check-dist.sh
|
||||
```
|
||||
|
||||
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs.
|
||||
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`). `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.
|
||||
|
||||
259
classes/issues.go
Normal file
259
classes/issues.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// The kinds of the CMS-data issue report (D-13, D-14 CMS part). The
|
||||
// crawler's checks of the rendered pages are Phase 33.
|
||||
const (
|
||||
IssueMissingTitle = "missing_title"
|
||||
IssueMissingDescription = "missing_description"
|
||||
IssueDuplicateTitle = "duplicate_title"
|
||||
IssueDuplicateDesc = "duplicate_description"
|
||||
IssueRuleOverridesRecord = "rule_overrides_record"
|
||||
IssueRedirectChain = "redirect_chain"
|
||||
IssueCanonicalNoindex = "canonical_noindex"
|
||||
IssueCanonicalRedirected = "canonical_redirected"
|
||||
IssueNoindexBlocked = "noindex_blocked"
|
||||
)
|
||||
|
||||
// Issue is one finding of the issue report: the address, the kind, the
|
||||
// details of the finding and the MCP tool that fixes it.
|
||||
type Issue struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
}
|
||||
|
||||
// The tools the issues point to (pier prefixes them with seo_).
|
||||
const (
|
||||
toolSetURLSEO = "seo_set_url_seo"
|
||||
toolUpdateRule = "seo_update_rule"
|
||||
toolUpdateRedirect = "seo_update_redirect"
|
||||
toolUpdateRobots = "seo_update_robots"
|
||||
)
|
||||
|
||||
// Issues builds the CMS-data issue report (D-13, D-14): missing effective
|
||||
// titles and descriptions per record and locale, effective titles and
|
||||
// descriptions shared by more than one address (records and URL rules),
|
||||
// URL rules overriding a record, redirect chains with their final
|
||||
// targets, canonicals pointing to a noindex or redirected address, and
|
||||
// noindex addresses robots.txt keeps crawlers from. Records are read
|
||||
// through their SEO entries (the generation job creates one per
|
||||
// published record and locale).
|
||||
func (s *Service) Issues(ctx context.Context) ([]Issue, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Issue
|
||||
var entries []models.Entry
|
||||
if err := db.Where("url <> ''").Order("url ASC, locale ASC").Find(&entries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []models.Rule
|
||||
if err := db.Order("url ASC").Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Missing and duplicate effective values.
|
||||
type place struct{ url, locale string }
|
||||
titles := map[string][]place{}
|
||||
descs := map[string][]place{}
|
||||
for _, e := range entries {
|
||||
if strings.TrimSpace(e.EffectiveTitle) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingTitle, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveTitle))
|
||||
titles[k] = append(titles[k], place{e.URL, e.Locale})
|
||||
}
|
||||
if strings.TrimSpace(e.EffectiveDescription) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingDescription, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveDescription))
|
||||
descs[k] = append(descs[k], place{e.URL, e.Locale})
|
||||
}
|
||||
}
|
||||
locale := s.DefaultLocale(ctx, db)
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact {
|
||||
continue
|
||||
}
|
||||
if t := strings.TrimSpace(r.Title); t != "" {
|
||||
k := locale + "|" + strings.ToLower(t)
|
||||
titles[k] = append(titles[k], place{r.URL, ""})
|
||||
}
|
||||
if d := strings.TrimSpace(r.Description); d != "" {
|
||||
k := locale + "|" + strings.ToLower(d)
|
||||
descs[k] = append(descs[k], place{r.URL, ""})
|
||||
}
|
||||
}
|
||||
duplicates := func(groups map[string][]place, kind string) {
|
||||
for key, list := range groups {
|
||||
if len(list) < 2 {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(key, "|")
|
||||
for i, p := range list {
|
||||
others := make([]string, 0, len(list)-1)
|
||||
for j, o := range list {
|
||||
if j != i && o.url != p.url {
|
||||
others = append(others, o.url)
|
||||
}
|
||||
}
|
||||
if len(others) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: p.url, Locale: p.locale, Tool: toolSetURLSEO, Details: map[string]any{"value": text, "also": others}})
|
||||
}
|
||||
}
|
||||
}
|
||||
duplicates(titles, IssueDuplicateTitle)
|
||||
duplicates(descs, IssueDuplicateDesc)
|
||||
|
||||
// URL rules overriding a record.
|
||||
if reg := s.Registry(); reg != nil {
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact || strings.Contains(r.URL, "?") {
|
||||
continue
|
||||
}
|
||||
var fields []string
|
||||
for _, p := range models.RuleProperties {
|
||||
if strings.TrimSpace(r.Value(p.Column)) != "" {
|
||||
fields = append(fields, p.Column)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
key, match, err := reg.MatchPath(ctx, trimSlash(r.URL), s.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match == nil || !match.Exact {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRuleOverridesRecord, URL: r.URL, Locale: match.Locale, Tool: toolUpdateRule, Details: map[string]any{
|
||||
"rule_id": r.ID, "type": key, "id": match.ID, "fields": fields,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect chains.
|
||||
redirects, err := LoadRedirects(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := NewRedirectSet(redirects)
|
||||
site := s.SiteURL()
|
||||
for _, r := range redirects {
|
||||
if r.Status == models.Status410 {
|
||||
continue
|
||||
}
|
||||
path, query, ok := redirectProbe(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ch, err := set.Follow(site, path, query, MaxRedirectHops)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch.Loop || len(ch.Hops) < 2 || ch.Hops[0].Rule.ID != r.ID {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRedirectChain, URL: r.Source, Tool: toolUpdateRedirect, Details: map[string]any{
|
||||
"redirect_id": r.ID, "hops": len(ch.Hops), "final": displayURL(site, ch.Final), "path": displayPath(r, ch),
|
||||
}})
|
||||
}
|
||||
|
||||
// Canonicals pointing to a noindex or redirected address.
|
||||
canonical := func(u, loc, value, tool string, details map[string]any) error {
|
||||
kind, target, err := s.canonicalProblem(ctx, value)
|
||||
if err != nil || kind == "" {
|
||||
return err
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details["canonical"] = value
|
||||
if target != "" {
|
||||
details["target"] = target
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: u, Locale: loc, Tool: tool, Details: details})
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := canonical(e.URL, e.Locale, e.Canonical, toolSetURLSEO, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
if err := canonical(r.URL, "", r.Canonical, toolUpdateRule, map[string]any{"rule_id": r.ID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Noindex addresses robots.txt blocks.
|
||||
robots, err := LoadRobots(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocked, err := s.BlockedNoindex(db, robots.Content, robots.Bots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range blocked {
|
||||
out = append(out, Issue{Kind: IssueNoindexBlocked, URL: u, Tool: toolUpdateRobots})
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
if out[i].URL != out[j].URL {
|
||||
return out[i].URL < out[j].URL
|
||||
}
|
||||
return out[i].Locale < out[j].Locale
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canonicalProblem checks a canonical on this site (D-06): a redirect
|
||||
// answers canonical_redirected with the final address, a page served
|
||||
// noindex (in production) canonical_noindex. Other sites and empty values
|
||||
// answer no kind.
|
||||
func (s *Service) canonicalProblem(ctx context.Context, canonical string) (kind, target string, err error) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
if canonical == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
ref := canonical
|
||||
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
|
||||
if !sameSite(s.SiteURL(), u.Host) {
|
||||
return "", "", nil
|
||||
}
|
||||
ref = u.String()
|
||||
}
|
||||
res, err := s.Resolver().Resolve(ctx, ref)
|
||||
if err != nil {
|
||||
if err == ErrBadURL {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
if res.Action == ActionRedirect {
|
||||
return IssueCanonicalRedirected, res.Location, nil
|
||||
}
|
||||
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
|
||||
return "", "", nil
|
||||
}
|
||||
return IssueCanonicalNoindex, "", nil
|
||||
}
|
||||
64
classes/manage.go
Normal file
64
classes/manage.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoadRedirect returns redirect id; ErrNoRecord when there is none.
|
||||
func LoadRedirect(db *gorm.DB, id uint) (models.Redirect, error) {
|
||||
var rows []models.Redirect
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Redirect{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Redirect{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// LoadNotFound returns 404 log entry id; ErrNoRecord when there is none.
|
||||
func LoadNotFound(db *gorm.DB, id uint) (models.NotFound, error) {
|
||||
var rows []models.NotFound
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.NotFound{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.NotFound{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// NotFoundValues are a 404 log entry's values for history.
|
||||
func NotFoundValues(n models.NotFound) map[string]any {
|
||||
return map[string]any{"url": n.URL, "hits": n.Hits, "referer": n.Referer}
|
||||
}
|
||||
|
||||
// DeleteNotFound removes a 404 log entry inside tx and records it in
|
||||
// history; the address is logged again on its next 404.
|
||||
func (s *Service) DeleteNotFound(ctx context.Context, tx *gorm.DB, n models.NotFound) error {
|
||||
if err := tx.Delete(&models.NotFound{}, n.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectNotFound,
|
||||
SubjectID: strconv.FormatUint(uint64(n.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_notfound", map[string]string{"url": n.URL}),
|
||||
Before: NotFoundValues(n),
|
||||
})
|
||||
}
|
||||
|
||||
// RedirectOrigin is the origin of a redirect created through ctx: mcp
|
||||
// through MCP, import through an import, else manual.
|
||||
func RedirectOrigin(ctx context.Context) string {
|
||||
switch ViaOf(ctx) {
|
||||
case models.ViaMCP:
|
||||
return models.OriginMCP
|
||||
case models.ViaImport:
|
||||
return models.OriginImport
|
||||
}
|
||||
return models.OriginManual
|
||||
}
|
||||
111
classes/robots_write.go
Normal file
111
classes/robots_write.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RobotsRow returns robots row 1 inside db, creating it when the
|
||||
// migration's row is gone (first or create, safe against a concurrent
|
||||
// first request).
|
||||
func RobotsRow(db *gorm.DB) (models.Robots, error) {
|
||||
var rows []models.Robots
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Robots{}, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows[0], nil
|
||||
}
|
||||
row := models.Robots{ID: 1, Bots: models.BotSwitches{}}
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return models.Robots{}, err
|
||||
}
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Robots{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Robots{}, errors.New("golem15.seo: robots row is missing")
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// RobotsValues are the robots row's values for history: the editor's text
|
||||
// and every listed bot's switch.
|
||||
func RobotsValues(r models.Robots) map[string]any {
|
||||
bots := map[string]any{}
|
||||
for _, b := range Bots {
|
||||
bots[b.Token] = r.Bots.Allowed(b.Token)
|
||||
}
|
||||
return map[string]any{"content": r.Content, "bots": bots}
|
||||
}
|
||||
|
||||
// RobotsWrite is one change of robots.txt: the editor's text when Content
|
||||
// is set, and the switches of the bots listed in Bots (by token).
|
||||
type RobotsWrite struct {
|
||||
Content *string
|
||||
Bots map[string]bool
|
||||
}
|
||||
|
||||
// SaveRobots writes robots.txt inside tx and records it in history
|
||||
// (D-22, D-25). A text over MaxRobotsBytes and a token that is not a
|
||||
// listed AI bot are refused with a *ProblemsError. It returns the stored
|
||||
// row and the noindex addresses Googlebot may no longer fetch under it
|
||||
// (the robots.blocks_noindex warning).
|
||||
func (s *Service) SaveRobots(ctx context.Context, tx *gorm.DB, in RobotsWrite) (models.Robots, []string, error) {
|
||||
row, err := RobotsRow(tx)
|
||||
if err != nil {
|
||||
return models.Robots{}, nil, err
|
||||
}
|
||||
problems := map[string][]models.Problem{}
|
||||
if in.Content != nil && len(*in.Content) > MaxRobotsBytes {
|
||||
problems["content"] = append(problems["content"], models.Problem{Key: "golem15.seo::lang.robots.too_large"})
|
||||
}
|
||||
for token := range in.Bots {
|
||||
if _, ok := BotByToken(token); !ok {
|
||||
problems["bots."+token] = append(problems["bots."+token], models.Problem{Key: "golem15.seo::lang.api.bot_unknown"})
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return models.Robots{}, nil, &ProblemsError{Problems: problems}
|
||||
}
|
||||
before := RobotsValues(row)
|
||||
if in.Content != nil {
|
||||
row.Content = *in.Content
|
||||
}
|
||||
bots := models.BotSwitches{}
|
||||
for token, on := range row.Bots {
|
||||
bots[token] = on
|
||||
}
|
||||
for token, on := range in.Bots {
|
||||
b, _ := BotByToken(token)
|
||||
bots[b.Token] = on
|
||||
}
|
||||
row.Bots = bots
|
||||
now := time.Now().UTC()
|
||||
row.UpdatedAt = &now
|
||||
if err := tx.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
|
||||
"content": row.Content, "bots": row.Bots, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return models.Robots{}, nil, err
|
||||
}
|
||||
if err := s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectRobots,
|
||||
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_robots", nil),
|
||||
Before: before,
|
||||
After: RobotsValues(row),
|
||||
}); err != nil {
|
||||
return models.Robots{}, nil, err
|
||||
}
|
||||
blocked, err := s.BlockedNoindex(tx, row.Content, row.Bots)
|
||||
if err != nil {
|
||||
return models.Robots{}, nil, err
|
||||
}
|
||||
return row, blocked, nil
|
||||
}
|
||||
308
classes/settings_write.go
Normal file
308
classes/settings_write.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SettingsRow returns settings row 1 inside db, creating it with the
|
||||
// defaults when the migration's row is gone.
|
||||
func SettingsRow(db *gorm.DB) (models.Settings, error) {
|
||||
var rows []models.Settings
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows[0], nil
|
||||
}
|
||||
row := models.DefaultSettings()
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
return LoadSettings(db)
|
||||
}
|
||||
|
||||
// SettingsColumns are the settings columns the API reads and writes, in
|
||||
// form order: the admin form's columns and the CSV delimiter of the
|
||||
// import and export screen.
|
||||
var SettingsColumns = []string{
|
||||
"site_name", "twitter_site", "title_template", "home_template",
|
||||
"default_description", "default_og_image", "query_params",
|
||||
"noindex_paths", "search_paths", "url_host", "url_slash",
|
||||
"url_lowercase", "verify_google", "verify_bing", "verify_yandex",
|
||||
"verify_other", "verify_files", "csv_delimiter",
|
||||
}
|
||||
|
||||
// SettingsValues are the settings row's values keyed by column, for
|
||||
// history and the API.
|
||||
func SettingsValues(st models.Settings) map[string]any {
|
||||
return map[string]any{
|
||||
"site_name": st.SiteName,
|
||||
"twitter_site": st.TwitterSite,
|
||||
"title_template": st.TitleTemplate,
|
||||
"home_template": st.HomeTemplate,
|
||||
"default_description": st.DefaultDescription,
|
||||
"default_og_image": st.DefaultOGImage,
|
||||
"query_params": plainValue(st.Params()),
|
||||
"noindex_paths": st.NoindexPaths,
|
||||
"search_paths": st.SearchPaths,
|
||||
"url_host": st.URLHost,
|
||||
"url_slash": st.URLSlash,
|
||||
"url_lowercase": st.URLLowercase,
|
||||
"verify_google": st.VerifyGoogle,
|
||||
"verify_bing": st.VerifyBing,
|
||||
"verify_yandex": st.VerifyYandex,
|
||||
"verify_other": plainValue(st.OtherMeta()),
|
||||
"verify_files": plainValue(st.Files()),
|
||||
"csv_delimiter": st.CSVDelimiter,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSettingsValue writes one column of the settings from a JSON value
|
||||
// (a string, a bool, or a list of objects); an unknown column or a value
|
||||
// of the wrong shape is an error.
|
||||
func SetSettingsValue(st *models.Settings, column string, v any) error {
|
||||
text := func() (string, error) {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s, nil
|
||||
case nil:
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("golem15.seo: settings %s is not text", column)
|
||||
}
|
||||
list := func(dst any) error {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(raw) == "null" {
|
||||
raw = []byte("[]")
|
||||
}
|
||||
return json.Unmarshal(raw, dst)
|
||||
}
|
||||
var err error
|
||||
switch column {
|
||||
case "site_name":
|
||||
st.SiteName, err = text()
|
||||
case "twitter_site":
|
||||
st.TwitterSite, err = text()
|
||||
case "title_template":
|
||||
st.TitleTemplate, err = text()
|
||||
case "home_template":
|
||||
st.HomeTemplate, err = text()
|
||||
case "default_description":
|
||||
st.DefaultDescription, err = text()
|
||||
case "default_og_image":
|
||||
st.DefaultOGImage, err = text()
|
||||
case "noindex_paths":
|
||||
st.NoindexPaths, err = text()
|
||||
case "search_paths":
|
||||
st.SearchPaths, err = text()
|
||||
case "url_host":
|
||||
st.URLHost, err = text()
|
||||
case "url_slash":
|
||||
st.URLSlash, err = text()
|
||||
case "verify_google":
|
||||
st.VerifyGoogle, err = text()
|
||||
case "verify_bing":
|
||||
st.VerifyBing, err = text()
|
||||
case "verify_yandex":
|
||||
st.VerifyYandex, err = text()
|
||||
case "csv_delimiter":
|
||||
st.CSVDelimiter, err = text()
|
||||
case "url_lowercase":
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
|
||||
}
|
||||
st.URLLowercase = b
|
||||
case "query_params":
|
||||
var out []models.QueryParam
|
||||
if err = list(&out); err == nil {
|
||||
st.QueryParams = lagoon.Jsonable[[]models.QueryParam]{Data: out, Valid: true}
|
||||
}
|
||||
case "verify_other":
|
||||
var out []models.VerifyMeta
|
||||
if err = list(&out); err == nil {
|
||||
st.VerifyOther = lagoon.Jsonable[[]models.VerifyMeta]{Data: out, Valid: true}
|
||||
}
|
||||
case "verify_files":
|
||||
var out []models.VerifyFile
|
||||
if err = list(&out); err == nil {
|
||||
st.VerifyFiles = lagoon.Jsonable[[]models.VerifyFile]{Data: out, Valid: true}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("golem15.seo: unknown settings column %q", column)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TypeSettingWrite changes the settings of one content type: a nil field
|
||||
// is left as it is.
|
||||
type TypeSettingWrite struct {
|
||||
Inject *bool `json:"inject,omitempty"`
|
||||
TitleTemplate *string `json:"title_template,omitempty"`
|
||||
DescriptionTemplate *string `json:"description_template,omitempty"`
|
||||
Robots *string `json:"robots,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsWrite is one change of the SEO settings: Values are columns of
|
||||
// SettingsColumns with JSON values, Types per content type key.
|
||||
type SettingsWrite struct {
|
||||
Values map[string]any
|
||||
Types map[string]TypeSettingWrite
|
||||
}
|
||||
|
||||
// SaveSettings writes the settings inside tx and records them in history,
|
||||
// like the settings screen (D-104): templates with an unknown placeholder,
|
||||
// malformed or duplicate verification files, an unknown address option or
|
||||
// delimiter, robots values outside the grammar and unknown content types
|
||||
// are refused with a *ProblemsError. Once the write commits, the entry
|
||||
// generation job is queued so the effective-value cache follows.
|
||||
func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrite) (models.Settings, error) {
|
||||
row, err := SettingsRow(tx)
|
||||
if err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
before := SettingsValues(row)
|
||||
problems := map[string][]models.Problem{}
|
||||
known := map[string]bool{}
|
||||
for _, c := range SettingsColumns {
|
||||
known[c] = true
|
||||
}
|
||||
for column, v := range in.Values {
|
||||
if !known[column] {
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
v = strings.TrimSpace(s)
|
||||
}
|
||||
if err := SetSettingsValue(&row, column, v); err != nil {
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
|
||||
}
|
||||
}
|
||||
oneOf := func(column, value string, allowed ...string) {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return
|
||||
}
|
||||
}
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
|
||||
}
|
||||
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
|
||||
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
|
||||
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
|
||||
for field, list := range row.Problems() {
|
||||
problems[field] = append(problems[field], list...)
|
||||
}
|
||||
types, err := LoadTypeSettings(tx)
|
||||
if err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
changed := map[string]models.TypeSetting{}
|
||||
reg := s.Registry()
|
||||
for key, w := range in.Types {
|
||||
field := "types." + key
|
||||
if reg == nil {
|
||||
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
|
||||
continue
|
||||
}
|
||||
if _, ok := reg.ContentType(key); !ok {
|
||||
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
|
||||
continue
|
||||
}
|
||||
ts, ok := types[key]
|
||||
if !ok {
|
||||
ts = models.DefaultTypeSetting(key)
|
||||
}
|
||||
if w.Inject != nil {
|
||||
ts.Inject = *w.Inject
|
||||
}
|
||||
if w.TitleTemplate != nil {
|
||||
ts.TitleTemplate = strings.TrimSpace(*w.TitleTemplate)
|
||||
problems[field+".title_template"] = append(problems[field+".title_template"], models.TemplateProblems(ts.TitleTemplate)...)
|
||||
}
|
||||
if w.DescriptionTemplate != nil {
|
||||
ts.DescriptionTemplate = strings.TrimSpace(*w.DescriptionTemplate)
|
||||
problems[field+".description_template"] = append(problems[field+".description_template"], models.TemplateProblems(ts.DescriptionTemplate)...)
|
||||
}
|
||||
if w.Robots != nil {
|
||||
normal, err := NormalizeRobots(*w.Robots)
|
||||
if err != nil {
|
||||
problems[field+".robots"] = append(problems[field+".robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
|
||||
}
|
||||
ts.Robots = normal
|
||||
}
|
||||
changed[key] = ts
|
||||
}
|
||||
for field, list := range problems {
|
||||
if len(list) == 0 {
|
||||
delete(problems, field)
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return models.Settings{}, &ProblemsError{Problems: problems}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
row.UpdatedAt = &now
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
if err := s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectSettings,
|
||||
SubjectID: "1",
|
||||
What: s.Phrase("golem15.seo::lang.history.what_settings", nil),
|
||||
Before: before,
|
||||
After: SettingsValues(row),
|
||||
}); err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
for key, ts := range changed {
|
||||
old, ok := types[key]
|
||||
if !ok {
|
||||
old = models.DefaultTypeSetting(key)
|
||||
}
|
||||
if err := s.SaveTypeSetting(ctx, tx, ts); err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
label := key
|
||||
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
|
||||
label = s.Phrase(typ.Label, nil)
|
||||
}
|
||||
if err := s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectSettings,
|
||||
SubjectID: "type:" + key,
|
||||
What: s.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + label,
|
||||
Before: TypeSettingValues(old),
|
||||
After: TypeSettingValues(ts),
|
||||
}); err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
}
|
||||
s.queueGenerateAfterCommit(ctx, tx)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// queueGenerateAfterCommit queues the entry generation job once tx
|
||||
// commits; a queue error is logged.
|
||||
func (s *Service) queueGenerateAfterCommit(ctx context.Context, tx *gorm.DB) {
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
db, err := s.GormDB()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
|
||||
log.Warn("golem15.seo: queue entry generation", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/controllers"
|
||||
@@ -116,43 +114,24 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension {
|
||||
if svc == nil {
|
||||
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
||||
}
|
||||
row, ok := in.Record.(*models.Robots)
|
||||
if !ok || row == nil {
|
||||
if row, ok := in.Record.(*models.Robots); !ok || row == nil {
|
||||
return pact.AdminFormSaveResult{}, nil
|
||||
}
|
||||
before := robotsValues(*row)
|
||||
var w classes.RobotsWrite
|
||||
if raw, ok := in.Values["content"]; ok {
|
||||
if text, ok := raw.(string); ok {
|
||||
row.Content = text
|
||||
w.Content = &text
|
||||
}
|
||||
}
|
||||
bots := models.BotSwitches{}
|
||||
for token, on := range row.Bots {
|
||||
bots[token] = on
|
||||
}
|
||||
for _, b := range classes.Bots {
|
||||
if raw, ok := in.Values[classes.BotField(b.Token)]; ok {
|
||||
bots[b.Token] = truthy(raw)
|
||||
if w.Bots == nil {
|
||||
w.Bots = map[string]bool{}
|
||||
}
|
||||
w.Bots[b.Token] = truthy(raw)
|
||||
}
|
||||
}
|
||||
row.Bots = bots
|
||||
now := time.Now().UTC()
|
||||
row.UpdatedAt = &now
|
||||
if err := in.DB.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
|
||||
"content": row.Content, "bots": row.Bots, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
if err := svc.Record(ctx, in.DB, classes.Change{
|
||||
Kind: models.SubjectRobots,
|
||||
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
|
||||
What: svc.Phrase("golem15.seo::lang.history.what_robots", nil),
|
||||
Before: before,
|
||||
After: robotsValues(*row),
|
||||
}); err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
blocked, err := svc.BlockedNoindex(in.DB, row.Content, row.Bots)
|
||||
_, blocked, err := svc.SaveRobots(ctx, in.DB, w)
|
||||
if err != nil || len(blocked) == 0 {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
@@ -169,15 +148,6 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// robotsValues are the robots row's values for history.
|
||||
func robotsValues(r models.Robots) map[string]any {
|
||||
bots := map[string]any{}
|
||||
for _, b := range classes.Bots {
|
||||
bots[b.Token] = r.Bots.Allowed(b.Token)
|
||||
}
|
||||
return map[string]any{"content": r.Content, "bots": bots}
|
||||
}
|
||||
|
||||
// robotsTestPayload is the tester's request: op context answers the
|
||||
// crawler list and the environment; op test answers whether agent may
|
||||
// fetch url under the editor's unsaved text and switches.
|
||||
|
||||
@@ -169,6 +169,7 @@ history:
|
||||
what_redirect: Redirect “:url”
|
||||
what_robots: robots.txt
|
||||
what_settings: SEO settings
|
||||
what_notfound: 404 log entry “:url”
|
||||
change_cell:
|
||||
one: ':field: :before → :after'
|
||||
other: ':count fields changed'
|
||||
@@ -294,3 +295,6 @@ api:
|
||||
field_unknown: This is not an SEO field.
|
||||
keyphrase_record_only: A focus keyphrase belongs to a record; a URL rule has none.
|
||||
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.
|
||||
bot_unknown: This is not an AI bot of the robots.txt screen.
|
||||
value_invalid: This value is not valid here.
|
||||
type_unknown: No content type with this key is registered.
|
||||
|
||||
@@ -173,6 +173,7 @@ history:
|
||||
what_redirect: Przekierowanie „:url”
|
||||
what_robots: robots.txt
|
||||
what_settings: Ustawienia SEO
|
||||
what_notfound: Wpis dziennika 404 „:url”
|
||||
change_cell:
|
||||
one: ':field: :before → :after'
|
||||
few: Zmieniono :count pola
|
||||
@@ -304,3 +305,6 @@ api:
|
||||
field_unknown: To nie jest pole SEO.
|
||||
keyphrase_record_only: Fraza kluczowa należy do rekordu; reguła adresu jej nie ma.
|
||||
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).
|
||||
bot_unknown: To nie jest bot AI z ekranu robots.txt.
|
||||
value_invalid: Ta wartość jest tu nieprawidłowa.
|
||||
type_unknown: Nie zarejestrowano typu treści o tym kluczu.
|
||||
|
||||
259
mcp.go
259
mcp.go
@@ -18,12 +18,25 @@ const mcpGuard = "backend"
|
||||
const mcpPermission = apicontrollers.PermissionManage
|
||||
|
||||
// MCP declares the SEO tools (D-10, P-24): one per operation of the SEO
|
||||
// admin, each bound to a /_seo/api/v1 management route, run as the backend
|
||||
// account linked to the MCP user and listed only when that account has
|
||||
// golem15.seo.manage. pier prefixes them with seo_. GET tools are
|
||||
// read-only, so a read grant sees only those.
|
||||
// admin (operations.go maps each admin operation to its tool), each bound
|
||||
// to a /_seo/api/v1 management route, run as the backend account linked to
|
||||
// the MCP user and listed only when that account has golem15.seo.manage.
|
||||
// pier prefixes them with seo_. GET tools are read-only, so a read grant
|
||||
// sees only those; deletes and "mark as gone" are destructive.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
destructive := true
|
||||
return pact.MCP{Tools: []pact.MCPTool{
|
||||
// Pages addressed by URL (D-11, D-13).
|
||||
{
|
||||
Name: "get_url_seo",
|
||||
Title: "Get the SEO of a URL",
|
||||
Description: "Shows the effective SEO of a page addressed by URL: every value with its source (URL rule, record, template, global settings, content or default), the redirect decision, the owner a change would be written to (the record or a URL rule) and the warnings of the SEO tab.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/seo",
|
||||
Input: apicontrollers.GetSEOInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "set_url_seo",
|
||||
Title: "Set the SEO of a URL",
|
||||
@@ -34,5 +47,243 @@ func (p *Plugin) MCP() pact.MCP {
|
||||
Input: apicontrollers.SetSEOInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
// URL rules (D-02).
|
||||
{
|
||||
Name: "list_rules",
|
||||
Title: "List URL rules",
|
||||
Description: "Lists the URL rules (SEO for an address with or without a record behind it) by address, with a search text and the match kind.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/rules",
|
||||
Input: apicontrollers.ListRulesInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "show_rule",
|
||||
Title: "Show a URL rule",
|
||||
Description: "Shows one URL rule by id.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/rules/{id}",
|
||||
Input: apicontrollers.RuleIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "create_rule",
|
||||
Title: "Create a URL rule",
|
||||
Description: "Creates a URL rule: an exact address or a pattern where * matches any text, with the values it sets. A rule wins over the record's SEO tab.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_seo/api/v1/rules",
|
||||
Input: apicontrollers.CreateRuleInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "update_rule",
|
||||
Title: "Update a URL rule",
|
||||
Description: "Changes the fields sent of one URL rule by id.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/rules/{id}",
|
||||
Input: apicontrollers.UpdateRuleInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "delete_rule",
|
||||
Title: "Delete a URL rule",
|
||||
Description: "Deletes one URL rule by id; history keeps its values.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/_seo/api/v1/rules/{id}",
|
||||
Input: apicontrollers.RuleIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
|
||||
// Redirects (D-16 to D-19).
|
||||
{
|
||||
Name: "list_redirects",
|
||||
Title: "List redirects",
|
||||
Description: "Lists redirects, newest change first, with a search text and the status (301, 302, 410) and origin filters.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/redirects",
|
||||
Input: apicontrollers.ListRedirectsInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "show_redirect",
|
||||
Title: "Show a redirect",
|
||||
Description: "Shows one redirect by id.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/redirects/{id}",
|
||||
Input: apicontrollers.RedirectIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "create_redirect",
|
||||
Title: "Create a redirect",
|
||||
Description: "Creates a 301, 302 or 410 redirect from a path, a pattern or a regular expression. A redirect that would create a loop is refused with redirect_loop and the loop's path; a chain is saved with a warning.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_seo/api/v1/redirects",
|
||||
Input: apicontrollers.CreateRedirectInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "update_redirect",
|
||||
Title: "Update a redirect",
|
||||
Description: "Changes the fields sent of one redirect by id; a loop is refused like a create.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/redirects/{id}",
|
||||
Input: apicontrollers.UpdateRedirectInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "delete_redirect",
|
||||
Title: "Delete a redirect",
|
||||
Description: "Deletes one redirect by id; history keeps its values.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/_seo/api/v1/redirects/{id}",
|
||||
Input: apicontrollers.RedirectIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
|
||||
// 404 log (D-20).
|
||||
{
|
||||
Name: "list_not_found",
|
||||
Title: "List the 404 log",
|
||||
Description: "Lists the addresses that answered 404, most hits first, with whether a redirect answers them now (status open or resolved).",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/notfound",
|
||||
Input: apicontrollers.ListNotFoundInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "redirect_not_found",
|
||||
Title: "Redirect a 404 address",
|
||||
Description: "Creates a 301 from a logged 404 address to a target. Refused when a rule already answers the address.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_seo/api/v1/notfound/{id}/redirect",
|
||||
Input: apicontrollers.RedirectNotFoundInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "gone_not_found",
|
||||
Title: "Mark a 404 address as gone",
|
||||
Description: "Creates a 410 for a logged 404 address, which tells search engines to drop it. Refused when a rule already answers the address.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_seo/api/v1/notfound/{id}/gone",
|
||||
Input: apicontrollers.NotFoundIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
{
|
||||
Name: "delete_not_found",
|
||||
Title: "Remove a 404 log entry",
|
||||
Description: "Removes one entry from the 404 log; it comes back on its next 404.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/_seo/api/v1/notfound/{id}",
|
||||
Input: apicontrollers.NotFoundIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
|
||||
// robots.txt and AI bots (D-22, D-24, D-25, D-99).
|
||||
{
|
||||
Name: "show_robots",
|
||||
Title: "Show robots.txt",
|
||||
Description: "Shows the robots.txt rules, the AI bot switches, the file crawlers get in production and whether the site runs outside production (crawlers then get Disallow: /).",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/robots",
|
||||
Input: struct{}{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "update_robots",
|
||||
Title: "Update robots.txt",
|
||||
Description: "Changes the robots.txt rules and the AI bot switches sent. The answer lists noindex addresses the new rules keep Googlebot from.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/robots",
|
||||
Input: apicontrollers.UpdateRobotsInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "test_robots",
|
||||
Title: "Test an address against robots.txt",
|
||||
Description: "Answers whether a crawler may fetch an address under the saved production robots.txt, with Google's matcher and the deciding line.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/robots/test",
|
||||
Input: apicontrollers.TestRobotsInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
// Settings (D-03, D-05, D-07, D-20b, D-21, D-23, D-104).
|
||||
{
|
||||
Name: "show_settings",
|
||||
Title: "Show the SEO settings",
|
||||
Description: "Shows the SEO settings (templates, defaults, the query parameter list, indexing paths, the address form, verification) and the settings of every content type.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/settings",
|
||||
Input: struct{}{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "update_settings",
|
||||
Title: "Update the SEO settings",
|
||||
Description: "Changes the SEO settings sent and, under types, the settings of content types by key, with the checks of the settings screen.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/settings",
|
||||
Input: apicontrollers.UpdateSettingsInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
// History (D-13).
|
||||
{
|
||||
Name: "list_history",
|
||||
Title: "List SEO history",
|
||||
Description: "Lists SEO changes, newest first, with author, time, via (admin, mcp, import, auto, undo), before and after values, filtered by via, kind or batch.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/history",
|
||||
Input: apicontrollers.ListHistoryInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "show_history",
|
||||
Title: "Show an SEO change",
|
||||
Description: "Shows one SEO change by id with its before and after values.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/history/{id}",
|
||||
Input: apicontrollers.HistoryIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
// Audit (D-13, D-14 CMS part).
|
||||
{
|
||||
Name: "list_issues",
|
||||
Title: "List SEO issues",
|
||||
Description: "Lists the CMS-data SEO issues: missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to noindex or redirected addresses, and noindex addresses robots.txt blocks; each names the tool that fixes it.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/issues",
|
||||
Input: apicontrollers.ListIssuesInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
237
mcp_test.go
237
mcp_test.go
@@ -146,3 +146,240 @@ func TestMCPSetURLSeoTracer(t *testing.T) {
|
||||
t.Fatalf("refused writes changed history: %+v", revs)
|
||||
}
|
||||
}
|
||||
|
||||
// issueKinds indexes an issue report by kind and URL.
|
||||
func issueKinds(t *testing.T, body map[string]any) map[string]map[string]map[string]any {
|
||||
t.Helper()
|
||||
out := map[string]map[string]map[string]any{}
|
||||
list, _ := body["data"].([]any)
|
||||
for _, item := range list {
|
||||
issue, _ := item.(map[string]any)
|
||||
kind, _ := issue["kind"].(string)
|
||||
u, _ := issue["url"].(string)
|
||||
if out[kind] == nil {
|
||||
out[kind] = map[string]map[string]any{}
|
||||
}
|
||||
out[kind][u] = issue
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestIssues checks the CMS-data issue report (D-13, D-14 CMS part):
|
||||
// missing descriptions, duplicate titles across records, a URL rule over a
|
||||
// record, a redirect chain with its final target, a canonical pointing to
|
||||
// a redirected address and a noindex page robots.txt blocks, each with
|
||||
// the tool that fixes it.
|
||||
func TestIssues(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||||
env.createPage(t, token, map[string]any{"title": "Same", "slug": "a", "excerpt": "First page", "published": true})
|
||||
env.createPage(t, token, map[string]any{"title": "Same", "slug": "b", "excerpt": "", "published": true})
|
||||
if _, err := env.seoPlugin(t).Service().GenerateEntries(t.Context(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/pages/a", "description": "Rule description"})
|
||||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old2", "target": "/pages/a"})
|
||||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old1", "target": "/old2"})
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/b", "values": map[string]any{"canonical": "/old2"}})
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "noindex"}, "force_rule": false})
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /pages/a\n"})
|
||||
|
||||
body := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues", agent, nil)
|
||||
kinds := issueKinds(t, body)
|
||||
expect := func(kind, u, tool string) map[string]any {
|
||||
t.Helper()
|
||||
issue, ok := kinds[kind][u]
|
||||
if !ok {
|
||||
t.Fatalf("no %s issue for %s in %v", kind, u, body["data"])
|
||||
}
|
||||
if issue["tool"] != tool {
|
||||
t.Fatalf("%s %s tool = %v, want %s", kind, u, issue["tool"], tool)
|
||||
}
|
||||
return issue
|
||||
}
|
||||
expect("missing_description", "/pages/b", "seo_set_url_seo")
|
||||
dup := expect("duplicate_title", "/pages/a", "seo_set_url_seo")
|
||||
if also := fmt.Sprint(dup["details"]); !strings.Contains(also, "/pages/b") {
|
||||
t.Fatalf("duplicate details = %s, want /pages/b", also)
|
||||
}
|
||||
expect("rule_overrides_record", "/pages/a", "seo_update_rule")
|
||||
chain := expect("redirect_chain", "/old1", "seo_update_redirect")
|
||||
if details, _ := chain["details"].(map[string]any); details["final"] != "/pages/a" {
|
||||
t.Fatalf("chain details = %v, want final /pages/a", chain["details"])
|
||||
}
|
||||
expect("canonical_redirected", "/pages/b", "seo_set_url_seo")
|
||||
expect("noindex_blocked", "/pages/a", "seo_update_robots")
|
||||
if _, ok := kinds["missing_title"]; ok {
|
||||
t.Fatalf("missing_title reported though every page has a title: %v", kinds["missing_title"])
|
||||
}
|
||||
|
||||
only := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=redirect_chain", agent, nil)
|
||||
if list, _ := only["data"].([]any); len(list) != 1 {
|
||||
t.Fatalf("kind filter = %v, want one redirect_chain", only["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagementAPI walks the management routes the tools bind to as an
|
||||
// MCP client: the effective SEO with sources and owner, URL rules,
|
||||
// redirects (a loop refused with redirect_loop, a chain saved with a
|
||||
// warning, origin mcp), the 404 log actions, robots.txt and its tester,
|
||||
// settings with refusals, and history.
|
||||
func TestManagementAPI(t *testing.T) {
|
||||
env := bootEnv(t, "SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=report-token")
|
||||
token := env.admin(t, true)
|
||||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||||
env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
||||
|
||||
got := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/seo?url=/pages/a", agent, nil)
|
||||
owner, _ := got["owner"].(map[string]any)
|
||||
sources, _ := got["sources"].(map[string]any)
|
||||
if got["action"] != "ok" || owner["written_to"] != "record" || sources["title"] == nil || sources["canonical"] != "default" {
|
||||
t.Fatalf("get_url_seo = %v", got)
|
||||
}
|
||||
if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/seo?url=nope", agent, nil); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad url = %d, want 422", rec.Code)
|
||||
}
|
||||
|
||||
// URL rules.
|
||||
rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"})
|
||||
data, _ := rule["data"].(map[string]any)
|
||||
ruleID := fmt.Sprint(data["id"])
|
||||
if data["robots"] == "" || data["match"] != "pattern" {
|
||||
t.Fatalf("create_rule = %v", rule)
|
||||
}
|
||||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "validation_failed") {
|
||||
t.Fatalf("duplicate rule = %d %s, want 422 validation_failed", rec.Code, rec.Body.String())
|
||||
}
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/rules/"+ruleID, agent, map[string]any{"title": "Tagged"})
|
||||
shown := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil)
|
||||
if d, _ := shown["data"].(map[string]any); d["title"] != "Tagged" || d["robots"] == "" {
|
||||
t.Fatalf("show_rule = %v, want the update to keep robots", shown)
|
||||
}
|
||||
list := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules?search=blog", agent, nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Fatalf("list_rules = %v", list)
|
||||
}
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/rules/"+ruleID, agent, nil)
|
||||
if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("deleted rule = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Redirects: a chain is saved with a warning, a loop refused.
|
||||
first := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/x", "target": "/y"})
|
||||
if d, _ := first["data"].(map[string]any); d["origin"] != "mcp" || d["status"] != float64(301) {
|
||||
t.Fatalf("create_redirect = %v, want origin mcp and 301", first)
|
||||
}
|
||||
chain := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/y", "target": "/z"})
|
||||
if chain["warnings"] == nil {
|
||||
t.Fatalf("chain = %v, want a chain warning", chain)
|
||||
}
|
||||
loop := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/z", "target": "/x"})
|
||||
if loop.Code != http.StatusUnprocessableEntity || !strings.Contains(loop.Body.String(), `"error":"redirect_loop"`) || !strings.Contains(loop.Body.String(), `"path":[`) {
|
||||
t.Fatalf("loop = %d %s, want 422 redirect_loop with the path", loop.Code, loop.Body.String())
|
||||
}
|
||||
if n := env.redirectCount(t); n != 2 {
|
||||
t.Fatalf("redirects after the refused loop = %d, want 2", n)
|
||||
}
|
||||
firstID := fmt.Sprint(first["data"].(map[string]any)["id"])
|
||||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"source": "/z"}); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("update into a loop = %d %s, want 422", rec.Code, rec.Body.String())
|
||||
}
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"status": 302})
|
||||
if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects/"+firstID, agent, nil)["data"].(map[string]any); d["status"] != float64(302) || d["target"] != "/y" {
|
||||
t.Fatalf("show_redirect = %v", d)
|
||||
}
|
||||
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?status=302", agent, nil); l["total"] != float64(1) {
|
||||
t.Fatalf("list_redirects?status=302 = %v", l)
|
||||
}
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/redirects/"+firstID, agent, nil)
|
||||
|
||||
// 404 log.
|
||||
for _, u := range []string{"/missing-1", "/missing-2", "/missing-3"} {
|
||||
if rec := env.report(t, "report-token", map[string]string{"url": u}); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("report %s = %d", u, rec.Code)
|
||||
}
|
||||
}
|
||||
nf := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=open", agent, nil)
|
||||
rows, _ := nf["data"].([]any)
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("list_not_found = %v", nf)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, row := range rows {
|
||||
m := row.(map[string]any)
|
||||
ids[fmt.Sprint(m["url"])] = fmt.Sprint(m["id"])
|
||||
}
|
||||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{}); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("redirect without target = %d, want 422", rec.Code)
|
||||
}
|
||||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{"target": "/pages/a"})
|
||||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/gone", agent, nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("gone on a resolved address = %d %s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
gone := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-2"]+"/gone", agent, nil)
|
||||
if d, _ := gone["data"].(map[string]any); d["status"] != float64(410) || d["origin"] != "notfound" {
|
||||
t.Fatalf("gone_not_found = %v", gone)
|
||||
}
|
||||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/notfound/"+ids["/missing-3"], agent, nil)
|
||||
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=resolved", agent, nil); l["total"] != float64(2) {
|
||||
t.Fatalf("resolved = %v, want 2", l)
|
||||
}
|
||||
|
||||
// robots.txt.
|
||||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"bots": map[string]any{"NoSuchBot": false}}); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unknown bot = %d, want 422", rec.Code)
|
||||
}
|
||||
robots := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /private\n", "bots": map[string]any{"GPTBot": false}})
|
||||
if d, _ := robots["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["production"]), "GPTBot") {
|
||||
t.Fatalf("update_robots = %v, want a GPTBot group in production", robots)
|
||||
}
|
||||
test := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots/test?url=/private/x&agent=Googlebot", agent, nil)
|
||||
if res, _ := test["result"].(map[string]any); res["allowed"] != false || !strings.Contains(fmt.Sprint(res["rule"]), "/private") {
|
||||
t.Fatalf("test_robots = %v", test)
|
||||
}
|
||||
if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots", agent, nil)["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["content"]), "/private") {
|
||||
t.Fatalf("show_robots = %v", d)
|
||||
}
|
||||
|
||||
// Settings.
|
||||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"title_template": "%nope% | %site%"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "title_template") {
|
||||
t.Fatalf("bad template = %d %s, want 422 on title_template", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"url_host": "sometimes"}); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad url_host = %d, want 422", rec.Code)
|
||||
}
|
||||
settings := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{
|
||||
"site_name": "Example", "csv_delimiter": ";",
|
||||
"types": map[string]any{pagesTypeKey: map[string]any{"title_template": "%title% – %site%"}},
|
||||
})
|
||||
d, _ := settings["data"].(map[string]any)
|
||||
values, _ := d["settings"].(map[string]any)
|
||||
types, _ := d["types"].(map[string]any)
|
||||
pageType, _ := types[pagesTypeKey].(map[string]any)
|
||||
if values["site_name"] != "Example" || values["csv_delimiter"] != ";" || pageType["title_template"] != "%title% – %site%" {
|
||||
t.Fatalf("update_settings = %v", settings)
|
||||
}
|
||||
if v, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", agent, nil)["data"].(map[string]any); fmt.Sprint(v["settings"].(map[string]any)["site_name"]) != "Example" {
|
||||
t.Fatalf("show_settings = %v", v)
|
||||
}
|
||||
|
||||
// History: every write above is recorded with via mcp.
|
||||
hist := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history?via=mcp&per_page=200", agent, nil)
|
||||
entries, _ := hist["data"].([]any)
|
||||
kinds := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
kinds[fmt.Sprint(e.(map[string]any)["kind"])] = true
|
||||
}
|
||||
for _, k := range []string{"rule", "redirect", "robots", "settings", "notfound"} {
|
||||
if !kinds[k] {
|
||||
t.Fatalf("history via mcp lacks %s: %v", k, kinds)
|
||||
}
|
||||
}
|
||||
firstEntry := entries[0].(map[string]any)
|
||||
one := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history/"+fmt.Sprint(firstEntry["id"]), agent, nil)
|
||||
if one["data"].(map[string]any)["via"] != "mcp" {
|
||||
t.Fatalf("show_history = %v", one)
|
||||
}
|
||||
}
|
||||
|
||||
95
operations.go
Normal file
95
operations.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package seo
|
||||
|
||||
import "git.golem15.com/golem15/sm-seo-plugin/controllers"
|
||||
|
||||
// The kinds of an admin operation (AdminOperation.Kind). Record, bulk and
|
||||
// widget actions add ":" and the action's name.
|
||||
const (
|
||||
// OpList is a controller's list.
|
||||
OpList = "list"
|
||||
// OpShow is a record's form or preview, or a singleton's form.
|
||||
OpShow = "show"
|
||||
// OpCreate is the create form's save (toolbar create).
|
||||
OpCreate = "create"
|
||||
// OpUpdate is the update form's save, or a singleton's save.
|
||||
OpUpdate = "update"
|
||||
// OpDelete is the form's and the list toolbar's delete.
|
||||
OpDelete = "delete"
|
||||
// OpExtensionSave is a form extension's save on the controller.
|
||||
OpExtensionSave = "extension_save"
|
||||
// OpUpload is a field type's upload.
|
||||
OpUpload = "upload"
|
||||
// OpRecordAction is a record action ("record_action:<name>").
|
||||
OpRecordAction = "record_action"
|
||||
// OpBulkAction is a bulk action ("bulk_action:<name>").
|
||||
OpBulkAction = "bulk_action"
|
||||
// OpWidgetAction is a widget action ("widget_action:<name>").
|
||||
OpWidgetAction = "widget_action"
|
||||
)
|
||||
|
||||
// ContentController stands for the form of every content type the SEO tab
|
||||
// is added to (D-91): the operations of the tab are the same on all.
|
||||
const ContentController = "*"
|
||||
|
||||
// AdminOperation is one operation of the SEO admin and the MCP tool that
|
||||
// performs it (D-10): the controller, the kind of operation and the tool
|
||||
// name without the seo_ prefix.
|
||||
type AdminOperation struct {
|
||||
Controller string
|
||||
Kind string
|
||||
Tool string
|
||||
}
|
||||
|
||||
// AdminOperations maps every operation of the SEO admin to its MCP tool
|
||||
// (D-10, SC-4). TestEveryAdminOperationHasTool derives the operations from
|
||||
// the controllers, their configs, actions and form extensions, and fails
|
||||
// when one is missing here, when a tool named here does not exist, or when
|
||||
// a tool is not bound to a registered route.
|
||||
var AdminOperations = []AdminOperation{
|
||||
// The SEO tab of every content type.
|
||||
{ContentController, OpShow, "get_url_seo"},
|
||||
{ContentController, OpExtensionSave, "set_url_seo"},
|
||||
{ContentController, OpWidgetAction + ":seo_snippet", "get_url_seo"},
|
||||
// The social image is set by URL through MCP (uploads are no tool).
|
||||
{ContentController, OpUpload, "set_url_seo"},
|
||||
|
||||
// SEO settings and the per-type tabs.
|
||||
{controllers.SettingsControllerID, OpShow, "show_settings"},
|
||||
{controllers.SettingsControllerID, OpUpdate, "update_settings"},
|
||||
{controllers.SettingsControllerID, OpExtensionSave, "update_settings"},
|
||||
|
||||
// Redirects.
|
||||
{controllers.RedirectsControllerID, OpList, "list_redirects"},
|
||||
{controllers.RedirectsControllerID, OpShow, "show_redirect"},
|
||||
{controllers.RedirectsControllerID, OpCreate, "create_redirect"},
|
||||
{controllers.RedirectsControllerID, OpUpdate, "update_redirect"},
|
||||
{controllers.RedirectsControllerID, OpDelete, "delete_redirect"},
|
||||
{controllers.RedirectsControllerID, OpExtensionSave, "update_redirect"},
|
||||
|
||||
// 404 log.
|
||||
{controllers.NotFoundControllerID, OpList, "list_not_found"},
|
||||
{controllers.NotFoundControllerID, OpShow, "list_not_found"},
|
||||
{controllers.NotFoundControllerID, OpDelete, "delete_not_found"},
|
||||
{controllers.NotFoundControllerID, OpRecordAction + ":" + controllers.NotFoundActionRedirect, "redirect_not_found"},
|
||||
{controllers.NotFoundControllerID, OpRecordAction + ":" + controllers.NotFoundActionGone, "gone_not_found"},
|
||||
{controllers.NotFoundControllerID, OpBulkAction + ":" + controllers.NotFoundActionGone, "gone_not_found"},
|
||||
|
||||
// URL rules.
|
||||
{controllers.RulesControllerID, OpList, "list_rules"},
|
||||
{controllers.RulesControllerID, OpShow, "show_rule"},
|
||||
{controllers.RulesControllerID, OpCreate, "create_rule"},
|
||||
{controllers.RulesControllerID, OpUpdate, "update_rule"},
|
||||
{controllers.RulesControllerID, OpDelete, "delete_rule"},
|
||||
{controllers.RulesControllerID, OpExtensionSave, "update_rule"},
|
||||
{controllers.RulesControllerID, OpWidgetAction + ":seo_snippet", "get_url_seo"},
|
||||
|
||||
// robots.txt and AI bots.
|
||||
{controllers.RobotsControllerID, OpShow, "show_robots"},
|
||||
{controllers.RobotsControllerID, OpUpdate, "update_robots"},
|
||||
{controllers.RobotsControllerID, OpExtensionSave, "update_robots"},
|
||||
{controllers.RobotsControllerID, OpWidgetAction + ":" + RobotsTestAction, "test_robots"},
|
||||
|
||||
// History.
|
||||
{controllers.HistoryControllerID, OpList, "list_history"},
|
||||
{controllers.HistoryControllerID, OpShow, "show_history"},
|
||||
}
|
||||
242
operations_test.go
Normal file
242
operations_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// recordingRouter records the method and full pattern of every route.
|
||||
type recordingRouter struct {
|
||||
prefix string
|
||||
routes map[string]bool
|
||||
}
|
||||
|
||||
func (r *recordingRouter) add(method, path string) { r.routes[method+" "+r.prefix+path] = true }
|
||||
func (r *recordingRouter) Group(prefix string, _ []string, fn func(pact.Router)) {
|
||||
fn(&recordingRouter{prefix: r.prefix + prefix, routes: r.routes})
|
||||
}
|
||||
func (r *recordingRouter) GroupRaw(prefix string, mw []string, fn func(pact.Router)) {
|
||||
r.Group(prefix, mw, fn)
|
||||
}
|
||||
func (r *recordingRouter) Get(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodGet, p) }
|
||||
func (r *recordingRouter) Post(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodPost, p) }
|
||||
func (r *recordingRouter) Put(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodPut, p) }
|
||||
func (r *recordingRouter) Patch(p string, _ http.HandlerFunc, _ ...string) {
|
||||
r.add(http.MethodPatch, p)
|
||||
}
|
||||
func (r *recordingRouter) Delete(p string, _ http.HandlerFunc, _ ...string) {
|
||||
r.add(http.MethodDelete, p)
|
||||
}
|
||||
func (r *recordingRouter) Where(string, string) {}
|
||||
func (r *recordingRouter) WhereIn(string, ...string) {}
|
||||
|
||||
// routesOf records the routes the plugin registers.
|
||||
func routesOf(t *testing.T, p *Plugin) map[string]bool {
|
||||
t.Helper()
|
||||
rec := &recordingRouter{routes: map[string]bool{}}
|
||||
if err := p.Routes(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rec.routes
|
||||
}
|
||||
|
||||
// configKeys reads the top-level keys and toolbar buttons of a controller
|
||||
// config file; a missing file answers none.
|
||||
func configKeys(t *testing.T, path string) (keys map[string]bool, buttons []string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
keys = map[string]bool{}
|
||||
for k := range doc {
|
||||
keys[k] = true
|
||||
}
|
||||
if tb, ok := doc["toolbar"].(map[string]any); ok {
|
||||
if list, ok := tb["buttons"].([]any); ok {
|
||||
for _, b := range list {
|
||||
if s, ok := b.(string); ok {
|
||||
buttons = append(buttons, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys, buttons
|
||||
}
|
||||
|
||||
// requiredOperations derives the operations of the SEO admin from the
|
||||
// controllers, their configs, actions, field types and form extensions.
|
||||
func requiredOperations(t *testing.T, p *Plugin) []AdminOperation {
|
||||
t.Helper()
|
||||
var out []AdminOperation
|
||||
need := func(controller, kind string) {
|
||||
out = append(out, AdminOperation{Controller: controller, Kind: kind})
|
||||
}
|
||||
noApp := func() *backpack.App { return nil }
|
||||
for _, c := range controllers.AdminControllers(noApp) {
|
||||
id := c.ID()
|
||||
formKeys, _ := configKeys(t, filepath.Join(c.ConfigDir(), "config_form.yaml"))
|
||||
listKeys, buttons := configKeys(t, filepath.Join(c.ConfigDir(), "config_list.yaml"))
|
||||
if _, ok := c.(pact.AdminSingleton); ok {
|
||||
need(id, OpShow)
|
||||
need(id, OpUpdate)
|
||||
} else {
|
||||
if len(listKeys) > 0 {
|
||||
need(id, OpList)
|
||||
}
|
||||
if len(formKeys) > 0 {
|
||||
need(id, OpShow)
|
||||
}
|
||||
if formKeys["update"] {
|
||||
need(id, OpUpdate)
|
||||
}
|
||||
}
|
||||
for _, b := range buttons {
|
||||
switch b {
|
||||
case "create":
|
||||
need(id, OpCreate)
|
||||
case "delete":
|
||||
need(id, OpDelete)
|
||||
}
|
||||
}
|
||||
if h, ok := c.(pact.HasAdminRecordActions); ok {
|
||||
for _, a := range h.AdminRecordActions() {
|
||||
need(id, OpRecordAction+":"+a.Name)
|
||||
}
|
||||
}
|
||||
if h, ok := c.(pact.HasAdminBulkActions); ok {
|
||||
for _, a := range h.AdminBulkActions() {
|
||||
need(id, OpBulkAction+":"+a.Name)
|
||||
}
|
||||
}
|
||||
if h, ok := c.(pact.HasAdminActions); ok {
|
||||
for _, a := range h.AdminActions() {
|
||||
need(id, OpWidgetAction+":"+a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The form extensions the plugin adds (AdminActionExtensions needs a
|
||||
// booted registry, so each builder is called directly).
|
||||
exts := []pact.AdminActionExtension{
|
||||
{Controller: ContentController, Form: p.seoTab("acme-page", "Page")},
|
||||
{Controller: controllers.SettingsControllerID, Form: p.typeSettingsTabs([]pact.MenuItemType{{Key: "acme-page", Label: "Page"}})},
|
||||
{Controller: controllers.RulesControllerID, Form: p.ruleHistory()},
|
||||
{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()},
|
||||
{Controller: controllers.RobotsControllerID, Form: p.robotsForm()},
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.Form == nil {
|
||||
t.Fatalf("extension on %s is nil", ext.Controller)
|
||||
}
|
||||
if ext.Form.Save != nil {
|
||||
need(ext.Controller, OpExtensionSave)
|
||||
}
|
||||
if ext.Form.Load != nil {
|
||||
need(ext.Controller, OpShow)
|
||||
}
|
||||
for _, a := range ext.Form.Actions {
|
||||
need(ext.Controller, OpWidgetAction+":"+a.Name)
|
||||
}
|
||||
}
|
||||
for _, ft := range classes.FieldTypes(noApp, controllers.PermissionManage) {
|
||||
if ft.Upload != nil {
|
||||
need(ContentController, OpUpload)
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEveryAdminOperationHasTool is the D-10 parity check (SC-4): every
|
||||
// operation of the SEO admin is listed in AdminOperations, every listed
|
||||
// tool exists in MCP(), and every tool is bound to a route Routes()
|
||||
// registers (pier refuses an unbound tool at boot).
|
||||
func TestEveryAdminOperationHasTool(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
tools := map[string]pact.MCPTool{}
|
||||
for _, tool := range p.MCP().Tools {
|
||||
if _, dup := tools[tool.Name]; dup {
|
||||
t.Errorf("tool %s is declared twice", tool.Name)
|
||||
}
|
||||
tools[tool.Name] = tool
|
||||
}
|
||||
mapped := map[string]string{}
|
||||
for _, op := range AdminOperations {
|
||||
mapped[op.Controller+" "+op.Kind] = op.Tool
|
||||
if _, ok := tools[op.Tool]; !ok {
|
||||
t.Errorf("operation %s %s maps to tool %q, which MCP() does not declare", op.Controller, op.Kind, op.Tool)
|
||||
}
|
||||
}
|
||||
required := requiredOperations(t, p)
|
||||
if len(required) < 20 {
|
||||
t.Fatalf("derived only %d operations; the inventory is broken", len(required))
|
||||
}
|
||||
for _, op := range required {
|
||||
if _, ok := mapped[op.Controller+" "+op.Kind]; !ok {
|
||||
t.Errorf("admin operation %s %s has no MCP tool in AdminOperations", op.Controller, op.Kind)
|
||||
}
|
||||
}
|
||||
routes := routesOf(t, p)
|
||||
for _, tool := range tools {
|
||||
if !routes[tool.Method+" "+tool.Path] {
|
||||
t.Errorf("tool %s binds to %s %s, which Routes does not register", tool.Name, tool.Method, tool.Path)
|
||||
}
|
||||
if !strings.HasPrefix(tool.Path, "/_seo/api/v1/") {
|
||||
t.Errorf("tool %s is not on the management API: %s", tool.Name, tool.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPReadToolsAreGET checks the read/write ceiling (D-10, T-32-23):
|
||||
// a tool is read-only exactly when it is a GET (so a read grant sees only
|
||||
// GET tools), destructive tools are never GET, and every tool runs as the
|
||||
// backend guard behind golem15.seo.manage.
|
||||
func TestMCPReadToolsAreGET(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
reads := 0
|
||||
for _, tool := range p.MCP().Tools {
|
||||
get := tool.Method == http.MethodGet
|
||||
readOnly := get
|
||||
if tool.ReadOnly != nil {
|
||||
readOnly = *tool.ReadOnly
|
||||
}
|
||||
if readOnly != get {
|
||||
t.Errorf("tool %s: read-only %v but method %s", tool.Name, readOnly, tool.Method)
|
||||
}
|
||||
if get {
|
||||
reads++
|
||||
}
|
||||
if tool.Destructive != nil && *tool.Destructive && get {
|
||||
t.Errorf("tool %s is a destructive GET", tool.Name)
|
||||
}
|
||||
if tool.Method == http.MethodDelete && tool.Destructive != nil && !*tool.Destructive {
|
||||
t.Errorf("tool %s: a DELETE marked not destructive", tool.Name)
|
||||
}
|
||||
if tool.Guard != "backend" {
|
||||
t.Errorf("tool %s: guard %q, want backend", tool.Name, tool.Guard)
|
||||
}
|
||||
if !slices.Equal(tool.Permissions, []string{controllers.PermissionManage}) {
|
||||
t.Errorf("tool %s: permissions %v, want [%s]", tool.Name, tool.Permissions, controllers.PermissionManage)
|
||||
}
|
||||
}
|
||||
if reads == 0 {
|
||||
t.Fatal("no read tool")
|
||||
}
|
||||
}
|
||||
23
routes.go
23
routes.go
@@ -42,7 +42,30 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
})
|
||||
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
|
||||
api := "throttle:" + BucketAPI
|
||||
g.Get("/seo", apicontrollers.GetSEO(p.Service), api)
|
||||
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
|
||||
g.Get("/rules", apicontrollers.ListRules(p.Service), api)
|
||||
g.Post("/rules", apicontrollers.CreateRule(p.Service), api)
|
||||
g.Get("/rules/{id}", apicontrollers.ShowRule(p.Service), api)
|
||||
g.Put("/rules/{id}", apicontrollers.UpdateRule(p.Service), api)
|
||||
g.Delete("/rules/{id}", apicontrollers.DeleteRule(p.Service), api)
|
||||
g.Get("/redirects", apicontrollers.ListRedirects(p.Service), api)
|
||||
g.Post("/redirects", apicontrollers.CreateRedirect(p.Service), api)
|
||||
g.Get("/redirects/{id}", apicontrollers.ShowRedirect(p.Service), api)
|
||||
g.Put("/redirects/{id}", apicontrollers.UpdateRedirect(p.Service), api)
|
||||
g.Delete("/redirects/{id}", apicontrollers.DeleteRedirect(p.Service), api)
|
||||
g.Get("/notfound", apicontrollers.ListNotFound(p.Service), api)
|
||||
g.Post("/notfound/{id}/redirect", apicontrollers.RedirectNotFound(p.Service), api)
|
||||
g.Post("/notfound/{id}/gone", apicontrollers.GoneNotFound(p.Service), api)
|
||||
g.Delete("/notfound/{id}", apicontrollers.DeleteNotFound(p.Service), api)
|
||||
g.Get("/robots", apicontrollers.ShowRobots(p.Service), api)
|
||||
g.Put("/robots", apicontrollers.UpdateRobots(p.Service), api)
|
||||
g.Get("/robots/test", apicontrollers.TestRobotsURL(p.Service), api)
|
||||
g.Get("/settings", apicontrollers.ShowSettings(p.Service), api)
|
||||
g.Put("/settings", apicontrollers.UpdateSettings(p.Service), api)
|
||||
g.Get("/history", apicontrollers.ListHistory(p.Service), api)
|
||||
g.Get("/history/{id}", apicontrollers.ShowHistory(p.Service), api)
|
||||
g.Get("/issues", apicontrollers.ListIssues(p.Service), api)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user