package api import ( "context" "crypto/subtle" "encoding/json" "errors" "net/http" "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 // with room to spare. const maxReportBody = 8 << 10 // notFoundReport is the 404 report body. type notFoundReport struct { URL string `json:"url"` Referer string `json:"referer"` } // NotFound serves POST /_seo/api/v1/notfound (D-20, P-22): the frontend // server reports a 404 with "Authorization: Bearer ", the configured // golem15.seo.notfound_token, compared in constant time. It answers 204, // 401 for a missing or wrong token, 404 when no token is configured (the // endpoint is off), and 422 for a body without a path or http(s) URL of at // most 2048 bytes. func NotFound(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.NotFoundToken() if want == "" { wire.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "Not Found"}) 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 notFoundReport dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody)) if err := dec.Decode(&in); err != nil { wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "The request body is invalid."}) return } if err := svc.ReportNotFound(r.Context(), in.URL, in.Referer); err != nil { if errors.Is(err, classes.ErrNotFoundURL) { wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) return } log.Error("golem15.seo: 404 report", "error", err) wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal Server Error"}) return } 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 }) }