Cybersecurity-Projects/PROJECTS/intermediate/security-news-scraper/internal/watch/notify.go

115 lines
2.5 KiB
Go

// ©AngelaMos | 2026
// notify.go
package watch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
const (
notifyMaxStatus = 300
defaultNotifyTimeout = 15 * time.Second
headerContentType = "Content-Type"
mimeJSON = "application/json"
)
type Notifier interface {
Notify(ctx context.Context, r Report) error
}
type WebhookNotifier struct {
URL string
Client *http.Client
}
type webhookPayload struct {
Text string `json:"text"`
Content string `json:"content"`
Items []webhookItem `json:"items"`
}
type webhookItem struct {
Title string `json:"title"`
URL string `json:"url"`
Score float64 `json:"score"`
MaxCVSS float64 `json:"max_cvss"`
IsKEV bool `json:"is_kev"`
CVEs []string `json:"cves,omitempty"`
Sources int `json:"sources"`
}
func (w WebhookNotifier) Notify(ctx context.Context, r Report) error {
if len(r.Notable) == 0 {
return nil
}
body, err := json.Marshal(buildPayload(r))
if err != nil {
return fmt.Errorf("notify: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("notify: new request: %w", err)
}
req.Header.Set(headerContentType, mimeJSON)
resp, err := w.client().Do(req)
if err != nil {
return fmt.Errorf("notify: post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= notifyMaxStatus {
return fmt.Errorf("notify: webhook returned %s", resp.Status)
}
return nil
}
func (w WebhookNotifier) client() *http.Client {
if w.Client != nil {
return w.Client
}
return &http.Client{Timeout: defaultNotifyTimeout}
}
func buildPayload(r Report) webhookPayload {
summary := summarize(r)
items := make([]webhookItem, len(r.Notable))
for i, n := range r.Notable {
items[i] = webhookItem{
Title: n.Title,
URL: n.URL,
Score: n.Score,
MaxCVSS: n.MaxCVSS,
IsKEV: n.IsKEV,
CVEs: n.CVEs,
Sources: n.Sources,
}
}
return webhookPayload{Text: summary, Content: summary, Items: items}
}
func summarize(r Report) string {
var sb strings.Builder
fmt.Fprintf(&sb, "nadezhda: %d notable %s\n", len(r.Notable), storyWord(len(r.Notable)))
for _, n := range r.Notable {
tag := ""
if n.IsKEV {
tag = " [KEV]"
}
fmt.Fprintf(&sb, "- %s%s (%s)\n", n.Title, tag, n.URL)
}
return strings.TrimRight(sb.String(), "\n")
}
func storyWord(n int) string {
if n == 1 {
return "story"
}
return "stories"
}