- POST /_seo/api/v1/batch runs changes in one transaction: dry_run answers the diff and the conflicts (loops, chains, noindex on a published record, canonicals to redirected or noindex addresses) and rolls back; a refused change refuses the batch (D-12) - undo of a change or a whole batch from History and through MCP, with the undo_stale, undo_loop and undo_gone refusals (D-13) - CSV export and import of redirects, rules and records by URL: BOM, comma or semicolon, formula cells escaped, a dry-run preview, all or nothing with via import; the screen's apply token is HMAC-SHA256 over the file and a fingerprint of the data (transfer.stale) - Import and export singleton with the seo_transfer widget action, History record actions undo and undo_batch, navigation entry - MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv - TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
206 lines
7.6 KiB
Go
206 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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"`
|
|
}
|
|
|
|
// 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 classes.RedirectFields
|
|
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 classes.RedirectFields
|
|
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
|
|
})
|
|
}
|