- POST /_seo/api/v1/registry/routes (public, bucket seo-notfound): the frontend server reports its static routes with the bearer token golem15.seo.route_report_token, else notfound_token (constant-time; both empty answer 404); at most 2000 paths - unknown paths become registry pages (origin reported, kind page, a name from the last segment), known ones get reported_at; paths another content type owns and invalid ones are skipped; nothing is deleted or overwritten, and the report's time marks missing pages
365 lines
13 KiB
Go
365 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"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"
|
|
)
|
|
|
|
// maxRouteReportBody caps the route report body: 2000 paths of 2048 bytes
|
|
// with their JSON quoting.
|
|
const maxRouteReportBody = classes.MaxReportedRoutes*(classes.MaxRouteBytes+8) + 1024
|
|
|
|
// routeReport is the route report body.
|
|
type routeReport struct {
|
|
Routes []string `json:"routes"`
|
|
}
|
|
|
|
// RouteReport serves POST /_seo/api/v1/registry/routes (D-33, P33-20): the
|
|
// frontend server reports its static routes with "Authorization: Bearer
|
|
// <token>", golem15.seo.route_report_token (else notfound_token), compared
|
|
// in constant time. Unknown paths become registry pages (origin reported),
|
|
// known ones get reported_at; nothing is deleted or overwritten. It answers
|
|
// 200 with the counts, 401 for a missing or wrong token, 404 when no token
|
|
// is configured (the report is off), and 422 for a malformed body or more
|
|
// than 2000 paths.
|
|
func RouteReport(service func() *classes.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
svc := service()
|
|
if svc == nil {
|
|
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"})
|
|
return
|
|
}
|
|
want := svc.RouteReportToken()
|
|
if want == "" {
|
|
wire.WriteJSON(w, http.StatusNotFound, map[string]string{"error": errNotFound})
|
|
return
|
|
}
|
|
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok || subtle.ConstantTimeCompare([]byte(strings.TrimSpace(got)), []byte(want)) != 1 {
|
|
wire.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
var in routeReport
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRouteReportBody))
|
|
if err := dec.Decode(&in); err != nil {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": errInvalidBody})
|
|
return
|
|
}
|
|
if len(in.Routes) > classes.MaxReportedRoutes {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "too_many_routes", "max": classes.MaxReportedRoutes})
|
|
return
|
|
}
|
|
var out classes.RouteReport
|
|
err := write(r.Context(), svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
var err error
|
|
out, err = svc.ReportRoutes(ctx, tx, in.Routes)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, classes.ErrTooManyRoutes) {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "too_many_routes", "max": classes.MaxReportedRoutes})
|
|
return
|
|
}
|
|
log.Error("golem15.seo: route report", "error", err)
|
|
wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": errInternal})
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// ListPagesInput is the query of GET /_seo/api/v1/pages.
|
|
type ListPagesInput 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, another locale's address or the name contains"`
|
|
ID uint `json:"id,omitempty" query:"id" jsonschema:"one page by id"`
|
|
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"page, offer, service or landing"`
|
|
Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, reported or mcp"`
|
|
Missing string `json:"missing,omitempty" query:"missing" jsonschema:"only: reported pages the site's newest route report did not include; hide: all others"`
|
|
}
|
|
|
|
// PageIDInput names one registry page.
|
|
type PageIDInput struct {
|
|
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
|
}
|
|
|
|
// CreatePageInput is the body of POST /_seo/api/v1/pages.
|
|
type CreatePageInput struct {
|
|
URL map[string]string `json:"url" jsonschema:"the page's path per locale code, e.g. {\"pl\": \"/kontakt\", \"en\": \"/contact\"}; the default locale is required, a locale left out or empty has no version of the page; a plain string is the default locale's path"`
|
|
Title map[string]string `json:"title,omitempty" jsonschema:"the page's name per locale code (default SEO title, HTML sitemap, breadcrumbs); a plain string is the default locale's"`
|
|
Kind *string `json:"kind,omitempty" jsonschema:"page (the default), offer, service or landing: decides the sitemap file and the default structured data"`
|
|
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id (breadcrumbs); null or 0 for the top level"`
|
|
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"the date the page's content last changed, YYYY-MM-DD (sitemap lastmod); today by default"`
|
|
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
|
}
|
|
|
|
// UpdatePageInput is PUT /_seo/api/v1/pages/{id}: only the fields sent are
|
|
// changed; in url and title only the locales sent.
|
|
type UpdatePageInput struct {
|
|
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
|
URL map[string]string `json:"url,omitempty" jsonschema:"paths per locale code to change; an empty path removes that locale's version (the default locale's cannot be removed)"`
|
|
Title map[string]string `json:"title,omitempty" jsonschema:"names per locale code to change"`
|
|
Kind *string `json:"kind,omitempty" jsonschema:"page, offer, service or landing"`
|
|
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id; null or 0 for the top level"`
|
|
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"YYYY-MM-DD; empty clears it"`
|
|
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
|
}
|
|
|
|
// pageBody is a decoded create or update body; a translatable value is a
|
|
// locale map or a plain string (the default locale's).
|
|
type pageBody struct {
|
|
write classes.PageWrite
|
|
}
|
|
|
|
func (b *pageBody) UnmarshalJSON(raw []byte) error {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
return err
|
|
}
|
|
texts := func(name string) (map[string]string, error) {
|
|
v, ok := fields[name]
|
|
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
|
return nil, nil
|
|
}
|
|
var s string
|
|
if json.Unmarshal(v, &s) == nil {
|
|
return map[string]string{"": s}, nil
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(v, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil {
|
|
m = map[string]string{}
|
|
}
|
|
return m, nil
|
|
}
|
|
var err error
|
|
if b.write.URL, err = texts("url"); err != nil {
|
|
return err
|
|
}
|
|
if b.write.Title, err = texts("title"); err != nil {
|
|
return err
|
|
}
|
|
str := func(name string) (*string, error) {
|
|
v, ok := fields[name]
|
|
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
|
return nil, nil
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(v, &s); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
if b.write.Kind, err = str("kind"); err != nil {
|
|
return err
|
|
}
|
|
if b.write.ContentUpdatedAt, err = str("content_updated_at"); err != nil {
|
|
return err
|
|
}
|
|
if b.write.Note, err = str("note"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := fields["parent_id"]; ok {
|
|
b.write.ParentSet = true
|
|
if !bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
|
var n uint
|
|
if err := json.Unmarshal(v, &n); err != nil {
|
|
return err
|
|
}
|
|
if n != 0 {
|
|
b.write.ParentID = &n
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PageJSON is a registry page in an answer: its default-locale address and
|
|
// name, every locale's address and name, and whether the newest route
|
|
// report missed it.
|
|
func PageJSON(p models.Page, def string, reported *bool) map[string]any {
|
|
urls := p.LocaleURLs(def)
|
|
titles := map[string]string{def: p.Title}
|
|
for l, t := range p.Titles.Get() {
|
|
if l != def && t != "" {
|
|
titles[l] = t
|
|
}
|
|
}
|
|
var parent any
|
|
if p.Parent() != 0 {
|
|
parent = p.Parent()
|
|
}
|
|
var date any
|
|
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
|
date = p.ContentUpdatedAt.String()
|
|
}
|
|
out := map[string]any{
|
|
"id": p.ID, "url": p.URL, "urls": urls, "title": p.Title, "titles": titles,
|
|
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date,
|
|
"reported_at": p.ReportedAt, "note": p.Note, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt,
|
|
}
|
|
if reported != nil {
|
|
out["missing"] = *reported
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pageAnswer is a page in an answer with its missing state.
|
|
func pageAnswer(ctx context.Context, svc *classes.Service, db *gorm.DB, p models.Page) (map[string]any, error) {
|
|
settings, err := classes.LoadSettings(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
missing := classes.PageMissing(p, settings.RoutesReportedAt)
|
|
return PageJSON(p, svc.DefaultLocale(ctx, db), &missing), nil
|
|
}
|
|
|
|
// ListPages serves GET /_seo/api/v1/pages: the registry by address,
|
|
// filtered by search text, id, kind, origin and the missing state.
|
|
func ListPages(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)
|
|
query := r.URL.Query()
|
|
q := db.Model(&models.Page{}).Where("url <> ''").Order("url ASC, id ASC")
|
|
if in.Search != "" {
|
|
p := like(in.Search)
|
|
q = q.Where(`lower(url) LIKE ? OR lower(title) LIKE ? OR lower(urls::text) LIKE ? OR lower(titles::text) LIKE ?`, p, p, p, p)
|
|
}
|
|
if id, err := strconv.ParseUint(query.Get("id"), 10, 64); err == nil && id > 0 {
|
|
q = q.Where("id = ?", id)
|
|
}
|
|
for param, scope := range map[string]string{"kind": models.FilterPageKind, "origin": models.FilterPageOrigin, "missing": models.FilterPageMissing} {
|
|
if v := strings.TrimSpace(query.Get(param)); v != "" {
|
|
q = models.Page{}.FilterScope(scope, q, v)
|
|
}
|
|
}
|
|
var rows []models.Page
|
|
total, err := paginate(q, in, &rows)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
settings, err := classes.LoadSettings(db)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
def := svc.DefaultLocale(ctx, db)
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
missing := classes.PageMissing(row, settings.RoutesReportedAt)
|
|
out = append(out, PageJSON(row, def, &missing))
|
|
}
|
|
return http.StatusOK, listBody(out, in, total), nil
|
|
})
|
|
}
|
|
|
|
// savePage applies a body to p and saves it in tx.
|
|
func savePage(ctx context.Context, svc *classes.Service, tx *gorm.DB, p *models.Page, w classes.PageWrite, before map[string]any) error {
|
|
problems := classes.ApplyPageFields(p, w)
|
|
if len(problems) > 0 {
|
|
return &classes.ProblemsError{Problems: problems}
|
|
}
|
|
if err := svc.ApplyPageTexts(ctx, tx, p, w.URL, w.Title); err != nil {
|
|
return err
|
|
}
|
|
return svc.SavePage(ctx, tx, p, before)
|
|
}
|
|
|
|
// CreatePage serves POST /_seo/api/v1/pages: 201 with the page, 422
|
|
// validation_failed with the C21 refusals (url_required, url_invalid,
|
|
// url_taken, url_owned, parent_loop). The origin is mcp through MCP.
|
|
func CreatePage(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
var body pageBody
|
|
if err := decode(r, maxBody, &body); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
row := models.NewPage()
|
|
row.Origin = classes.PageOrigin(ctx)
|
|
var answer map[string]any
|
|
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
if err := savePage(ctx, svc, tx, &row, body.write, nil); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
answer, err = pageAnswer(ctx, svc, tx, row)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusCreated, map[string]any{"data": answer}, nil
|
|
})
|
|
}
|
|
|
|
// UpdatePage serves PUT /_seo/api/v1/pages/{id}: the fields sent change
|
|
// and are checked like a create.
|
|
func UpdatePage(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 pageBody
|
|
if err := decode(r, maxBody, &body); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
var answer map[string]any
|
|
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
row, err := classes.LoadPage(tx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := savePage(ctx, svc, tx, &row, body.write, classes.PageValues(row)); err != nil {
|
|
return err
|
|
}
|
|
answer, err = pageAnswer(ctx, svc, tx, row)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusOK, map[string]any{"data": answer}, nil
|
|
})
|
|
}
|
|
|
|
// DeletePage serves DELETE /_seo/api/v1/pages/{id}: the page and its SEO
|
|
// entries go (history keeps them); a page the site reports comes back with
|
|
// its next route report.
|
|
func DeletePage(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.LoadPage(tx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return svc.DeletePage(ctx, tx, row)
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return http.StatusOK, map[string]any{"deleted": id}, nil
|
|
})
|
|
}
|