Refactor arrtrix webhook to use fixed path and remove legacy config
Some checks failed
Test action / kaas (push) Failing after 1s

- Switch arrtrix webhook to a fixed path: /_arrtrix/webhook
- Remove Radarr-specific and secret-based config from arrtrix
- Simplify connector and webhook handler logic
- Update NixOS module to drop legacy webhook config
- Add new tests for generic arrtrix webhook handler
This commit is contained in:
Chris Kruining 2026-04-16 09:47:00 +02:00
parent fe627f3aab
commit bbfe6867c8
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
11 changed files with 211 additions and 285 deletions

6
.editorconfig Normal file
View file

@ -0,0 +1,6 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8

5
.gitattributes vendored
View file

@ -1,4 +1 @@
* text=auto
core.autocrlf=false
core.eol=lf
core.filemode=false
* text=auto eol=lf

View file

@ -18,7 +18,7 @@
keyFile = "/var/lib/element-call/key";
mkMautrix = bridge: i: conf: {
${bridge} =
${bridge} = mkMerge [
{
enable = true;
registerToSynapse = true;
@ -43,7 +43,8 @@
};
};
}
// conf;
conf
];
};
in {
options.${namespace}.services.communication.matrix = {
@ -110,7 +111,13 @@ in {
(mkMautrix "mautrix-signal" 1 {})
(mkMautrix "mautrix-telegram" 2 {})
(mkMautrix "mautrix-whatsapp" 3 {})
(mkMautrix "arrtrix" 4 {})
(mkMautrix "arrtrix" 4 {
settings.network.webhooks.radarr = {
enabled = true;
path = "/_arrtrix/webhooks/radarr";
secret = "";
};
})
{
matrix-synapse = {
enable = true;

View file

@ -15,11 +15,6 @@
settingsFormat = pkgs.formats.json {};
defaultConfig = {
network.webhooks.radarr = {
enabled = false;
path = "/_arrtrix/webhooks/radarr";
secret = "";
};
bridge = {
command_prefix = "!arr";
relay.enabled = true;

View file

@ -120,3 +120,40 @@ encryption:
t.Fatalf("expected hidden double puppet secrets to stay internal-only")
}
}
func TestLoadIgnoresLegacyWebhookSettings(t *testing.T) {
cfg, err := Load([]byte(`
network:
webhooks:
radarr:
enabled: true
path: /_arrtrix/webhooks/radarr
secret: legacy-secret
bridge:
command_prefix: "!arr"
homeserver:
address: http://127.0.0.1:8008
domain: test.local
appservice:
id: arrtrix
bot:
username: arrtrixbot
displayname: Arrtrix Bot
username_template: arrtrix_{{.}}
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
`))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg == nil {
t.Fatal("expected config to load")
}
}

View file

@ -14,40 +14,23 @@ import (
//go:embed example-config.yaml
var ExampleConfig string
type Config struct {
Webhooks WebhooksConfig `yaml:"webhooks"`
}
type WebhooksConfig struct {
Radarr webhook.RadarrConfig `yaml:"radarr"`
}
func (c *Config) applyDefaults() {
c.Webhooks.Radarr.ApplyDefaults()
}
func (c *Config) Validate() error {
return c.Webhooks.Radarr.Validate()
}
type Config struct{}
func upgradeConfig(helper up.Helper) {}
func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
s.Config.applyDefaults()
return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig)
}
func (s *ArrtrixConnector) ValidateConfig() error {
s.Config.applyDefaults()
return s.Config.Validate()
return nil
}
func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error {
s.Config.applyDefaults()
if s.Bridge == nil {
return fmt.Errorf("bridge is not initialized")
}
return webhook.MountRadarr(router, s.Bridge, s.Config.Webhooks.Radarr)
return webhook.MountArr(router, s.Bridge)
}
var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil)

View file

@ -1,23 +0,0 @@
package connector
import "testing"
func TestConfigDefaultsApplyRadarrWebhookPath(t *testing.T) {
var cfg Config
cfg.applyDefaults()
if cfg.Webhooks.Radarr.Path == "" {
t.Fatal("expected radarr webhook path default to be set")
}
}
func TestConfigValidateRejectsEnabledWebhookWithoutSecret(t *testing.T) {
cfg := Config{}
cfg.Webhooks.Radarr.Enabled = true
cfg.applyDefaults()
if err := cfg.Validate(); err == nil {
t.Fatal("expected missing secret to fail validation")
}
}

View file

@ -1,10 +1,4 @@
# Arrtrix-specific runtime options.
# No network-specific config is required yet.
#
webhooks:
radarr:
enabled: false
path: /_arrtrix/webhooks/radarr
secret: ""
# The first implementation delivers notifications to the only configured
# management room. If more than one management room exists, the webhook is
# rejected until routing is configured more explicitly.
# Arr-stack webhooks are exposed automatically on the fixed built-in path:
# POST /_arrtrix/webhook

View file

@ -14,30 +14,21 @@ import (
"maunium.net/go/mautrix/id"
)
const (
defaultRadarrWebhookPath = "/_arrtrix/webhooks/radarr"
radarrSecretHeader = "X-Arrtrix-Webhook-Secret"
)
const ArrWebhookPath = "/_arrtrix/webhook"
var (
ErrNoManagementRoom = errors.New("no management room configured")
ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured")
)
type RadarrConfig struct {
Enabled bool `yaml:"enabled"`
Path string `yaml:"path"`
Secret string `yaml:"secret"`
type payload struct {
EventType string `json:"eventType"`
Movie *movie `json:"movie"`
MovieFile *movieFile `json:"movieFile"`
IsUpgrade bool `json:"isUpgrade"`
}
type radarrPayload struct {
EventType string `json:"eventType"`
Movie *radarrMovie `json:"movie"`
MovieFile *radarrMovieFile `json:"movieFile"`
IsUpgrade bool `json:"isUpgrade"`
}
type radarrMovie struct {
type movie struct {
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdbId"`
@ -45,7 +36,7 @@ type radarrMovie struct {
Path string `json:"path"`
}
type radarrMovieFile struct {
type movieFile struct {
Quality string `json:"quality"`
RelativePath string `json:"relativePath"`
SceneName string `json:"sceneName"`
@ -60,62 +51,30 @@ type noticeSender interface {
SendNotice(context.Context, id.RoomID, string) error
}
type RadarrHandler struct {
config RadarrConfig
type ArrHandler struct {
resolver roomResolver
sender noticeSender
}
func (c *RadarrConfig) ApplyDefaults() {
if c.Path == "" {
c.Path = defaultRadarrWebhookPath
func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error {
if bridge == nil {
return fmt.Errorf("bridge is not initialized")
}
}
func (c *RadarrConfig) Validate() error {
c.ApplyDefaults()
if !c.Enabled {
return nil
}
if !strings.HasPrefix(c.Path, "/") {
return fmt.Errorf("network.webhooks.radarr.path must start with /")
}
if strings.TrimSpace(c.Secret) == "" {
return fmt.Errorf("network.webhooks.radarr.secret must be set when the webhook is enabled")
}
return nil
}
func MountRadarr(router *http.ServeMux, bridge *bridgev2.Bridge, cfg RadarrConfig) error {
cfg.ApplyDefaults()
if !cfg.Enabled {
return nil
}
if err := cfg.Validate(); err != nil {
return err
}
handler := &RadarrHandler{
config: cfg,
handler := &ArrHandler{
resolver: bridgeRoomResolver{bridge: bridge},
sender: bridgeNoticeSender{bridge: bridge},
}
router.Handle(fmt.Sprintf("POST %s", cfg.Path), handler)
router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler)
return nil
}
func (h *RadarrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !authorized(r, h.config.Secret) {
http.Error(w, "invalid webhook secret", http.StatusUnauthorized)
return
}
var payload radarrPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var body payload
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid webhook payload", http.StatusBadRequest)
return
}
if strings.TrimSpace(payload.EventType) == "" {
if strings.TrimSpace(body.EventType) == "" {
http.Error(w, "missing eventType", http.StatusBadRequest)
return
}
@ -130,7 +89,7 @@ func (h *RadarrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if err = h.sender.SendNotice(r.Context(), roomID, renderRadarrNotice(payload)); err != nil {
if err = h.sender.SendNotice(r.Context(), roomID, renderNotice(body)); err != nil {
http.Error(w, "failed to deliver webhook", http.StatusBadGateway)
return
}
@ -164,6 +123,7 @@ func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomI
if err = rows.Err(); err != nil {
return "", fmt.Errorf("failed to iterate management rooms: %w", err)
}
switch len(owners) {
case 0:
return "", ErrNoManagementRoom
@ -187,43 +147,30 @@ func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, ma
return err
}
func authorized(r *http.Request, secret string) bool {
if secret == "" {
return true
}
if r.Header.Get(radarrSecretHeader) == secret {
return true
}
if bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); bearer == secret && bearer != r.Header.Get("Authorization") {
return true
}
return r.URL.Query().Get("secret") == secret
}
func renderRadarrNotice(payload radarrPayload) string {
title := "Radarr"
if payload.Movie != nil {
title = payload.Movie.Title
if payload.Movie.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, payload.Movie.Year)
func renderNotice(body payload) string {
title := "Arr"
if body.Movie != nil {
title = body.Movie.Title
if body.Movie.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, body.Movie.Year)
}
}
lines := []string{fmt.Sprintf("**Radarr %s**", payload.EventType)}
if title != "Radarr" {
lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)}
if title != "Arr" {
lines = append(lines, fmt.Sprintf("Movie: %s", title))
}
if payload.MovieFile != nil && payload.MovieFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", payload.MovieFile.Quality))
if body.MovieFile != nil && body.MovieFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality))
}
if payload.MovieFile != nil && payload.MovieFile.RelativePath != "" {
lines = append(lines, fmt.Sprintf("File: `%s`", payload.MovieFile.RelativePath))
if body.MovieFile != nil && body.MovieFile.RelativePath != "" {
lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath))
}
if payload.EventType == "Download" {
lines = append(lines, fmt.Sprintf("Upgrade: %t", payload.IsUpgrade))
if body.EventType == "Download" {
lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade))
}
if payload.Movie != nil && payload.Movie.ImdbID != "" {
lines = append(lines, fmt.Sprintf("IMDb: `%s`", payload.Movie.ImdbID))
if body.Movie != nil && body.Movie.ImdbID != "" {
lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID))
}
return strings.Join(lines, "\n")
}
@ -238,4 +185,4 @@ func convertUserIDs(users []id.UserID) []string {
var _ roomResolver = bridgeRoomResolver{}
var _ noticeSender = bridgeNoticeSender{}
var _ http.Handler = (*RadarrHandler)(nil)
var _ http.Handler = (*ArrHandler)(nil)

View file

@ -0,0 +1,114 @@
package webhook
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
type stubRoomResolver struct {
roomID id.RoomID
err error
}
func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) {
return s.roomID, s.err
}
type stubNoticeSender struct {
roomID id.RoomID
message string
err error
}
func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error {
s.roomID = roomID
s.message = message
return s.err
}
func TestMountArrRequiresBridge(t *testing.T) {
router := http.NewServeMux()
if err := MountArr(router, nil); err == nil {
t.Fatal("expected nil bridge to fail")
}
}
func TestArrHandlerDeliversNotice(t *testing.T) {
sender := &stubNoticeSender{}
handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"},
sender: sender,
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected accepted status, got %d", rec.Code)
}
if sender.roomID != "!room:test" {
t.Fatalf("expected notice sent to management room, got %q", sender.roomID)
}
if !strings.Contains(sender.message, "**Arr Download**") || !strings.Contains(sender.message, "Dune (2021)") {
t.Fatalf("unexpected message: %s", sender.message)
}
}
func TestArrHandlerReportsAmbiguousManagementRoom(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected conflict status, got %d", rec.Code)
}
}
func TestRenderNoticeForTestEvent(t *testing.T) {
msg := renderNotice(payload{EventType: "Test"})
if strings.TrimSpace(msg) != "**Arr Test**" {
t.Fatalf("unexpected test-event message: %q", msg)
}
}
func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"},
sender: &stubNoticeSender{err: errors.New("send failed")},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected bad gateway status, got %d", rec.Code)
}
}
func TestArrHandlerRejectsMissingEventType(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"movie":{"title":"Dune"}}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected bad request status, got %d", rec.Code)
}
}

View file

@ -1,131 +0,0 @@
package webhook
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
type stubRoomResolver struct {
roomID id.RoomID
err error
}
func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) {
return s.roomID, s.err
}
type stubNoticeSender struct {
roomID id.RoomID
message string
err error
}
func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error {
s.roomID = roomID
s.message = message
return s.err
}
func TestRadarrConfigDefaultsAndValidation(t *testing.T) {
cfg := RadarrConfig{Enabled: true, Secret: "secret"}
cfg.ApplyDefaults()
if cfg.Path != defaultRadarrWebhookPath {
t.Fatalf("expected default path %q, got %q", defaultRadarrWebhookPath, cfg.Path)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected config to validate, got %v", err)
}
}
func TestRadarrConfigRequiresSecretWhenEnabled(t *testing.T) {
cfg := RadarrConfig{Enabled: true}
if err := cfg.Validate(); err == nil {
t.Fatal("expected missing secret to fail validation")
}
}
func TestRadarrHandlerRejectsUnauthorizedRequests(t *testing.T) {
handler := &RadarrHandler{
config: RadarrConfig{Enabled: true, Secret: "secret"},
resolver: stubRoomResolver{roomID: "!room:test"},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status, got %d", rec.Code)
}
}
func TestRadarrHandlerDeliversNotice(t *testing.T) {
sender := &stubNoticeSender{}
handler := &RadarrHandler{
config: RadarrConfig{Enabled: true, Secret: "secret"},
resolver: stubRoomResolver{roomID: "!room:test"},
sender: sender,
}
req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath+"?secret=secret", strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected accepted status, got %d", rec.Code)
}
if sender.roomID != "!room:test" {
t.Fatalf("expected notice sent to management room, got %q", sender.roomID)
}
if !strings.Contains(sender.message, "**Radarr Download**") || !strings.Contains(sender.message, "Dune (2021)") {
t.Fatalf("unexpected message: %s", sender.message)
}
}
func TestRadarrHandlerReportsAmbiguousManagementRoom(t *testing.T) {
handler := &RadarrHandler{
config: RadarrConfig{Enabled: true, Secret: "secret"},
resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
req.Header.Set(radarrSecretHeader, "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected conflict status, got %d", rec.Code)
}
}
func TestRenderRadarrNoticeForTestEvent(t *testing.T) {
msg := renderRadarrNotice(radarrPayload{EventType: "Test"})
if strings.TrimSpace(msg) != "**Radarr Test**" {
t.Fatalf("unexpected test-event message: %q", msg)
}
}
func TestRadarrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
handler := &RadarrHandler{
config: RadarrConfig{Enabled: true, Secret: "secret"},
resolver: stubRoomResolver{roomID: "!room:test"},
sender: &stubNoticeSender{err: errors.New("send failed")},
}
req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
req.Header.Set(radarrSecretHeader, "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected bad gateway status, got %d", rec.Code)
}
}