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 } if problems := body.Problems(); len(problems) > 0 { return 0, nil, &classes.ProblemsError{Problems: problems} } 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 } if problems := body.Problems(); len(problems) > 0 { return 0, nil, &classes.ProblemsError{Problems: problems} } 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 }) }