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: two lists (routes and // noindex) of 2000 paths of 2048 bytes with their JSON quoting. const maxRouteReportBody = 2*classes.MaxReportedRoutes*(classes.MaxRouteBytes+8) + 1024 // routeReport is the route report body. Noindex is nil when the key is // absent and non-nil (also empty) when it is present. type routeReport struct { Routes []string `json:"routes"` Noindex *[]string `json:"noindex"` } // routeReportAnswer is the route report answer: the counts of the routes // and the number of stored noindex paths. type routeReportAnswer struct { classes.RouteReport Noindex int `json:"noindex"` } // RouteReport serves POST /_seo/api/v1/registry/routes (D-33, P33-20): the // frontend server reports its static routes with "Authorization: Bearer // ", 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. // // The optional noindex key (P33-58) lists the static paths the frontend // serves noindex: when it is present (also as []), the stored set becomes // exactly its valid entries, normalised like the routes, in the same // transaction (an invalid entry is skipped and counted in skipped); when it // is absent the stored set is unchanged. The sitemap filter leaves those // paths out of the sitemaps, and a changed set asks the sitemap for one // debounced rebuild. // // It answers 200 with the counts and noindex, the number of stored noindex // paths; 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 in either list, storing nothing. 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 || (in.Noindex != nil && len(*in.Noindex) > classes.MaxReportedRoutes) { wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "too_many_routes", "max": classes.MaxReportedRoutes}) return } var out routeReportAnswer err := write(r.Context(), svc, func(ctx context.Context, tx *gorm.DB) error { var err error out.RouteReport, err = svc.ReportRoutes(ctx, tx, in.Routes) if err != nil { return err } if in.Noindex != nil { var skipped int out.Noindex, skipped, _, err = svc.ReportNoindex(ctx, tx, *in.Noindex) out.Skipped += skipped return err } out.Noindex, err = classes.NoindexPathCount(tx) 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 }) }