- ToHTML() now html.EscapeString on ticker, generated_at, section name/body, citations (stored/LLM content can't inject script into exported file) - Discord destination webhook_url restricted to discord.com/discordapp.com hosts (SSRF guard: server POSTs alert cards; no private IPs or arbitrary hosts) - Router middleware caps POST/PUT/PATCH bodies at 1 MiB (MaxBytesReader) - TestDestinations updated: non-discord host must 422, uses realistic discord.com webhook URL
183 lines
5.5 KiB
Go
183 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"flowsight/internal/store"
|
|
)
|
|
|
|
// destOut is the masked API shape: secrets never leave the server.
|
|
type destOut struct {
|
|
ID int64 `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Label string `json:"label"`
|
|
Enabled bool `json:"enabled"`
|
|
Configured bool `json:"configured"`
|
|
}
|
|
|
|
func maskDestinations(rows []store.Destination) []destOut {
|
|
out := make([]destOut, 0, len(rows))
|
|
for _, d := range rows {
|
|
cfg := false
|
|
switch d.Kind {
|
|
case store.DestTelegram:
|
|
cfg = d.BotToken != "" && d.ChatID != ""
|
|
case store.DestDiscord:
|
|
cfg = d.WebhookURL != ""
|
|
}
|
|
out = append(out, destOut{ID: d.ID, Kind: d.Kind, Label: d.Label, Enabled: d.Enabled, Configured: cfg})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListDestinations serves GET /api/destinations (secrets masked).
|
|
func (s *Server) ListDestinations(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.DB.ListDestinations(s.userKey(r))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"destinations": maskDestinations(rows)})
|
|
}
|
|
|
|
// CreateDestination serves POST /api/destinations.
|
|
func (s *Server) CreateDestination(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Kind string `json:"kind" validate:"required,oneof=telegram discord"`
|
|
Label string `json:"label"`
|
|
BotToken string `json:"bot_token"`
|
|
ChatID string `json:"chat_id"`
|
|
WebhookURL string `json:"webhook_url"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
req.Kind = strings.ToLower(strings.TrimSpace(req.Kind))
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
writeErr(w, http.StatusUnprocessableEntity, "kind must be telegram or discord")
|
|
return
|
|
}
|
|
if msg := checkDestSecrets(req.Kind, req.BotToken, req.ChatID, req.WebhookURL); msg != "" {
|
|
writeErr(w, http.StatusUnprocessableEntity, msg)
|
|
return
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
id, err := s.DB.CreateDestination(store.Destination{
|
|
UserKey: s.userKey(r), Kind: req.Kind, Label: req.Label,
|
|
BotToken: req.BotToken, ChatID: req.ChatID, WebhookURL: req.WebhookURL,
|
|
Enabled: enabled,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "kind": req.Kind})
|
|
}
|
|
|
|
// UpdateDestination serves PATCH /api/destinations/:id. Kind is immutable;
|
|
// omitted secret fields keep their stored value.
|
|
func (s *Server) UpdateDestination(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Label *string `json:"label"`
|
|
Enabled *bool `json:"enabled"`
|
|
BotToken *string `json:"bot_token"`
|
|
ChatID *string `json:"chat_id"`
|
|
WebhookURL *string `json:"webhook_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
ok, err := s.DB.UpdateDestination(id, s.userKey(r), req.Label, req.Enabled, req.BotToken, req.ChatID, req.WebhookURL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "destination not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
|
|
}
|
|
|
|
// DeleteDestination serves DELETE /api/destinations/:id.
|
|
func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
ok, err := s.DB.DeleteDestination(id, s.userKey(r))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "destination not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
|
|
}
|
|
|
|
// checkDestSecrets validates kind-appropriate secrets. Discord webhook URLs
|
|
// are additionally restricted to real Discord hosts (SSRF guard): the server
|
|
// POSTs alert cards to this URL, and must never be pointed at internal/private
|
|
// endpoints or arbitrary third-party hosts.
|
|
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
|
|
switch kind {
|
|
case store.DestTelegram:
|
|
if strings.TrimSpace(botToken) == "" || strings.TrimSpace(chatID) == "" {
|
|
return "telegram needs bot_token and chat_id"
|
|
}
|
|
case store.DestDiscord:
|
|
u := strings.TrimSpace(webhookURL)
|
|
if u == "" {
|
|
return "discord needs webhook_url"
|
|
}
|
|
if !strings.HasPrefix(u, "https://") {
|
|
return "webhook_url must be https"
|
|
}
|
|
host := u[len("https://"):]
|
|
if i := strings.IndexAny(host, "/?"); i >= 0 {
|
|
host = host[:i]
|
|
}
|
|
if !discordWebhookHost(host) {
|
|
return "webhook_url must be a discord.com/app.com webhook host"
|
|
}
|
|
default:
|
|
return "kind must be telegram or discord"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// discordWebhookHost allows only Discord's webhook API hosts (subdomains
|
|
// included). Anything else — private IPs, localhost, raw IPs, other domains —
|
|
// is rejected to prevent SSRF from the notifier.
|
|
func discordWebhookHost(host string) bool {
|
|
h := strings.ToLower(strings.TrimSpace(host))
|
|
if h == "discord.com" || h == "discordapp.com" {
|
|
return true
|
|
}
|
|
for _, suffix := range []string{".discord.com", ".discordapp.com", ".discord.gg"} {
|
|
if strings.HasSuffix(h, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|