Skip to content

Commit a9a3e7e

Browse files
committed
feat(webhooks): templated body, custom headers/method, now_playing event, verified presets
Webhooks now carry a stable ID, optional Name, HTTP method, ContentType, custom Headers map, and a Go text/template BodyTemplate. Empty template preserves the legacy JSON envelope so existing receivers keep working. - WebhookConfig grows ID/Name/Method/Headers/BodyTemplate/ContentType. Legacy entries get an ID backfilled at load time. - dispatchWebhook renders the template against a context that merges event metadata (.Event/.Timestamp/.Hostname) with the per-event payload promoted to top-level fields (.Artist, .Title, .Mount, ...). Header values are templated too. For GET/HEAD the rendered body is appended as a query string so endpoints like TuneIn AIR's Playing.ashx work from a single template. - Helper funcs exposed inside templates: urlencode, json, lower, upper. - New now_playing event, fired from the streamer on each track start via a new StreamerManager.OnTrackStart callback (avoids server↔relay cyclic import). - /api/webhooks: GET/POST/PUT/DELETE CRUD + /api/webhooks/test for firing a sample payload + /api/webhooks/meta exposing the canonical event list, sample payloads, placeholders and verified presets. - Verified presets shipped: generic JSON, Discord, Slack, Mattermost, Microsoft Teams MessageCard, Telegram sendMessage, ntfy.sh, Pushover, TuneIn AIR Playing.ashx, webhook.site echo. - Outbound URL is re-validated on every send (defence in depth against hand-edited configs becoming SSRF vectors).
1 parent caf0235 commit a9a3e7e

7 files changed

Lines changed: 773 additions & 39 deletions

File tree

config/config.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
package config
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
46
"encoding/json"
57
"os"
68
"sync"
79

810
"golang.org/x/crypto/bcrypt"
911
)
1012

13+
// NewWebhookID returns a 12-char hex token used to address webhooks in
14+
// the API and admin UI. Random rather than sequential so deletes don't
15+
// invalidate cached references on the frontend.
16+
func NewWebhookID() string {
17+
b := make([]byte, 6)
18+
if _, err := rand.Read(b); err != nil {
19+
return "wh-fallback"
20+
}
21+
return "wh_" + hex.EncodeToString(b)
22+
}
23+
1124
const (
1225
RoleSuperAdmin = "superadmin"
1326
RoleAdmin = "admin"
@@ -108,10 +121,33 @@ type TranscoderConfig struct {
108121
OpusFrameSizeMS int `json:"opus_frame_size_ms,omitempty"` // 2.5/5/10/20/40/60, 0 = 20
109122
}
110123

124+
// WebhookConfig describes one outbound HTTP webhook subscription.
125+
//
126+
// The minimal form (URL + Events + Enabled) is preserved for backwards
127+
// compatibility with existing tinyice.json files written before templating
128+
// was added. New deployments get an ID assigned at load time so the admin
129+
// UI can address each entry uniquely even if URLs collide.
130+
//
131+
// BodyTemplate, when set, is rendered with Go text/template against a
132+
// context that contains .Event, .Timestamp, .Hostname plus every key from
133+
// the per-event payload promoted to the top level (so users can write
134+
// {{.Artist}} for now_playing, {{.Mount}} for source_connect, etc.). When
135+
// BodyTemplate is empty we fall back to the legacy JSON envelope, which
136+
// keeps existing receivers working unchanged.
137+
//
138+
// Method defaults to POST, ContentType to application/json. Headers is a
139+
// free-form map of extra request headers — useful for Authorization,
140+
// X-Webhook-Token, custom routing keys for Slack, etc.
111141
type WebhookConfig struct {
112-
URL string `json:"url"`
113-
Events []string `json:"events"` // "source_connect", "source_disconnect", "metadata_update", "security_lockout"
114-
Enabled bool `json:"enabled"`
142+
ID string `json:"id"`
143+
Name string `json:"name,omitempty"`
144+
URL string `json:"url"`
145+
Method string `json:"method,omitempty"`
146+
Events []string `json:"events"`
147+
Headers map[string]string `json:"headers,omitempty"`
148+
BodyTemplate string `json:"body_template,omitempty"`
149+
ContentType string `json:"content_type,omitempty"`
150+
Enabled bool `json:"enabled"`
115151
}
116152

117153
type AutoDJConfig struct {
@@ -405,6 +441,14 @@ func (config *Config) initMapsAndArrays() {
405441
if config.Webhooks == nil {
406442
config.Webhooks = make([]*WebhookConfig, 0)
407443
}
444+
// Backfill IDs for legacy webhooks (pre-templating). Done in-place so
445+
// the next SaveConfig persists them; without this the admin UI would
446+
// have no stable handle for edit/delete on existing entries.
447+
for _, wh := range config.Webhooks {
448+
if wh.ID == "" {
449+
wh.ID = NewWebhookID()
450+
}
451+
}
408452
if config.BannedIPs == nil {
409453
config.BannedIPs = make([]string, 0)
410454
}

relay/streamer.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ type StreamerManager struct {
8282
mu sync.RWMutex
8383
relay *Relay
8484
config *config.Config
85+
86+
// OnTrackStart fires once per track, after metadata is parsed and just
87+
// before encoding begins. Used by the server to dispatch the
88+
// `now_playing` webhook event without dragging the server package
89+
// into relay (which would be a cyclic import). Set by the server at
90+
// startup; nil on construction.
91+
OnTrackStart func(mount, streamerName, artist, title, album, filePath string)
8592
}
8693

8794
func NewStreamerManager(r *Relay, cfg *config.Config) *StreamerManager {
@@ -1049,6 +1056,13 @@ func (sm *StreamerManager) streamFile(ctx context.Context, s *Streamer, path str
10491056
// Fire on_play_command hook (runs in background, non-blocking).
10501057
s.execOnPlayCommand(s.CurrentArtist, s.CurrentTitle, s.CurrentAlbum, path, s.OutputMount)
10511058

1059+
// Notify the server so it can dispatch webhook subscribers. Run in a
1060+
// goroutine: the server callback may block briefly on JSON marshal /
1061+
// HTTP send setup and we don't want to delay the encoder start.
1062+
if cb := sm.OnTrackStart; cb != nil {
1063+
go cb(s.OutputMount, s.Name, s.CurrentArtist, s.CurrentTitle, s.CurrentAlbum, path)
1064+
}
1065+
10521066
// Apply the streamer's volume setting (0..1) to the PCM stream before
10531067
// it reaches the encoder. When Volume is 1.0 the wrapper is a no-op.
10541068
var pcm io.Reader = decoder

server/handlers_admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ func (s *Server) handleAddWebhook(w http.ResponseWriter, r *http.Request) {
495495
}
496496

497497
s.Config.Webhooks = append(s.Config.Webhooks, &config.WebhookConfig{
498+
ID: config.NewWebhookID(),
498499
URL: url,
499500
Events: events,
500501
Enabled: true,

0 commit comments

Comments
 (0)