{{.Title}}
{{.Detail}}
{{if .LookupURL}}Public lookup{{if .RemovalURL}} · Removal procedure{{end}}
{{else if .FixIsURL}}{{else if .Fix}}{{.Fix}}{{end}}
package checker import ( "bytes" "encoding/json" "fmt" "html/template" "sort" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport renders a generic, source-agnostic HTML report. The // per-source rich detail (VT vendor table, URLhaus URL list, …) is // rendered by sources implementing DetailRenderer; everything else // (headline, action-required cards, summary table) walks the uniform // SourceResult envelope without any source-specific switch. func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data BlacklistData if err := json.Unmarshal(ctx.Data(), &data); err != nil { return "", fmt.Errorf("decode blacklist data: %w", err) } view := reportView{ Domain: data.Domain, RegisteredDomain: data.RegisteredDomain, CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"), TotalHits: data.TotalHits(), Diagnoses: diagnose(&data), Sections: buildSections(&data), CSS: template.CSS(reportCSS), } view.Headline, view.HeadlineClass = headline(view.TotalHits) var b bytes.Buffer if err := reportTemplate.Execute(&b, view); err != nil { return "", fmt.Errorf("render blacklist report: %w", err) } return b.String(), nil } type reportView struct { Domain string RegisteredDomain string CollectedAt string TotalHits int Headline string HeadlineClass string Diagnoses []Diagnosis Sections []sourceSection CSS template.CSS } // sourceSection is one rendered card per Source (not per result): a // multi-result source like DNSBL is collapsed to a single section that // lists each subject as a row. Rich sources contribute extra HTML via // their RenderDetail implementation; plain sources fall back to the // generic Reasons/Evidence rendering. type sourceSection struct { SourceID string SourceName string StatusLabel string StatusClass string Subjects []subjectRow RichHTML template.HTML Reference string } type subjectRow struct { Subject string StatusLabel string StatusClass string Reasons []string Evidence []Evidence LookupURL string RemovalURL string Reference string Error string Disabled bool } func headline(hits int) (string, string) { switch hits { case 0: return "Domain is clean across all configured reputation sources.", SeverityOK case 1: return "Domain is currently listed on 1 source. Act now: a single listing already breaks email delivery and browser access.", SeverityCrit default: return fmt.Sprintf("Domain is currently listed on %d sources. This is severe: most mail and browsers will block access.", hits), SeverityCrit } } // diagnose builds the action-required cards by delegating to each // listed result's source. The generic code only orders cards by // severity; the wording and remediation are owned by the source. func diagnose(d *BlacklistData) []Diagnosis { byID := make(map[string]Source, len(Sources())) for _, s := range Sources() { byID[s.ID()] = s } var out []Diagnosis for _, r := range d.Results { if !r.Listed { continue } if s, ok := byID[r.SourceID]; ok { out = append(out, s.Diagnose(r)) } } // Errors are surfaced as warnings so a flaky source is visible // without dominating the page. for _, r := range d.Results { if r.Error == "" { continue } title := "Could not query " + r.SourceName if r.Subject != "" && r.Subject != r.SourceName { title = fmt.Sprintf("Could not query %s (%s)", r.SourceName, r.Subject) } out = append(out, Diagnosis{ Severity: SeverityWarn, Title: title, Detail: r.Error + ": the listing status of this source is unknown for this run.", }) } sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) }) return out } func sevRank(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 case SeverityInfo: return 2 } return 3 } // buildSections groups results by source, collapses multi-result // sources into a single card, and asks each source for its rich detail // HTML when applicable. func buildSections(d *BlacklistData) []sourceSection { byID := make(map[string]Source, len(Sources())) order := make([]string, 0, len(Sources())) for _, s := range Sources() { byID[s.ID()] = s order = append(order, s.ID()) } grouped := make(map[string][]SourceResult) for _, r := range d.Results { grouped[r.SourceID] = append(grouped[r.SourceID], r) } out := make([]sourceSection, 0, len(grouped)) for _, id := range order { results := grouped[id] if len(results) == 0 { continue } section := sourceSection{ SourceID: id, SourceName: byID[id].Name(), } section.StatusLabel, section.StatusClass = sectionStatus(results) for _, r := range results { if r.Reference != "" && section.Reference == "" { section.Reference = r.Reference } section.Subjects = append(section.Subjects, subjectRow{ Subject: subjectLabel(byID[id].Name(), r), StatusLabel: subjectStatusLabel(r), StatusClass: subjectStatusClass(r), Reasons: r.Reasons, Evidence: r.Evidence, LookupURL: r.LookupURL, RemovalURL: r.RemovalURL, Reference: r.Reference, Error: r.Error, Disabled: !r.Enabled, }) } // Rich detail: use the first listed result's payload (single- // subject sources have at most one). Plain sources skip this. if dr, ok := byID[id].(DetailRenderer); ok { for _, r := range results { if !r.Listed && len(r.Details) == 0 { continue } html, err := dr.RenderDetail(r) if err == nil && html != "" { section.RichHTML = html break } } } out = append(out, section) } return out } func sectionStatus(results []SourceResult) (string, string) { listed, errs, enabled := 0, 0, 0 for _, r := range results { if r.Enabled { enabled++ } if r.Listed { listed++ } else if r.Error != "" { errs++ } } switch { case enabled == 0: return "Disabled", "muted" case listed > 0: return fmt.Sprintf("LISTED (%d)", listed), "crit" case errs > 0: return "Errors", "warn" } return "Clean", "ok" } func subjectLabel(srcName string, r SourceResult) string { if r.Subject != "" && r.Subject != srcName { return r.Subject } return srcName } func subjectStatusLabel(r SourceResult) string { switch { case !r.Enabled: return "Disabled" case r.Listed: return "LISTED" case r.Error != "": return "Error" } return "Clean" } func subjectStatusClass(r SourceResult) string { switch { case !r.Enabled: return "muted" case r.Listed: return r.Severity case r.Error != "": return "warn" } return "ok" } var reportTemplate = template.Must(template.New("blacklist").Parse(`
{{.Detail}}
{{if .LookupURL}}Public lookup{{if .RemovalURL}} · Removal procedure{{end}}
{{else if .FixIsURL}}{{else if .Fix}}{{.Fix}}{{end}}
| Subject | Status | Detail |
|---|---|---|
| {{.Subject}} | {{.StatusLabel}} |
{{if .Disabled}}disabled
{{else if .Error}}{{.Error}}
{{else}}
{{range .Reasons}} {{.}} {{end}}
{{if .Evidence}}{{len .Evidence}} evidence item(s)
Lookup{{if .RemovalURL}} · Request removal{{end}} {{end}}
{{end}}
|