package main import ( "fmt" "math" "net/http" "strconv" "strings" "time" "unicode/utf8" ) const ( v2ColorBrand = 0x5865F2 v2ColorSuccess = 0x57F287 v2ColorUpdate = 0x3498DB v2ColorWarning = 0xFEE75C v2ColorError = 0xED4245 v2ColorCritical = 0x992D22 v2InlineFieldLimit = 80 ) type v2CommonRequest struct { Source *string `json:"source"` OccurredAt *string `json:"occurredAt"` Extra extraRequests `json:"extra"` } type v2MessageRequest struct { v2CommonRequest Title *string `json:"title"` Message *string `json:"message"` } type v2BackupRequest struct { v2CommonRequest Title *string `json:"title"` Asset *string `json:"asset"` SizeBytes *int64 `json:"sizeBytes"` DurationSeconds *float64 `json:"durationSeconds"` } type v2UpdateRequest struct { v2CommonRequest Host *string `json:"host"` Asset *string `json:"asset"` DurationSeconds *float64 `json:"durationSeconds"` FromVersion *string `json:"fromVersion"` ToVersion *string `json:"toVersion"` } type v2ErrorRequest struct { v2CommonRequest Message *string `json:"message"` Severity *string `json:"severity"` ErrorCode *string `json:"errorCode"` } func (a *application) v2MessageHandler(w http.ResponseWriter, r *http.Request) { var request v2MessageRequest if err := decodeRequest(w, r, &request, "title", "message", "source", "occurredat", "extra"); err != nil { a.writeDecodeError(w, r, err) return } when, valid := a.validV2Common(request.v2CommonRequest) if !valid || !requiredString(request.Message) || !validOptionalString(request.Title) { a.writeDecodeError(w, r, errInvalidPayload) return } if !a.send(w, r, buildV2MessagePayload(request, when)) { return } writeText(w, http.StatusOK, "Notification sent") } func (a *application) v2BackupHandler(w http.ResponseWriter, r *http.Request) { var request v2BackupRequest if err := decodeRequest(w, r, &request, "title", "asset", "sizebytes", "durationseconds", "source", "occurredat", "extra"); err != nil { a.writeDecodeError(w, r, err) return } when, valid := a.validV2Common(request.v2CommonRequest) if !valid || !requiredString(request.Asset) || !validOptionalString(request.Title) || request.SizeBytes == nil || *request.SizeBytes < 0 || !validOptionalNumber(request.DurationSeconds) { a.writeDecodeError(w, r, errInvalidPayload) return } if !a.send(w, r, buildV2BackupPayload(request, when)) { return } writeText(w, http.StatusOK, "Notification sent") } func (a *application) v2UpdateHandler(w http.ResponseWriter, r *http.Request) { var request v2UpdateRequest if err := decodeRequest(w, r, &request, "host", "asset", "durationseconds", "fromversion", "toversion", "source", "occurredat", "extra"); err != nil { a.writeDecodeError(w, r, err) return } when, valid := a.validV2Common(request.v2CommonRequest) if !valid || !requiredString(request.Host) || !requiredString(request.Asset) || !validNumber(request.DurationSeconds) || !validOptionalString(request.FromVersion) || !validOptionalString(request.ToVersion) { a.writeDecodeError(w, r, errInvalidPayload) return } if !a.send(w, r, buildV2UpdatePayload(request, when)) { return } writeText(w, http.StatusOK, "Notification sent") } func (a *application) v2ErrorHandler(w http.ResponseWriter, r *http.Request) { var request v2ErrorRequest if err := decodeRequest(w, r, &request, "source", "message", "severity", "errorcode", "occurredat", "extra"); err != nil { a.writeDecodeError(w, r, err) return } when, valid := a.validV2Common(request.v2CommonRequest) if !valid || !requiredString(request.Source) || !requiredString(request.Message) || !validV2Severity(request.Severity) || !validOptionalString(request.ErrorCode) { a.writeDecodeError(w, r, errInvalidPayload) return } if !a.send(w, r, buildV2ErrorPayload(request, when)) { return } writeText(w, http.StatusOK, "Notification sent") } func (a *application) validV2Common(request v2CommonRequest) (time.Time, bool) { if !validOptionalString(request.Source) || !validExtra(request.Extra) { return time.Time{}, false } if request.OccurredAt == nil { return a.now().UTC(), true } if !requiredString(request.OccurredAt) { return time.Time{}, false } when, err := time.Parse(time.RFC3339, *request.OccurredAt) if err != nil { return time.Time{}, false } return when.UTC(), true } func validOptionalString(value *string) bool { return value == nil || requiredString(value) } func validNumber(value *float64) bool { return value != nil && *value >= 0 && !math.IsNaN(*value) && !math.IsInf(*value, 0) } func validOptionalNumber(value *float64) bool { return value == nil || validNumber(value) } func validV2Severity(value *string) bool { if value == nil { return false } switch *value { case "warning", "error", "critical": return true default: return false } } func buildV2MessagePayload(request v2MessageRequest, when time.Time) discordPayload { title := "Message" if request.Title != nil { title = *request.Title } embed := newV2Embed("💬 "+title, *request.Message, v2ColorBrand, when) embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) return discordPayload{Embeds: []discordEmbed{embed}} } func buildV2BackupPayload(request v2BackupRequest, when time.Time) discordPayload { title := "Backup completed" if request.Title != nil { title = *request.Title } embed := newV2Embed("📦 "+title, fmt.Sprintf("**%s** was backed up successfully.", *request.Asset), v2ColorSuccess, when) embed.Fields = append(embed.Fields, v2Field("Size", formatBytes(*request.SizeBytes))) if request.DurationSeconds != nil { embed.Fields = append(embed.Fields, v2Field("Duration", formatV2Duration(*request.DurationSeconds))) } embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) return discordPayload{Embeds: []discordEmbed{embed}} } func buildV2UpdatePayload(request v2UpdateRequest, when time.Time) discordPayload { embed := newV2Embed("🔄 Update completed", fmt.Sprintf("**%s** was updated on **%s**.", *request.Asset, *request.Host), v2ColorUpdate, when) switch { case request.FromVersion != nil && request.ToVersion != nil: embed.Fields = append(embed.Fields, v2Field("Version", *request.FromVersion+" → "+*request.ToVersion)) case request.FromVersion != nil: embed.Fields = append(embed.Fields, v2Field("Previous version", *request.FromVersion)) case request.ToVersion != nil: embed.Fields = append(embed.Fields, v2Field("Installed version", *request.ToVersion)) } embed.Fields = append(embed.Fields, v2Field("Duration", formatV2Duration(*request.DurationSeconds))) embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) return discordPayload{Embeds: []discordEmbed{embed}} } func buildV2ErrorPayload(request v2ErrorRequest, when time.Time) discordPayload { title, color := v2ErrorStyle(*request.Severity) embed := newV2Embed(title, *request.Message, color, when) embed.Fields = append(embed.Fields, v2Field("Source", *request.Source)) if request.ErrorCode != nil { embed.Fields = append(embed.Fields, v2Field("Error code", *request.ErrorCode)) } embed.Fields = appendV2Extras(embed.Fields, request.Extra) return discordPayload{Embeds: []discordEmbed{embed}} } func newV2Embed(title, description string, color int, when time.Time) discordEmbed { return discordEmbed{ Title: title, Description: description, Color: color, Timestamp: when.UTC().Format(time.RFC3339), Footer: &discordEmbedFooter{Text: "Haven Notify"}, } } func v2ErrorStyle(severity string) (string, int) { switch severity { case "warning": return "⚠️ Warning", v2ColorWarning case "critical": return "🛑 Critical error", v2ColorCritical default: return "❌ Error", v2ColorError } } func appendV2SourceAndExtras(fields []discordEmbedField, source *string, extra extraRequests) []discordEmbedField { if source != nil { fields = append(fields, v2Field("Source", *source)) } return appendV2Extras(fields, extra) } func appendV2Extras(fields []discordEmbedField, extra extraRequests) []discordEmbedField { for _, field := range extra { value := *field.Value fields = append(fields, discordEmbedField{ Name: *field.Name, Value: value, Inline: !strings.ContainsAny(value, "\r\n") && utf8.RuneCountInString(value) <= v2InlineFieldLimit, }) } return fields } func v2Field(name, value string) discordEmbedField { return discordEmbedField{Name: name, Value: value, Inline: true} } func formatBytes(bytes int64) string { if bytes < 1024 { return strconv.FormatInt(bytes, 10) + " B" } value := float64(bytes) for _, unit := range []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} { value /= 1024 if value < 1024 || unit == "EiB" { return fmt.Sprintf("%.2f %s", value, unit) } } return strconv.FormatInt(bytes, 10) + " B" } func formatV2Duration(seconds float64) string { if seconds < 60 { return formatV2Number(seconds) + "s" } hours := math.Floor(seconds / 3600) seconds -= hours * 3600 minutes := math.Floor(seconds / 60) seconds -= minutes * 60 parts := make([]string, 0, 3) if hours > 0 { parts = append(parts, formatV2Number(hours)+"h") } if minutes > 0 { parts = append(parts, formatV2Number(minutes)+"m") } if seconds > 0 { parts = append(parts, formatV2Number(seconds)+"s") } return strings.Join(parts, " ") } func formatV2Number(value float64) string { formatted := strconv.FormatFloat(value, 'f', 3, 64) return strings.TrimRight(strings.TrimRight(formatted, "0"), ".") }