package api import ( "context" "errors" "net/http" "strconv" "strings" "time" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/classes/crawl" "git.golem15.com/golem15/sm-seo-plugin/models" "gorm.io/gorm" ) // ListIssuesInput is the query of GET /_seo/api/v1/issues. type ListIssuesInput struct { Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, content_js, orphan_page, redirect_chain or sitemap_blocked"` Severity string `json:"severity,omitempty" query:"severity" jsonschema:"error, warning or notice"` Source string `json:"source,omitempty" query:"source" jsonschema:"crawl (found by the crawler) or cms (CMS data checks)"` Fix string `json:"fix,omitempty" query:"fix" jsonschema:"tool (fixable through the MCP tool the issue names) or code (needs a change in the site's templates)"` Status string `json:"status,omitempty" query:"status" jsonschema:"open (the default), ignored or all"` URL string `json:"url,omitempty" query:"url" jsonschema:"only issues of this address (a path such as /blog/post)"` Crawl uint `json:"crawl,omitempty" query:"crawl" jsonschema:"the issues of this crawl id instead of the current ones"` } // IssueIDInput names one issue. type IssueIDInput struct { ID uint `json:"id" path:"id" jsonschema:"the issue id"` } // IssueIDsInput is the body of the ignore and unignore calls. type IssueIDsInput struct { IDs []uint `json:"ids" jsonschema:"the issue ids"` } // IssueView is a stored issue in an answer: the Phase 32 keys (kind, url, // locale, details, tool) and the audit's id, severity, source, fix, // found_at, ignored and crawl_id. tool is the fixing MCP tool when fix is // tool; for fix code, details.what names the template element. type IssueView struct { ID uint `json:"id"` Kind string `json:"kind"` URL string `json:"url"` Locale string `json:"locale,omitempty"` Details map[string]any `json:"details,omitempty"` Tool string `json:"tool"` Severity string `json:"severity"` Source string `json:"source"` Fix string `json:"fix"` FoundAt *time.Time `json:"found_at"` Ignored bool `json:"ignored"` CrawlID *uint `json:"crawl_id"` } // issueViews turns stored issues into their answer form. func issueViews(db *gorm.DB, rows []models.Issue) ([]IssueView, error) { fps := make([]string, 0, len(rows)) for _, r := range rows { fps = append(fps, r.Fingerprint) } ignored, err := crawl.IgnoredSet(db, fps) if err != nil { return nil, err } out := make([]IssueView, 0, len(rows)) for _, r := range rows { out = append(out, IssueView{ ID: r.ID, Kind: r.Kind, URL: r.URL, Locale: r.Locale, Details: r.DetailsMap(), Tool: r.Tool, Severity: r.Severity, Source: r.Source, Fix: r.Fix, FoundAt: r.FoundAt, Ignored: ignored[r.Fingerprint], CrawlID: r.CrawlID, }) } return out, nil } // ListIssues serves GET /_seo/api/v1/issues (D-13, D-14): the audit's // current issues, errors first: the newest crawl's findings with the // CMS-data issues stored with it, or before any crawl the CMS-data issues // as they are now (crawl selects another crawl's). Each issue names the // tool that fixes it, or fix code. The filters are severity, kind, // source, fix, url and status (open by default: ignored issues are left // out; ignored or all). counts holds the number per kind before the kind // filter. func ListIssues(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 } latest, err := crawl.Current(ctx, svc, db) if err != nil { return 0, nil, err } q := r.URL.Query() base := crawl.CurrentScope(db.Model(&models.Issue{}), latest) if id, err := strconv.ParseUint(strings.TrimSpace(q.Get("crawl")), 10, 64); err == nil && id > 0 { base = db.Model(&models.Issue{}).Where("golem15_seo_issues.crawl_id = ?", id) } var all []models.Issue if err := base.Order("rank ASC, kind ASC, url ASC, id ASC").Find(&all).Error; err != nil { return 0, nil, err } views, err := issueViews(db, all) if err != nil { return 0, nil, err } status := strings.TrimSpace(q.Get("status")) if status == "" { status = models.IssueOpen } match := map[string]string{ "kind": strings.TrimSpace(q.Get("kind")), "severity": strings.TrimSpace(q.Get("severity")), "source": strings.TrimSpace(q.Get("source")), "fix": strings.TrimSpace(q.Get("fix")), "url": strings.TrimSpace(q.Get("url")), } counts := map[string]int{} out := make([]IssueView, 0, len(views)) for _, v := range views { if status == models.IssueOpen && v.Ignored || status == models.IssueIgnored && !v.Ignored { continue } if !matches(match["severity"], v.Severity) || !matches(match["source"], v.Source) || !matches(match["fix"], v.Fix) || !matches(match["url"], v.URL) { continue } counts[v.Kind]++ if matches(match["kind"], v.Kind) { out = append(out, v) } } return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil }) } // matches reports whether a filter value (empty: any) matches. func matches(filter, value string) bool { return filter == "" || filter == value } // RecheckIssue serves POST /_seo/api/v1/issues/{id}/recheck: the issue's // address is fetched again through the crawler client and its issues are // replaced; the answer has the address, the issues left and the message. func RecheckIssue(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 res crawl.RecheckResult err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { issue, err := crawl.LoadIssue(tx, id) if err != nil { return err } res, err = crawl.Recheck(ctx, svc, tx, issue) return err }) if errors.Is(err, crawl.ErrNoSiteURL) { return 0, nil, StartRefusal(ctx, svc, err) } if err != nil { return 0, nil, err } msg := svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil) if res.Remaining > 0 { msg = svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil) } return http.StatusOK, map[string]any{"url": res.URL, "remaining": res.Remaining, "message": msg}, nil }) } // IgnoreIssues serves POST /_seo/api/v1/issues/ignore: the issues are // hidden in this and later crawls until they are no longer ignored. func IgnoreIssues(service func() *classes.Service) http.HandlerFunc { return ignoreHandler(service, true) } // UnignoreIssues serves POST /_seo/api/v1/issues/unignore: the issues are // shown in the audit again. func UnignoreIssues(service func() *classes.Service) http.HandlerFunc { return ignoreHandler(service, false) } func ignoreHandler(service func() *classes.Service, ignore bool) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { var in IssueIDsInput if err := decode(r, maxBody, &in); err != nil { return 0, nil, err } if len(in.IDs) == 0 || len(in.IDs) > 1000 { return 0, nil, Fail(http.StatusUnprocessableEntity, "ids_invalid") } n := 0 err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var rows []models.Issue if err := tx.Where("id IN ?", in.IDs).Find(&rows).Error; err != nil { return err } if len(rows) != len(uniqueIDs(in.IDs)) { return gorm.ErrRecordNotFound } var err error if ignore { adminID, _, _ := svc.Actor(ctx, tx) n, err = crawl.Ignore(tx, rows, adminID) } else { n, err = crawl.Unignore(tx, rows) } return err }) if err != nil { return 0, nil, err } key, field := "golem15.seo::lang.issues.unignored", "unignored" if ignore { key, field = "golem15.seo::lang.issues.ignored", "ignored" } return http.StatusOK, map[string]any{field: n, "message": svc.ChoiceCtx(ctx, key, n, nil)}, nil }) } // uniqueIDs drops repeated ids. func uniqueIDs(ids []uint) []uint { seen := map[uint]bool{} var out []uint for _, id := range ids { if !seen[id] { seen[id] = true out = append(out, id) } } return out }