From de0dc363baf7fd2978cf3d7ea53486f417ce2920 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 09:16:27 +0200 Subject: [PATCH 01/29] feat(sdks): add webhook types and endpoints Add the shared webhook data types (NotificationType, DiscordMentionType, WebhookKeyValue, WebhookDestination, WebhookItemTypes, WebhookDto, WebhookTestResult) and the six API client endpoints under /remux/webhooks that the server, dashboard, and dispatcher will build on. --- crates/remux-sdks/src/remux/mod.rs | 311 +++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 72f467ead..71a7119d9 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6194,6 +6194,227 @@ pub struct RefreshItemQuery { pub regenerate_trickplay: bool, } +// --------------------------------------------------------------------------- +// Webhooks +// --------------------------------------------------------------------------- + +/// Server-side event a webhook can subscribe to. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum_macros::EnumString, + strum_macros::Display, +)] +pub enum NotificationType { + ItemAdded, + ItemDeleted, + Generic, + PlaybackStart, + PlaybackProgress, + PlaybackStop, + AuthenticationSuccess, + AuthenticationFailure, + SessionStart, + TaskCompleted, + UserCreated, + UserDeleted, + UserUpdated, + UserPasswordChanged, + UserDataSaved, +} + +/// Who a Discord webhook message pings. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Serialize, + Deserialize, + strum_macros::EnumString, + strum_macros::Display, +)] +pub enum DiscordMentionType { + #[default] + None, + Here, + Everyone, +} + +/// A user-defined header or template field attached to a generic webhook. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct WebhookKeyValue { + pub key: String, + pub value: String, +} + +/// Destination-specific webhook settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "Type")] +pub enum WebhookDestination { + Generic { + headers: Vec, + fields: Vec, + }, + Discord { + avatar_url: Option, + bot_username: Option, + /// Hex color used for the Discord embed, e.g. `#AA5CC3`. + embed_color: Option, + mention_type: DiscordMentionType, + }, +} + +/// Item kinds a webhook reacts to. Everything is enabled by default. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WebhookItemTypes { + pub movies: bool, + pub episodes: bool, + pub series: bool, + pub seasons: bool, + pub albums: bool, + pub songs: bool, + pub videos: bool, +} + +impl Default for WebhookItemTypes { + fn default() -> Self { + Self { + movies: true, + episodes: true, + series: true, + seasons: true, + albums: true, + songs: true, + videos: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookDto { + /// Ignored on create — the server assigns a fresh id. + pub id: Uuid, + pub name: String, + pub enabled: bool, + pub url: String, + pub template: String, + pub destination: WebhookDestination, + /// Empty matches nothing, mirroring the Jellyfin webhook plugin. + pub notification_types: Vec, + /// Empty means every user. + pub user_filter: Vec, + pub item_types: WebhookItemTypes, + pub send_all_properties: bool, + pub trim_whitespace: bool, + pub skip_empty_message_body: bool, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookTestResult { + pub success: bool, + pub status_code: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GetWebhooks; + +impl Endpoint for GetWebhooks { + type Output = Vec; + fn path(&self) -> String { + "/remux/webhooks".into() + } +} + +#[derive(Debug, Clone)] +pub struct GetWebhook { + pub id: Uuid, +} + +impl Endpoint for GetWebhook { + type Output = WebhookDto; + fn path(&self) -> String { + format!("/remux/webhooks/{}", self.id) + } +} + +#[derive(Debug, Clone)] +pub struct CreateWebhook { + pub webhook: WebhookDto, +} + +impl Endpoint for CreateWebhook { + type Output = WebhookDto; + fn path(&self) -> String { + "/remux/webhooks".into() + } + fn method(&self) -> Method { + Method::POST + } + fn body(&self) -> Body { + Body::Json(serde_json::to_value(&self.webhook).unwrap_or_default()) + } +} + +#[derive(Debug, Clone)] +pub struct UpdateWebhook { + pub id: Uuid, + pub webhook: WebhookDto, +} + +impl Endpoint for UpdateWebhook { + type Output = WebhookDto; + fn path(&self) -> String { + format!("/remux/webhooks/{}", self.id) + } + fn method(&self) -> Method { + Method::POST + } + fn body(&self) -> Body { + Body::Json(serde_json::to_value(&self.webhook).unwrap_or_default()) + } +} + +#[derive(Debug, Clone)] +pub struct DeleteWebhook { + pub id: Uuid, +} + +impl Endpoint for DeleteWebhook { + type Output = (); + fn path(&self) -> String { + format!("/remux/webhooks/{}", self.id) + } + fn method(&self) -> Method { + Method::DELETE + } +} + +#[derive(Debug, Clone)] +pub struct TestWebhook { + pub id: Uuid, +} + +impl Endpoint for TestWebhook { + type Output = WebhookTestResult; + fn path(&self) -> String { + format!("/remux/webhooks/{}/test", self.id) + } + fn method(&self) -> Method { + Method::POST + } +} + #[cfg(test)] mod tests { use super::*; @@ -6604,4 +6825,94 @@ mod tests { .collect(); assert_eq!(flags_before, flags_after); } + + // ── Webhooks ────────────────────────────────────────────────────────────── + + #[test] + fn webhook_destination_generic_round_trips() { + let dest = WebhookDestination::Generic { + headers: vec![WebhookKeyValue { + key: "X-Token".to_string(), + value: "secret".to_string(), + }], + fields: vec![WebhookKeyValue { + key: "channel".to_string(), + value: "media".to_string(), + }], + }; + let json = serde_json::to_value(&dest).unwrap(); + assert_eq!(json["Type"], "Generic"); + assert_eq!(json["headers"][0]["key"], "X-Token"); + let back: WebhookDestination = serde_json::from_value(json).unwrap(); + assert_eq!(back, dest); + } + + #[test] + fn webhook_destination_discord_round_trips() { + let dest = WebhookDestination::Discord { + avatar_url: Some("https://example.test/a.png".to_string()), + bot_username: None, + embed_color: Some("#AA5CC3".to_string()), + mention_type: DiscordMentionType::Here, + }; + let json = serde_json::to_value(&dest).unwrap(); + assert_eq!(json["Type"], "Discord"); + assert_eq!(json["embed_color"], "#AA5CC3"); + assert_eq!(json["mention_type"], "Here"); + let back: WebhookDestination = serde_json::from_value(json).unwrap(); + assert_eq!(back, dest); + } + + #[test] + fn discord_mention_type_defaults_to_none() { + assert_eq!(DiscordMentionType::default(), DiscordMentionType::None); + } + + #[test] + fn notification_type_parses_and_displays() { + for (variant, expected) in [ + (NotificationType::ItemAdded, "ItemAdded"), + (NotificationType::PlaybackStart, "PlaybackStart"), + (NotificationType::UserPasswordChanged, "UserPasswordChanged"), + ] { + assert_eq!(variant.to_string(), expected); + assert_eq!( + expected + .parse::() + .unwrap(), + variant + ); + } + assert!( + "NotAThing" + .parse::() + .is_err() + ); + } + + #[test] + fn webhook_item_types_default_is_all_true() { + let t = WebhookItemTypes::default(); + assert!(t.movies); + assert!(t.episodes); + assert!(t.series); + assert!(t.seasons); + assert!(t.albums); + assert!(t.songs); + assert!(t.videos); + } + + #[test] + fn webhook_endpoints_use_lowercase_remux_paths() { + let id = Uuid::nil(); + assert_eq!(GetWebhooks.path(), "/remux/webhooks"); + assert_eq!(GetWebhook { id }.path(), format!("/remux/webhooks/{id}")); + assert_eq!(DeleteWebhook { id }.path(), format!("/remux/webhooks/{id}")); + assert_eq!(DeleteWebhook { id }.method(), Method::DELETE); + assert_eq!( + TestWebhook { id }.path(), + format!("/remux/webhooks/{id}/test") + ); + assert_eq!(TestWebhook { id }.method(), Method::POST); + } } From a147518204feaa4266603dd47956935ff5029ef0 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 09:26:27 +0200 Subject: [PATCH 02/29] test(sdks): cover all six webhook endpoints The endpoint test asserted only four of the six webhook endpoints, leaving CreateWebhook and UpdateWebhook entirely unchecked. Assert path and method for all six, and add body tests proving both mutating endpoints send the serialized WebhookDto as a JSON body. --- crates/remux-sdks/src/remux/mod.rs | 104 ++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 71a7119d9..7c780b4d2 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6902,17 +6902,119 @@ mod tests { assert!(t.videos); } + fn sample_webhook() -> WebhookDto { + WebhookDto { + id: Uuid::nil(), + name: "on new movie".to_string(), + enabled: true, + url: "https://example.test/hook".to_string(), + template: "{{Name}}".to_string(), + destination: WebhookDestination::Discord { + avatar_url: None, + bot_username: Some("remux".to_string()), + embed_color: Some("#AA5CC3".to_string()), + mention_type: DiscordMentionType::Everyone, + }, + notification_types: vec![NotificationType::ItemAdded], + user_filter: vec![], + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: true, + skip_empty_message_body: true, + created_at: None, + updated_at: None, + } + } + + fn json_body(body: Body) -> serde_json::Value { + match body { + Body::Json(value) => value, + _ => panic!("expected Body::Json"), + } + } + #[test] - fn webhook_endpoints_use_lowercase_remux_paths() { + fn webhook_endpoints_use_lowercase_remux_paths_and_methods() { let id = Uuid::nil(); + assert_eq!(GetWebhooks.path(), "/remux/webhooks"); + assert_eq!(GetWebhooks.method(), Method::GET); + assert_eq!(GetWebhook { id }.path(), format!("/remux/webhooks/{id}")); + assert_eq!(GetWebhook { id }.method(), Method::GET); + + let create = CreateWebhook { + webhook: sample_webhook(), + }; + assert_eq!(create.path(), "/remux/webhooks"); + assert_eq!(create.method(), Method::POST); + + let update = UpdateWebhook { + id, + webhook: sample_webhook(), + }; + assert_eq!(update.path(), format!("/remux/webhooks/{id}")); + assert_eq!(update.method(), Method::POST); + assert_eq!(DeleteWebhook { id }.path(), format!("/remux/webhooks/{id}")); assert_eq!(DeleteWebhook { id }.method(), Method::DELETE); + assert_eq!( TestWebhook { id }.path(), format!("/remux/webhooks/{id}/test") ); assert_eq!(TestWebhook { id }.method(), Method::POST); } + + #[test] + fn create_webhook_body_carries_the_serialized_dto() { + let webhook = sample_webhook(); + let body = json_body( + CreateWebhook { + webhook: webhook.clone(), + } + .body(), + ); + + assert_eq!(body["name"], "on new movie"); + assert_eq!(body["enabled"], true); + assert_eq!(body["url"], "https://example.test/hook"); + assert_eq!(body["template"], "{{Name}}"); + assert_eq!(body["destination"]["Type"], "Discord"); + assert_eq!(body["destination"]["mention_type"], "Everyone"); + assert_eq!(body["notification_types"][0], "ItemAdded"); + assert_eq!(body["item_types"]["movies"], true); + assert_eq!(body["skip_empty_message_body"], true); + + let back: WebhookDto = serde_json::from_value(body).unwrap(); + assert_eq!(back.name, webhook.name); + assert_eq!(back.url, webhook.url); + assert_eq!(back.destination, webhook.destination); + assert_eq!(back.notification_types, webhook.notification_types); + assert_eq!(back.item_types, webhook.item_types); + } + + #[test] + fn update_webhook_body_carries_the_serialized_dto() { + let id = Uuid::from_u128(42); + let mut webhook = sample_webhook(); + webhook.name = "renamed".to_string(); + webhook.enabled = false; + + let endpoint = UpdateWebhook { + id, + webhook: webhook.clone(), + }; + assert_eq!(endpoint.path(), format!("/remux/webhooks/{id}")); + + let body = json_body(endpoint.body()); + assert_eq!(body["name"], "renamed"); + assert_eq!(body["enabled"], false); + assert_eq!(body["destination"]["Type"], "Discord"); + + let back: WebhookDto = serde_json::from_value(body).unwrap(); + assert_eq!(back.name, webhook.name); + assert_eq!(back.enabled, webhook.enabled); + assert_eq!(back.destination, webhook.destination); + } } From 9b314725be36914fe2a0b825ee892215544a5796 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 09:39:10 +0200 Subject: [PATCH 03/29] feat(server): add webhooks table and repository Add the webhooks SQLite table and the Webhook repository backing it. The destination, notification_types, user_filter, and item_types columns are stored as JSON and decoded through #[sqlx(json)], so the tagged WebhookDestination enum and the key/value lists round-trip unchanged. create assigns a fresh uuid and ignores the id carried by the incoming DTO; update rewrites every mutable column while preserving created_at and bumping updated_at. Timestamps are bound from Utc::now() rather than left to the column defaults so they keep sub-second resolution. --- .../migrations/202608050001_webhooks.sql | 16 + crates/remux-server/src/db/mod.rs | 2 + crates/remux-server/src/db/webhook.rs | 572 ++++++++++++++++++ 3 files changed, 590 insertions(+) create mode 100644 crates/remux-server/migrations/202608050001_webhooks.sql create mode 100644 crates/remux-server/src/db/webhook.rs diff --git a/crates/remux-server/migrations/202608050001_webhooks.sql b/crates/remux-server/migrations/202608050001_webhooks.sql new file mode 100644 index 000000000..c6d7ee50a --- /dev/null +++ b/crates/remux-server/migrations/202608050001_webhooks.sql @@ -0,0 +1,16 @@ +CREATE TABLE webhooks ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + url TEXT NOT NULL, + template TEXT NOT NULL DEFAULT '', + destination TEXT NOT NULL, -- JSON WebhookDestination (tagged "Type") + notification_types TEXT NOT NULL DEFAULT '[]', -- JSON [NotificationType] + user_filter TEXT NOT NULL DEFAULT '[]', -- JSON [uuid] + item_types TEXT NOT NULL, -- JSON WebhookItemTypes + send_all_properties INTEGER NOT NULL DEFAULT 0, + trim_whitespace INTEGER NOT NULL DEFAULT 0, + skip_empty_message_body INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/crates/remux-server/src/db/mod.rs b/crates/remux-server/src/db/mod.rs index 74c660799..ab32dcad2 100644 --- a/crates/remux-server/src/db/mod.rs +++ b/crates/remux-server/src/db/mod.rs @@ -18,6 +18,7 @@ pub mod settings; pub mod stream_group; pub mod task; pub mod user; +pub mod webhook; pub use api_key::*; pub use image::*; pub use iptv::*; @@ -26,6 +27,7 @@ pub use settings::*; pub use stream_group::*; pub use task::*; pub use user::*; +pub use webhook::*; pub async fn connect(url: &str, slow_query_threshold_ms: u64) -> Result { let opts = SqliteConnectOptions::from_str(url)? diff --git a/crates/remux-server/src/db/webhook.rs b/crates/remux-server/src/db/webhook.rs new file mode 100644 index 000000000..881ce8762 --- /dev/null +++ b/crates/remux-server/src/db/webhook.rs @@ -0,0 +1,572 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use remux_sdks::remux::{ + NotificationType, WebhookDestination, WebhookDto, WebhookItemTypes, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{SqlitePool, types::Json}; +use uuid::Uuid; + +/// A stored outgoing webhook subscription. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Webhook { + pub id: Uuid, + pub name: String, + pub enabled: bool, + pub url: String, + pub template: String, + #[sqlx(json)] + pub destination: WebhookDestination, + #[sqlx(json)] + pub notification_types: Vec, + #[sqlx(json)] + pub user_filter: Vec, + #[sqlx(json)] + pub item_types: WebhookItemTypes, + pub send_all_properties: bool, + pub trim_whitespace: bool, + pub skip_empty_message_body: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Webhook { + /// Insert a new webhook. The id carried by `dto` is ignored — the server + /// always assigns a fresh one. + pub async fn create(db: &SqlitePool, dto: &WebhookDto) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO webhooks + (id, name, enabled, url, template, destination, notification_types, + user_filter, item_types, send_all_properties, trim_whitespace, + skip_empty_message_body, created_at, updated_at) + VALUES + (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?13)", + ) + .bind(id) + .bind(&dto.name) + .bind(dto.enabled) + .bind(&dto.url) + .bind(&dto.template) + .bind(Json(&dto.destination)) + .bind(Json(&dto.notification_types)) + .bind(Json(&dto.user_filter)) + .bind(Json(&dto.item_types)) + .bind(dto.send_all_properties) + .bind(dto.trim_whitespace) + .bind(dto.skip_empty_message_body) + .bind(now) + .execute(db) + .await?; + + Self::get_by_id(db, &id) + .await? + .ok_or_else(|| anyhow::anyhow!("webhook not found after insert")) + } + + pub async fn get_by_id(db: &SqlitePool, id: &Uuid) -> Result> { + Ok( + sqlx::query_as::<_, Self>("SELECT * FROM webhooks WHERE id = ?1") + .bind(id) + .fetch_optional(db) + .await?, + ) + } + + pub async fn get_all(db: &SqlitePool) -> Result> { + Ok( + sqlx::query_as::<_, Self>("SELECT * FROM webhooks ORDER BY created_at") + .fetch_all(db) + .await?, + ) + } + + pub async fn get_enabled(db: &SqlitePool) -> Result> { + Ok(sqlx::query_as::<_, Self>( + "SELECT * FROM webhooks WHERE enabled = 1 ORDER BY created_at", + ) + .fetch_all(db) + .await?) + } + + /// Overwrite every mutable column. `created_at` is preserved and + /// `updated_at` is bumped to now. + pub async fn update(db: &SqlitePool, id: &Uuid, dto: &WebhookDto) -> Result { + sqlx::query( + "UPDATE webhooks SET + name = ?2, + enabled = ?3, + url = ?4, + template = ?5, + destination = ?6, + notification_types = ?7, + user_filter = ?8, + item_types = ?9, + send_all_properties = ?10, + trim_whitespace = ?11, + skip_empty_message_body = ?12, + updated_at = ?13 + WHERE id = ?1", + ) + .bind(id) + .bind(&dto.name) + .bind(dto.enabled) + .bind(&dto.url) + .bind(&dto.template) + .bind(Json(&dto.destination)) + .bind(Json(&dto.notification_types)) + .bind(Json(&dto.user_filter)) + .bind(Json(&dto.item_types)) + .bind(dto.send_all_properties) + .bind(dto.trim_whitespace) + .bind(dto.skip_empty_message_body) + .bind(Utc::now()) + .execute(db) + .await?; + + Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow::anyhow!("webhook {id} not found")) + } + + pub async fn delete(db: &SqlitePool, id: &Uuid) -> Result<()> { + sqlx::query("DELETE FROM webhooks WHERE id = ?1") + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + pub fn into_dto(self) -> WebhookDto { + WebhookDto { + id: self.id, + name: self.name, + enabled: self.enabled, + url: self.url, + template: self.template, + destination: self.destination, + notification_types: self.notification_types, + user_filter: self.user_filter, + item_types: self.item_types, + send_all_properties: self.send_all_properties, + trim_whitespace: self.trim_whitespace, + skip_empty_message_body: self.skip_empty_message_body, + created_at: Some(self.created_at), + updated_at: Some(self.updated_at), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use remux_sdks::remux::{DiscordMentionType, WebhookKeyValue}; + + async fn test_db() -> SqlitePool { + let db = crate::db::connect("sqlite::memory:", 10_000) + .await + .unwrap(); + crate::db::migrate(&db) + .await + .unwrap(); + db + } + + fn sample_dto() -> WebhookDto { + WebhookDto { + id: Uuid::new_v4(), + name: "discord".into(), + enabled: true, + url: "https://example.test/hook".into(), + template: "{{ItemName}}".into(), + destination: WebhookDestination::Discord { + avatar_url: Some("https://example.test/avatar.png".into()), + bot_username: Some("remux".into()), + embed_color: Some("#AA5CC3".into()), + mention_type: DiscordMentionType::Here, + }, + notification_types: vec![ + NotificationType::ItemAdded, + NotificationType::PlaybackStart, + ], + user_filter: vec![Uuid::new_v4()], + item_types: WebhookItemTypes { + songs: false, + ..Default::default() + }, + send_all_properties: true, + trim_whitespace: true, + skip_empty_message_body: true, + created_at: None, + updated_at: None, + } + } + + #[tokio::test] + async fn create_assigns_a_fresh_id_and_persists_scalar_columns() { + let db = test_db().await; + let dto = sample_dto(); + + let created = Webhook::create(&db, &dto) + .await + .unwrap(); + + assert_ne!(created.id, dto.id, "create must ignore the incoming id"); + assert_eq!(created.name, dto.name); + assert_eq!(created.enabled, dto.enabled); + assert_eq!(created.url, dto.url); + assert_eq!(created.template, dto.template); + assert_eq!(created.send_all_properties, dto.send_all_properties); + assert_eq!(created.trim_whitespace, dto.trim_whitespace); + assert_eq!(created.skip_empty_message_body, dto.skip_empty_message_body); + + let fetched = Webhook::get_by_id(&db, &created.id) + .await + .unwrap() + .expect("webhook must be readable back"); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.name, "discord"); + assert_eq!(fetched.url, "https://example.test/hook"); + assert_eq!(fetched.template, "{{ItemName}}"); + assert!(fetched.enabled); + } + + #[tokio::test] + async fn get_by_id_returns_none_for_unknown_id() { + let db = test_db().await; + assert!( + Webhook::get_by_id(&db, &Uuid::new_v4()) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn json_columns_round_trip() { + let db = test_db().await; + let users = vec![Uuid::new_v4(), Uuid::new_v4()]; + let dto = WebhookDto { + destination: WebhookDestination::Generic { + headers: vec![WebhookKeyValue { + key: "X-Token".into(), + value: "secret".into(), + }], + fields: vec![ + WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }, + WebhookKeyValue { + key: "kind".into(), + value: "alert".into(), + }, + ], + }, + notification_types: vec![ + NotificationType::ItemAdded, + NotificationType::UserPasswordChanged, + ], + user_filter: users.clone(), + item_types: WebhookItemTypes { + movies: true, + episodes: false, + series: true, + seasons: false, + albums: true, + songs: false, + videos: true, + }, + ..sample_dto() + }; + + let created = Webhook::create(&db, &dto) + .await + .unwrap(); + let fetched = Webhook::get_by_id(&db, &created.id) + .await + .unwrap() + .expect("webhook must be readable back"); + + // The tagged enum must come back as the same variant, with payload intact. + match &fetched.destination { + WebhookDestination::Generic { headers, fields } => { + assert_eq!(headers.len(), 1); + assert_eq!(headers[0].key, "X-Token"); + assert_eq!(headers[0].value, "secret"); + assert_eq!(fields.len(), 2); + assert_eq!(fields[1].key, "kind"); + } + other => panic!("expected Generic destination, got {other:?}"), + } + assert_eq!(fetched.destination, dto.destination); + assert_eq!(fetched.notification_types, dto.notification_types); + assert_eq!(fetched.user_filter, users); + assert_eq!(fetched.item_types, dto.item_types); + assert!( + !fetched + .item_types + .episodes + ); + assert!( + !fetched + .item_types + .songs + ); + } + + #[tokio::test] + async fn discord_destination_round_trips() { + let db = test_db().await; + let created = Webhook::create(&db, &sample_dto()) + .await + .unwrap(); + let fetched = Webhook::get_by_id(&db, &created.id) + .await + .unwrap() + .expect("webhook must be readable back"); + + match fetched.destination { + WebhookDestination::Discord { + avatar_url, + bot_username, + embed_color, + mention_type, + } => { + assert_eq!( + avatar_url.as_deref(), + Some("https://example.test/avatar.png") + ); + assert_eq!(bot_username.as_deref(), Some("remux")); + assert_eq!(embed_color.as_deref(), Some("#AA5CC3")); + assert_eq!(mention_type, DiscordMentionType::Here); + } + other => panic!("expected Discord destination, got {other:?}"), + } + } + + #[tokio::test] + async fn get_all_is_ordered_by_created_at() { + let db = test_db().await; + for name in ["first", "second", "third"] { + Webhook::create( + &db, + &WebhookDto { + name: name.into(), + ..sample_dto() + }, + ) + .await + .unwrap(); + } + + let all = Webhook::get_all(&db) + .await + .unwrap(); + assert_eq!( + all.iter() + .map(|w| w + .name + .as_str()) + .collect::>(), + vec!["first", "second", "third"] + ); + } + + #[tokio::test] + async fn get_enabled_excludes_disabled_rows() { + let db = test_db().await; + let on = Webhook::create( + &db, + &WebhookDto { + name: "on".into(), + enabled: true, + ..sample_dto() + }, + ) + .await + .unwrap(); + let off = Webhook::create( + &db, + &WebhookDto { + name: "off".into(), + enabled: false, + ..sample_dto() + }, + ) + .await + .unwrap(); + + let enabled = Webhook::get_enabled(&db) + .await + .unwrap(); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].id, on.id); + + // The disabled row still exists — get_enabled filters, it does not delete. + assert_eq!( + Webhook::get_all(&db) + .await + .unwrap() + .len(), + 2 + ); + assert!( + !Webhook::get_by_id(&db, &off.id) + .await + .unwrap() + .expect("disabled webhook still stored") + .enabled + ); + } + + #[tokio::test] + async fn update_replaces_json_columns_and_advances_updated_at() { + let db = test_db().await; + let created = Webhook::create(&db, &sample_dto()) + .await + .unwrap(); + + let patch = WebhookDto { + id: Uuid::new_v4(), + name: "renamed".into(), + enabled: false, + url: "https://example.test/other".into(), + template: "{{SeriesName}}".into(), + destination: WebhookDestination::Generic { + headers: vec![WebhookKeyValue { + key: "Authorization".into(), + value: "Bearer x".into(), + }], + fields: vec![], + }, + notification_types: vec![NotificationType::PlaybackStop], + user_filter: vec![], + item_types: WebhookItemTypes { + movies: false, + ..Default::default() + }, + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: None, + updated_at: None, + }; + + let updated = Webhook::update(&db, &created.id, &patch) + .await + .unwrap(); + + assert_eq!(updated.id, created.id, "update must not re-key the row"); + assert_eq!(updated.name, "renamed"); + assert!(!updated.enabled); + assert_eq!(updated.url, "https://example.test/other"); + assert_eq!(updated.template, "{{SeriesName}}"); + assert!(!updated.send_all_properties); + assert!(!updated.trim_whitespace); + assert!(!updated.skip_empty_message_body); + + // JSON columns actually changed. + assert_ne!(updated.destination, created.destination); + assert_eq!(updated.destination, patch.destination); + assert_eq!( + updated.notification_types, + vec![NotificationType::PlaybackStop] + ); + assert!( + updated + .user_filter + .is_empty() + ); + assert!( + !updated + .item_types + .movies + ); + + assert_eq!( + updated.created_at, created.created_at, + "created_at must be preserved" + ); + assert!( + updated.updated_at > created.updated_at, + "updated_at must advance ({} !> {})", + updated.updated_at, + created.updated_at + ); + + // The change is persisted, not just reflected in the returned value. + let fetched = Webhook::get_by_id(&db, &created.id) + .await + .unwrap() + .expect("webhook must still exist"); + assert_eq!(fetched.destination, patch.destination); + assert_eq!(fetched.name, "renamed"); + assert_eq!(fetched.updated_at, updated.updated_at); + } + + #[tokio::test] + async fn delete_removes_only_the_target_row() { + let db = test_db().await; + let a = Webhook::create( + &db, + &WebhookDto { + name: "a".into(), + ..sample_dto() + }, + ) + .await + .unwrap(); + let b = Webhook::create( + &db, + &WebhookDto { + name: "b".into(), + ..sample_dto() + }, + ) + .await + .unwrap(); + + Webhook::delete(&db, &a.id) + .await + .unwrap(); + + assert!( + Webhook::get_by_id(&db, &a.id) + .await + .unwrap() + .is_none() + ); + let all = Webhook::get_all(&db) + .await + .unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, b.id); + } + + #[tokio::test] + async fn into_dto_exposes_stored_timestamps() { + let db = test_db().await; + let created = Webhook::create(&db, &sample_dto()) + .await + .unwrap(); + let (id, created_at, updated_at, destination) = ( + created.id, + created.created_at, + created.updated_at, + created + .destination + .clone(), + ); + + let dto = created.into_dto(); + + assert_eq!(dto.id, id); + assert_eq!(dto.created_at, Some(created_at)); + assert_eq!(dto.updated_at, Some(updated_at)); + assert_eq!(dto.destination, destination); + assert_eq!(dto.name, "discord"); + assert!(dto.enabled); + } +} From 4492c8c1e9821d449f13a4616d5584436470bbcd Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 09:51:14 +0200 Subject: [PATCH 04/29] test(server): make transposed webhook bool binds detectable send_all_properties, trim_whitespace and skip_empty_message_body are bound to three adjacent same-typed placeholders in create and update. The fixture set all three to true and the update patch set all three to false, so any permutation of those binds still passed every test. Give the three bools distinct values in the fixture and the inverse in the update patch, and assert them literally on the rows read back from the database. Swapping any two binds now fails an assertion. --- crates/remux-server/src/db/webhook.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/remux-server/src/db/webhook.rs b/crates/remux-server/src/db/webhook.rs index 881ce8762..1f356f0c7 100644 --- a/crates/remux-server/src/db/webhook.rs +++ b/crates/remux-server/src/db/webhook.rs @@ -195,8 +195,11 @@ mod tests { songs: false, ..Default::default() }, + // Distinct values on purpose: these three bools sit on adjacent + // placeholders of the same type, so identical values would let any + // permutation of the binds pass. send_all_properties: true, - trim_whitespace: true, + trim_whitespace: false, skip_empty_message_body: true, created_at: None, updated_at: None, @@ -230,6 +233,9 @@ mod tests { assert_eq!(fetched.url, "https://example.test/hook"); assert_eq!(fetched.template, "{{ItemName}}"); assert!(fetched.enabled); + assert!(fetched.send_all_properties); + assert!(!fetched.trim_whitespace); + assert!(fetched.skip_empty_message_body); } #[tokio::test] @@ -447,8 +453,10 @@ mod tests { movies: false, ..Default::default() }, + // Inverse of the fixture, and still distinct from each other, so a + // transposed bind in `update` cannot go unnoticed either. send_all_properties: false, - trim_whitespace: false, + trim_whitespace: true, skip_empty_message_body: false, created_at: None, updated_at: None, @@ -464,7 +472,7 @@ mod tests { assert_eq!(updated.url, "https://example.test/other"); assert_eq!(updated.template, "{{SeriesName}}"); assert!(!updated.send_all_properties); - assert!(!updated.trim_whitespace); + assert!(updated.trim_whitespace); assert!(!updated.skip_empty_message_body); // JSON columns actually changed. @@ -504,6 +512,9 @@ mod tests { assert_eq!(fetched.destination, patch.destination); assert_eq!(fetched.name, "renamed"); assert_eq!(fetched.updated_at, updated.updated_at); + assert!(!fetched.send_all_properties); + assert!(fetched.trim_whitespace); + assert!(!fetched.skip_empty_message_body); } #[tokio::test] From 3bbbff6891f9781479525fee104b3032e6f0bbb4 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 10:05:26 +0200 Subject: [PATCH 05/29] test(server): catch every transposition of the webhook bool binds Three booleans range over two values, so any single fixture leaves one symmetric pair. The previous true/false/true shape has send_all_properties and skip_empty_message_body both true, so swapping those two binds wrote the same values into the same columns and stayed invisible in both create and update. Add FLAG_CASES, the three one-hot combinations, and drive create and update through each of them. Every case detects the two swaps involving its single true, so the three together cover all three pairwise swaps. update is seeded with the inverse triple first, forcing all three columns to be written. Verified by applying each of the three swaps to create and to update in turn: all six now fail, where the two 1<->3 swaps previously passed. --- crates/remux-server/src/db/webhook.rs | 91 ++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/remux-server/src/db/webhook.rs b/crates/remux-server/src/db/webhook.rs index 1f356f0c7..2b779c29e 100644 --- a/crates/remux-server/src/db/webhook.rs +++ b/crates/remux-server/src/db/webhook.rs @@ -197,7 +197,8 @@ mod tests { }, // Distinct values on purpose: these three bools sit on adjacent // placeholders of the same type, so identical values would let any - // permutation of the binds pass. + // permutation of the binds pass. This shape alone still cannot + // catch a 1↔3 swap (both are `true`) — see `FLAG_CASES` below. send_all_properties: true, trim_whitespace: false, skip_empty_message_body: true, @@ -206,6 +207,94 @@ mod tests { } } + /// `(send_all_properties, trim_whitespace, skip_empty_message_body)`. + /// + /// Three booleans only take two values, so no single combination can + /// distinguish all three pairwise swaps: whichever shape is chosen, one + /// pair holds the same value and swapping it is invisible. These one-hot + /// cases cover the three swaps between them — each case detects the two + /// swaps that involve its single `true`: + /// + /// | case | 1↔2 | 2↔3 | 1↔3 | + /// |---------------|-----|-----|-----| + /// | `(T, F, F)` | ✓ | | ✓ | + /// | `(F, T, F)` | ✓ | ✓ | | + /// | `(F, F, T)` | | ✓ | ✓ | + const FLAG_CASES: [(bool, bool, bool); 3] = [ + (true, false, false), + (false, true, false), + (false, false, true), + ]; + + fn assert_flags(webhook: &Webhook, expected: (bool, bool, bool), stage: &str) { + assert_eq!( + ( + webhook.send_all_properties, + webhook.trim_whitespace, + webhook.skip_empty_message_body, + ), + expected, + "{stage}: (send_all_properties, trim_whitespace, skip_empty_message_body)" + ); + } + + /// Guards against a transposed `.bind()` among the three adjacent boolean + /// placeholders in `create` and in `update`. + #[tokio::test] + async fn boolean_flags_land_in_their_own_columns() { + let db = test_db().await; + + for (send_all, trim, skip_empty) in FLAG_CASES { + let expected = (send_all, trim, skip_empty); + let dto = WebhookDto { + send_all_properties: send_all, + trim_whitespace: trim, + skip_empty_message_body: skip_empty, + ..sample_dto() + }; + + // create writes the triple. + let created = Webhook::create(&db, &dto) + .await + .unwrap(); + assert_flags(&created, expected, "create returned"); + assert_flags( + &Webhook::get_by_id(&db, &created.id) + .await + .unwrap() + .expect("created webhook must be readable back"), + expected, + "create stored", + ); + + // update writes the triple onto a row currently holding its inverse, + // so every column has to be written to reach the expected state. + let seed = Webhook::create( + &db, + &WebhookDto { + send_all_properties: !send_all, + trim_whitespace: !trim, + skip_empty_message_body: !skip_empty, + ..sample_dto() + }, + ) + .await + .unwrap(); + let updated = Webhook::update(&db, &seed.id, &dto) + .await + .unwrap(); + assert_flags(&updated, expected, "update returned"); + assert_flags( + &Webhook::get_by_id(&db, &seed.id) + .await + .unwrap() + .expect("updated webhook must be readable back"), + expected, + "update stored", + ); + } + } + #[tokio::test] async fn create_assigns_a_fresh_id_and_persists_scalar_columns() { let db = test_db().await; From aa69691a7fe93b80e8540da4f852003b0c60f9e7 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 10:23:35 +0200 Subject: [PATCH 06/29] feat(server): add webhook event bus and dispatcher Add WebhookEvent, the internal event type covering the fifteen notification types, and WebhookService, which owns a broadcast channel and the background task that turns events into deliveries. Events carry the data already in hand where they are raised, so emitting is a non-blocking send on a bounded broadcast channel and never touches the database. ItemDeleted boxes the media row because it has to be captured before the DELETE runs. The dispatcher owns the only receiver. It caches the enabled webhooks, the union of the notification types they subscribe to, and a Handlebars registry; the CRUD endpoints will mark that snapshot dirty and it is reloaded before the next event. A lagged receive is logged and skipped rather than ending the loop, and each delivery is spawned so one slow endpoint cannot stall the hooks behind it. matches() is a pure function over the three filter rules. An empty notification_types list matches nothing, mirroring the Jellyfin webhook plugin. An empty user_filter accepts every user, and events that carry no user are exempt from it entirely. The item_types toggles apply only when the event is about an item, mapping Movie, Episode, Series, Season and Album to their own flags, Track to songs, and every other kind to videos. payload, template and sender are minimal stubs so this compiles on its own; payload building, Handlebars helpers and HTTP delivery follow. --- Cargo.lock | 33 + crates/remux-server/Cargo.toml | 1 + crates/remux-server/src/lib.rs | 7 + crates/remux-server/src/services/mod.rs | 1 + .../src/services/webhooks/events.rs | 445 +++++++++++++ .../remux-server/src/services/webhooks/mod.rs | 583 ++++++++++++++++++ .../src/services/webhooks/payload.rs | 39 ++ .../src/services/webhooks/sender.rs | 23 + .../src/services/webhooks/template.rs | 20 + 9 files changed, 1152 insertions(+) create mode 100644 crates/remux-server/src/services/webhooks/events.rs create mode 100644 crates/remux-server/src/services/webhooks/mod.rs create mode 100644 crates/remux-server/src/services/webhooks/payload.rs create mode 100644 crates/remux-server/src/services/webhooks/sender.rs create mode 100644 crates/remux-server/src/services/webhooks/template.rs diff --git a/Cargo.lock b/Cargo.lock index 51ab26372..a63cab592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3544,6 +3544,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handlebars" +version = "6.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -5246,6 +5262,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-rational" version = "0.2.4" @@ -6664,6 +6695,7 @@ dependencies = [ "flate2", "futures", "futures-util", + "handlebars", "headers", "http 1.4.0", "http-body 1.0.1", @@ -7264,6 +7296,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", diff --git a/crates/remux-server/Cargo.toml b/crates/remux-server/Cargo.toml index 211b3b037..4f57d6f5c 100644 --- a/crates/remux-server/Cargo.toml +++ b/crates/remux-server/Cargo.toml @@ -131,6 +131,7 @@ tikv-jemallocator = { version = "0.7", optional = true } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } imageproc = { version = "0.26", default-features = false, features = ["text"] } ab_glyph = "0.2" +handlebars = "6" [features] default = ["jemalloc"] jemalloc = ["tikv-jemallocator"] diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index 42417df15..1c4c7047d 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -261,6 +261,7 @@ pub async fn init_app( )), web_paths, addons, + webhooks: services::webhooks::WebhookService::new(), started_at: Utc::now(), }; @@ -278,6 +279,11 @@ pub async fn init_app( std::time::Duration::from_secs(60 * 15), ); + // Fans emitted webhook events out to the enabled webhooks. + ctx.webhooks + .clone() + .spawn_dispatcher(ctx.clone()); + db::StreamGroup::migrate_from_settings(&conn).await; let task_service = tasks::TaskService::new(ctx.clone()).await?; @@ -348,6 +354,7 @@ pub struct AppContext { /// Present in filesystem builds; `None` in desktop (assets are embedded). pub web_paths: Option, pub addons: addons::AddonService, + pub webhooks: services::webhooks::WebhookService, /// When this server process started. pub started_at: chrono::DateTime, } diff --git a/crates/remux-server/src/services/mod.rs b/crates/remux-server/src/services/mod.rs index 655e4166a..5215be48c 100644 --- a/crates/remux-server/src/services/mod.rs +++ b/crates/remux-server/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod image; pub(crate) mod resolve; pub(crate) mod stream_service; pub mod stremio; +pub mod webhooks; pub use resolve::MediaResolveService; pub(crate) use resolve::ResolvedItem; diff --git a/crates/remux-server/src/services/webhooks/events.rs b/crates/remux-server/src/services/webhooks/events.rs new file mode 100644 index 000000000..89c66b7b7 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/events.rs @@ -0,0 +1,445 @@ +//! The internal webhook event type. +//! +//! Events carry the data that is already in hand at the emission point so that +//! emitting never touches the database. Anything else the templates need is +//! resolved later, off the hot path, by the dispatcher. + +use crate::db; +use remux_sdks::remux::NotificationType; +use uuid::Uuid; + +/// The user a webhook event is attributed to. +#[derive(Debug, Clone)] +pub struct UserEventData { + pub id: Uuid, + pub username: String, +} + +/// The client/device a webhook event originated from. +#[derive(Debug, Clone)] +pub struct DeviceEventData { + pub id: String, + pub name: String, + pub client_name: String, + pub remote_ip: Option, +} + +/// The playback state shared by the three playback events. +#[derive(Debug, Clone)] +pub struct PlaybackEventData { + pub user: UserEventData, + pub item_id: Uuid, + pub device: DeviceEventData, + pub position_ticks: i64, + pub is_paused: bool, + pub play_method: Option, +} + +/// Why a `UserDataSaved` event was raised. +#[derive(Debug, Clone, Copy, strum_macros::Display)] +pub enum UserDataSaveReason { + TogglePlayed, + ToggleFavorite, + PlaybackProgress, + PlaybackFinished, +} + +/// One server-side occurrence a webhook can subscribe to. +/// +/// Maps 1:1 onto [`NotificationType`] — see [`WebhookEvent::notification_type`]. +#[derive(Debug, Clone)] +pub enum WebhookEvent { + ItemAdded { + item_id: Uuid, + }, + /// The row is captured *before* the DELETE, so the payload can still be built. + ItemDeleted { + item: Box, + }, + Generic { + title: String, + extra: Vec<(String, String)>, + }, + PlaybackStart { + playback: PlaybackEventData, + }, + PlaybackProgress { + playback: PlaybackEventData, + }, + PlaybackStop { + playback: PlaybackEventData, + }, + AuthenticationSuccess { + user: UserEventData, + device: DeviceEventData, + }, + AuthenticationFailure { + username: String, + remote_ip: Option, + }, + SessionStart { + user: UserEventData, + device: DeviceEventData, + }, + TaskCompleted { + key: String, + name: String, + succeeded: bool, + elapsed_ms: u64, + }, + UserCreated { + user: UserEventData, + }, + UserDeleted { + user_id: Uuid, + username: String, + }, + UserUpdated { + user: UserEventData, + }, + UserPasswordChanged { + user: UserEventData, + }, + UserDataSaved { + user: UserEventData, + item_id: Uuid, + save_reason: UserDataSaveReason, + }, +} + +impl WebhookEvent { + /// The subscription key operators pick in the admin UI. + pub fn notification_type(&self) -> NotificationType { + match self { + Self::ItemAdded { .. } => NotificationType::ItemAdded, + Self::ItemDeleted { .. } => NotificationType::ItemDeleted, + Self::Generic { .. } => NotificationType::Generic, + Self::PlaybackStart { .. } => NotificationType::PlaybackStart, + Self::PlaybackProgress { .. } => NotificationType::PlaybackProgress, + Self::PlaybackStop { .. } => NotificationType::PlaybackStop, + Self::AuthenticationSuccess { .. } => { + NotificationType::AuthenticationSuccess + } + Self::AuthenticationFailure { .. } => { + NotificationType::AuthenticationFailure + } + Self::SessionStart { .. } => NotificationType::SessionStart, + Self::TaskCompleted { .. } => NotificationType::TaskCompleted, + Self::UserCreated { .. } => NotificationType::UserCreated, + Self::UserDeleted { .. } => NotificationType::UserDeleted, + Self::UserUpdated { .. } => NotificationType::UserUpdated, + Self::UserPasswordChanged { .. } => NotificationType::UserPasswordChanged, + Self::UserDataSaved { .. } => NotificationType::UserDataSaved, + } + } + + /// The user this event is attributed to, if any. `None` disables the + /// per-webhook user filter for this event. + pub fn user_id(&self) -> Option { + match self { + Self::PlaybackStart { playback } + | Self::PlaybackProgress { playback } + | Self::PlaybackStop { playback } => Some( + playback + .user + .id, + ), + Self::AuthenticationSuccess { user, .. } + | Self::SessionStart { user, .. } + | Self::UserCreated { user } + | Self::UserUpdated { user } + | Self::UserPasswordChanged { user } + | Self::UserDataSaved { user, .. } => Some(user.id), + Self::UserDeleted { user_id, .. } => Some(*user_id), + Self::ItemAdded { .. } + | Self::ItemDeleted { .. } + | Self::Generic { .. } + | Self::AuthenticationFailure { .. } + | Self::TaskCompleted { .. } => None, + } + } + + /// The library item this event is about, if any. + pub fn item_id(&self) -> Option { + match self { + Self::ItemAdded { item_id } | Self::UserDataSaved { item_id, .. } => { + Some(*item_id) + } + Self::ItemDeleted { item } => Some(item.id), + Self::PlaybackStart { playback } + | Self::PlaybackProgress { playback } + | Self::PlaybackStop { playback } => Some(playback.item_id), + Self::Generic { .. } + | Self::AuthenticationSuccess { .. } + | Self::AuthenticationFailure { .. } + | Self::SessionStart { .. } + | Self::TaskCompleted { .. } + | Self::UserCreated { .. } + | Self::UserDeleted { .. } + | Self::UserUpdated { .. } + | Self::UserPasswordChanged { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn user() -> UserEventData { + UserEventData { + id: Uuid::from_u128(1), + username: "alice".into(), + } + } + + fn device() -> DeviceEventData { + DeviceEventData { + id: "device-1".into(), + name: "Living Room".into(), + client_name: "Jellyfin Web".into(), + remote_ip: Some("10.0.0.2".into()), + } + } + + fn playback() -> PlaybackEventData { + PlaybackEventData { + user: user(), + item_id: Uuid::from_u128(2), + device: device(), + position_ticks: 123, + is_paused: false, + play_method: Some("DirectStream".into()), + } + } + + fn media() -> Box { + Box::new(db::Media { + id: Uuid::from_u128(3), + ..Default::default() + }) + } + + /// Compile-time guard. Adding a variant to [`WebhookEvent`] breaks this + /// match, which forces `EVENTS` (and therefore the mapping under test) to + /// be extended too. + fn variant_index(event: &WebhookEvent) -> usize { + match event { + WebhookEvent::ItemAdded { .. } => 0, + WebhookEvent::ItemDeleted { .. } => 1, + WebhookEvent::Generic { .. } => 2, + WebhookEvent::PlaybackStart { .. } => 3, + WebhookEvent::PlaybackProgress { .. } => 4, + WebhookEvent::PlaybackStop { .. } => 5, + WebhookEvent::AuthenticationSuccess { .. } => 6, + WebhookEvent::AuthenticationFailure { .. } => 7, + WebhookEvent::SessionStart { .. } => 8, + WebhookEvent::TaskCompleted { .. } => 9, + WebhookEvent::UserCreated { .. } => 10, + WebhookEvent::UserDeleted { .. } => 11, + WebhookEvent::UserUpdated { .. } => 12, + WebhookEvent::UserPasswordChanged { .. } => 13, + WebhookEvent::UserDataSaved { .. } => 14, + } + } + + const VARIANT_COUNT: usize = 15; + + /// One sample per variant, paired with the notification type it must map to. + fn events() -> Vec<(WebhookEvent, NotificationType)> { + vec![ + ( + WebhookEvent::ItemAdded { + item_id: Uuid::from_u128(2), + }, + NotificationType::ItemAdded, + ), + ( + WebhookEvent::ItemDeleted { item: media() }, + NotificationType::ItemDeleted, + ), + ( + WebhookEvent::Generic { + title: "hello".into(), + extra: vec![], + }, + NotificationType::Generic, + ), + ( + WebhookEvent::PlaybackStart { + playback: playback(), + }, + NotificationType::PlaybackStart, + ), + ( + WebhookEvent::PlaybackProgress { + playback: playback(), + }, + NotificationType::PlaybackProgress, + ), + ( + WebhookEvent::PlaybackStop { + playback: playback(), + }, + NotificationType::PlaybackStop, + ), + ( + WebhookEvent::AuthenticationSuccess { + user: user(), + device: device(), + }, + NotificationType::AuthenticationSuccess, + ), + ( + WebhookEvent::AuthenticationFailure { + username: "mallory".into(), + remote_ip: None, + }, + NotificationType::AuthenticationFailure, + ), + ( + WebhookEvent::SessionStart { + user: user(), + device: device(), + }, + NotificationType::SessionStart, + ), + ( + WebhookEvent::TaskCompleted { + key: "scan".into(), + name: "Scan library".into(), + succeeded: true, + elapsed_ms: 42, + }, + NotificationType::TaskCompleted, + ), + ( + WebhookEvent::UserCreated { user: user() }, + NotificationType::UserCreated, + ), + ( + WebhookEvent::UserDeleted { + user_id: Uuid::from_u128(1), + username: "alice".into(), + }, + NotificationType::UserDeleted, + ), + ( + WebhookEvent::UserUpdated { user: user() }, + NotificationType::UserUpdated, + ), + ( + WebhookEvent::UserPasswordChanged { user: user() }, + NotificationType::UserPasswordChanged, + ), + ( + WebhookEvent::UserDataSaved { + user: user(), + item_id: Uuid::from_u128(2), + save_reason: UserDataSaveReason::TogglePlayed, + }, + NotificationType::UserDataSaved, + ), + ] + } + + #[test] + fn every_variant_has_a_sample() { + let covered: HashSet = events() + .iter() + .map(|(event, _)| variant_index(event)) + .collect(); + assert_eq!( + covered.len(), + VARIANT_COUNT, + "events() must contain exactly one sample per WebhookEvent variant" + ); + } + + #[test] + fn notification_type_maps_each_variant() { + for (event, expected) in events() { + assert_eq!( + event.notification_type(), + expected, + "wrong notification type for {event:?}" + ); + } + } + + /// A mapping that collapses two variants onto the same notification type + /// would still pass a naive per-case check if the expectations were copied + /// from the (wrong) implementation. Distinctness is checked separately. + #[test] + fn notification_types_are_distinct_across_variants() { + let produced: HashSet = events() + .iter() + .map(|(event, _)| event.notification_type()) + .collect(); + assert_eq!( + produced.len(), + VARIANT_COUNT, + "each WebhookEvent variant must map to its own NotificationType" + ); + } + + #[test] + fn user_id_is_present_only_for_user_attributed_events() { + let alice = Uuid::from_u128(1); + let expected: Vec> = vec![ + None, // ItemAdded + None, // ItemDeleted + None, // Generic + Some(alice), // PlaybackStart + Some(alice), // PlaybackProgress + Some(alice), // PlaybackStop + Some(alice), // AuthenticationSuccess + None, // AuthenticationFailure + Some(alice), // SessionStart + None, // TaskCompleted + Some(alice), // UserCreated + Some(alice), // UserDeleted + Some(alice), // UserUpdated + Some(alice), // UserPasswordChanged + Some(alice), // UserDataSaved + ]; + assert_eq!(expected.len(), VARIANT_COUNT); + for ((event, _), want) in events() + .iter() + .zip(expected) + { + assert_eq!(event.user_id(), want, "wrong user_id for {event:?}"); + } + } + + #[test] + fn item_id_is_present_only_for_item_scoped_events() { + let item = Uuid::from_u128(2); + let deleted = Uuid::from_u128(3); + let expected: Vec> = vec![ + Some(item), // ItemAdded + Some(deleted), // ItemDeleted — read off the captured row + None, // Generic + Some(item), // PlaybackStart + Some(item), // PlaybackProgress + Some(item), // PlaybackStop + None, // AuthenticationSuccess + None, // AuthenticationFailure + None, // SessionStart + None, // TaskCompleted + None, // UserCreated + None, // UserDeleted + None, // UserUpdated + None, // UserPasswordChanged + Some(item), // UserDataSaved + ]; + assert_eq!(expected.len(), VARIANT_COUNT); + for ((event, _), want) in events() + .iter() + .zip(expected) + { + assert_eq!(event.item_id(), want, "wrong item_id for {event:?}"); + } + } +} diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs new file mode 100644 index 000000000..2eed5700a --- /dev/null +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -0,0 +1,583 @@ +//! Outgoing webhooks: an in-process event bus plus the background dispatcher +//! that turns events into HTTP deliveries. +//! +//! Emission is fire-and-forget (`WebhookService::emit`) so no request handler +//! ever waits on a webhook. A single dispatcher task owns the receiver, keeps a +//! cached snapshot of the enabled webhooks, and fans each event out to the +//! hooks that match it. + +pub mod events; +mod payload; +mod sender; +mod template; + +pub use events::{ + DeviceEventData, PlaybackEventData, UserDataSaveReason, UserEventData, WebhookEvent, +}; + +use crate::{AppContext, db}; +use remux_sdks::remux::NotificationType; +use std::{ + collections::HashSet, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::{ + sync::{RwLock, broadcast, broadcast::error::RecvError}, + task::JoinHandle, +}; +use tracing::warn; + +/// Buffered events per subscriber. Large enough that a slow dispatcher pass +/// (one enrichment round-trip) never drops events under normal playback load. +const EVENT_CHANNEL_CAPACITY: usize = 4096; + +/// The enabled webhooks as last read from the database, plus everything derived +/// from them that would otherwise be recomputed per event. +#[derive(Default)] +pub(crate) struct LoadedWebhooks { + pub hooks: Vec, + /// Template registry. Helpers and partials are registered in task 4. + pub registry: handlebars::Handlebars<'static>, + /// Union of every enabled hook's subscriptions — the dispatcher fast-path. + pub wanted: HashSet, +} + +struct Inner { + /// Set by the webhook CRUD endpoints; consumed by the dispatcher. + dirty: AtomicBool, + cache: RwLock, +} + +#[derive(Clone)] +pub struct WebhookService { + tx: broadcast::Sender>, + inner: Arc, +} + +impl WebhookService { + pub fn new() -> Self { + let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + Self { + tx, + inner: Arc::new(Inner { + // The dispatcher loads the cache once on startup, so nothing is + // stale until the CRUD endpoints say so. + dirty: AtomicBool::new(false), + cache: RwLock::new(LoadedWebhooks::default()), + }), + } + } + + /// Publish an event. Never blocks and never fails the caller: with no + /// dispatcher running (or a lagging one) the event is simply dropped. + pub fn emit(&self, event: WebhookEvent) { + let _ = self + .tx + .send(Arc::new(event)); + } + + /// Mark the cached webhook set stale. The dispatcher reloads before it + /// handles the next event. + pub fn invalidate(&self) { + self.inner + .dirty + .store(true, Ordering::Release); + } + + /// Replace the cached snapshot from the database. On error the previous + /// snapshot is kept — a transient DB failure must not silently disable + /// every webhook. + async fn reload(&self, db: &sqlx::SqlitePool) { + let hooks = match db::Webhook::get_enabled(db).await { + Ok(hooks) => hooks, + Err(e) => { + warn!(error = %e, "failed to load webhooks, keeping previous set"); + return; + } + }; + let wanted = hooks + .iter() + .flat_map(|hook| { + hook.notification_types + .iter() + .copied() + }) + .collect(); + let mut cache = self + .inner + .cache + .write() + .await; + *cache = LoadedWebhooks { + hooks, + registry: handlebars::Handlebars::new(), + wanted, + }; + } + + /// Whether `hook` wants `event`. Pure: `item_kind` is the kind of the item + /// the event is about, or `None` when the event carries no item. + pub(crate) fn matches( + hook: &db::Webhook, + event: &WebhookEvent, + item_kind: Option<&db::MediaKind>, + ) -> bool { + // 1. Subscription. An empty list matches nothing — this mirrors the + // Jellyfin webhook plugin and is not an oversight. + if !hook + .notification_types + .contains(&event.notification_type()) + { + return false; + } + + // 2. User filter. Empty means every user; events with no user are exempt. + if !hook + .user_filter + .is_empty() + && let Some(user_id) = event.user_id() + && !hook + .user_filter + .contains(&user_id) + { + return false; + } + + // 3. Item-type toggles, only for events that carry an item. + if let Some(kind) = item_kind { + let types = &hook.item_types; + let allowed = match kind { + db::MediaKind::Movie => types.movies, + db::MediaKind::Episode => types.episodes, + db::MediaKind::Series => types.series, + db::MediaKind::Season => types.seasons, + db::MediaKind::Album => types.albums, + db::MediaKind::Track => types.songs, + _ => types.videos, + }; + if !allowed { + return false; + } + } + + true + } + + /// Run the dispatcher until the channel closes. Owns the only receiver. + pub fn spawn_dispatcher(self, ctx: AppContext) -> JoinHandle<()> { + tokio::spawn(async move { + let mut rx = self + .tx + .subscribe(); + self.reload(&ctx.db) + .await; + + loop { + let event = match rx + .recv() + .await + { + Ok(event) => event, + Err(RecvError::Lagged(dropped)) => { + warn!(dropped, "webhook dispatcher lagged"); + continue; + } + Err(RecvError::Closed) => return, + }; + + if self + .inner + .dirty + .swap(false, Ordering::AcqRel) + { + self.reload(&ctx.db) + .await; + } + + let cache = self + .inner + .cache + .read() + .await; + if !cache + .wanted + .contains(&event.notification_type()) + { + continue; + } + + let item = payload::enrich_item(&ctx, &event).await; + let item_kind = item + .as_ref() + .map(|i| &i.media.kind); + let targets: Vec<&db::Webhook> = cache + .hooks + .iter() + .filter(|hook| Self::matches(hook, &event, item_kind)) + .collect(); + if targets.is_empty() { + continue; + } + + let data = payload::build_data(&ctx, &event, item.as_ref()); + for hook in targets { + match template::render(hook, &cache.registry, &data) { + // Delivery is spawned so one slow endpoint cannot stall + // the dispatcher or the hooks behind it. + Ok(Some(body)) => { + tokio::spawn(sender::deliver(hook.clone(), body)); + } + // `skip_empty_message_body` suppressed the delivery. + Ok(None) => {} + Err(e) => { + warn!(webhook = %hook.name, error = %e, "webhook template render failed") + } + } + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use remux_sdks::remux::{ + DiscordMentionType, WebhookDestination, WebhookItemTypes, + }; + use uuid::Uuid; + + const NONE_ENABLED: WebhookItemTypes = WebhookItemTypes { + movies: false, + episodes: false, + series: false, + seasons: false, + albums: false, + songs: false, + videos: false, + }; + + const ALL_ENABLED: WebhookItemTypes = WebhookItemTypes { + movies: true, + episodes: true, + series: true, + seasons: true, + albums: true, + songs: true, + videos: true, + }; + + fn hook( + notification_types: Vec, + user_filter: Vec, + item_types: WebhookItemTypes, + ) -> db::Webhook { + let now = Utc::now(); + db::Webhook { + id: Uuid::from_u128(100), + name: "test".into(), + enabled: true, + url: "https://example.test/hook".into(), + template: "{{ItemName}}".into(), + destination: WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }, + notification_types, + user_filter, + item_types, + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: now, + updated_at: now, + } + } + + /// Subscribes to everything the tests emit, with both other filters wide open. + fn permissive(notification_types: Vec) -> db::Webhook { + hook(notification_types, vec![], ALL_ENABLED) + } + + fn item_added() -> WebhookEvent { + WebhookEvent::ItemAdded { + item_id: Uuid::from_u128(2), + } + } + + fn alice() -> Uuid { + Uuid::from_u128(1) + } + + fn playback_by(user_id: Uuid) -> WebhookEvent { + WebhookEvent::PlaybackStart { + playback: PlaybackEventData { + user: UserEventData { + id: user_id, + username: "alice".into(), + }, + item_id: Uuid::from_u128(2), + device: DeviceEventData { + id: "device-1".into(), + name: "Living Room".into(), + client_name: "Jellyfin Web".into(), + remote_ip: None, + }, + position_ticks: 0, + is_paused: false, + play_method: None, + }, + } + } + + // --- rule 1: notification types ------------------------------------- + + /// Deliberate parity with the Jellyfin webhook plugin: a webhook that + /// subscribes to nothing receives nothing, even with every other filter + /// wide open. + #[test] + fn empty_notification_types_match_nothing() { + let hook = hook(vec![], vec![], ALL_ENABLED); + assert!(!WebhookService::matches(&hook, &item_added(), None)); + assert!(!WebhookService::matches( + &hook, + &item_added(), + Some(&db::MediaKind::Movie) + )); + assert!(!WebhookService::matches(&hook, &playback_by(alice()), None)); + } + + #[test] + fn only_subscribed_notification_types_match() { + let hook = permissive(vec![NotificationType::PlaybackStart]); + assert!( + WebhookService::matches(&hook, &playback_by(alice()), None), + "subscribed type must match" + ); + assert!( + !WebhookService::matches(&hook, &item_added(), None), + "unsubscribed type must not match" + ); + } + + // --- rule 2: user filter -------------------------------------------- + + #[test] + fn empty_user_filter_accepts_any_user() { + let hook = permissive(vec![NotificationType::PlaybackStart]); + assert!(WebhookService::matches( + &hook, + &playback_by(alice()), + None + )); + assert!(WebhookService::matches( + &hook, + &playback_by(Uuid::from_u128(99)), + None + )); + } + + #[test] + fn user_filter_accepts_only_listed_users() { + let hook = hook( + vec![NotificationType::PlaybackStart], + vec![alice(), Uuid::from_u128(7)], + ALL_ENABLED, + ); + assert!( + WebhookService::matches(&hook, &playback_by(alice()), None), + "listed user must match" + ); + assert!( + WebhookService::matches(&hook, &playback_by(Uuid::from_u128(7)), None), + "any listed user must match" + ); + assert!( + !WebhookService::matches(&hook, &playback_by(Uuid::from_u128(99)), None), + "unlisted user must not match" + ); + } + + #[test] + fn user_filter_is_ignored_for_events_without_a_user() { + let hook = hook( + vec![NotificationType::ItemAdded], + vec![alice()], + ALL_ENABLED, + ); + assert!( + WebhookService::matches(&hook, &item_added(), None), + "an event with no user must not be filtered out by user_filter" + ); + } + + // --- rule 3: item types --------------------------------------------- + + /// `(media kind, the single `item_types` flag that gates it)`. + fn item_type_cases() -> Vec<(db::MediaKind, WebhookItemTypes)> { + vec![ + ( + db::MediaKind::Movie, + WebhookItemTypes { + movies: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Episode, + WebhookItemTypes { + episodes: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Series, + WebhookItemTypes { + series: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Season, + WebhookItemTypes { + seasons: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Album, + WebhookItemTypes { + albums: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Track, + WebhookItemTypes { + songs: true, + ..NONE_ENABLED + }, + ), + // Fallthrough: everything not named above is gated by `videos`. + ( + db::MediaKind::Artist, + WebhookItemTypes { + videos: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::Collection, + WebhookItemTypes { + videos: true, + ..NONE_ENABLED + }, + ), + ( + db::MediaKind::TvChannel, + WebhookItemTypes { + videos: true, + ..NONE_ENABLED + }, + ), + ] + } + + fn inverted(types: &WebhookItemTypes) -> WebhookItemTypes { + WebhookItemTypes { + movies: !types.movies, + episodes: !types.episodes, + series: !types.series, + seasons: !types.seasons, + albums: !types.albums, + songs: !types.songs, + videos: !types.videos, + } + } + + /// Each kind is gated by exactly one flag: enabling only that flag matches, + /// and disabling only that flag (every other flag on) does not. Together + /// these pin the mapping — a kind wired to the wrong flag fails both halves. + #[test] + fn each_media_kind_is_gated_by_its_own_flag() { + for (kind, only_this) in item_type_cases() { + let enabled = permissive(vec![NotificationType::ItemAdded]); + let enabled = db::Webhook { + item_types: only_this.clone(), + ..enabled + }; + assert!( + WebhookService::matches(&enabled, &item_added(), Some(&kind)), + "{kind:?} must match when only its own flag is enabled ({only_this:?})" + ); + + let all_but_this = inverted(&only_this); + let disabled = db::Webhook { + item_types: all_but_this.clone(), + ..permissive(vec![NotificationType::ItemAdded]) + }; + assert!( + !WebhookService::matches(&disabled, &item_added(), Some(&kind)), + "{kind:?} must not match when only its own flag is disabled ({all_but_this:?})" + ); + } + } + + #[test] + fn item_type_flags_are_ignored_when_the_event_has_no_item() { + let hook = hook(vec![NotificationType::ItemAdded], vec![], NONE_ENABLED); + assert!( + WebhookService::matches(&hook, &item_added(), None), + "with no item kind in hand, item_types must not gate the event" + ); + } + + // --- the three rules are ANDed --------------------------------------- + + #[test] + fn all_three_rules_must_pass() { + let base = hook( + vec![NotificationType::PlaybackStart], + vec![alice()], + WebhookItemTypes { + movies: true, + ..NONE_ENABLED + }, + ); + let event = playback_by(alice()); + assert!(WebhookService::matches( + &base, + &event, + Some(&db::MediaKind::Movie) + )); + + // Break exactly one rule at a time. + assert!(!WebhookService::matches( + &db::Webhook { + notification_types: vec![NotificationType::PlaybackStop], + ..base.clone() + }, + &event, + Some(&db::MediaKind::Movie) + )); + assert!(!WebhookService::matches( + &db::Webhook { + user_filter: vec![Uuid::from_u128(99)], + ..base.clone() + }, + &event, + Some(&db::MediaKind::Movie) + )); + assert!(!WebhookService::matches( + &base, + &event, + Some(&db::MediaKind::Episode) + )); + } +} diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs new file mode 100644 index 000000000..a10d504d6 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -0,0 +1,39 @@ +//! STUB — filled in by task 4 (payload building). +//! +//! Only the shapes the dispatcher needs exist here. No enrichment is performed +//! yet, so no webhook currently sees item data. + +use super::events::WebhookEvent; +use crate::{AppContext, db}; +use serde_json::{Map, Value}; + +/// The library item an event is about, resolved once per event. +pub(crate) struct ItemContext { + pub media: db::Media, +} + +/// TODO(task 4): resolve the item (and its parents) for item-scoped events. +pub(crate) async fn enrich_item( + _ctx: &AppContext, + _event: &WebhookEvent, +) -> Option { + None +} + +/// TODO(task 4): the full Jellyfin-plugin variable set. +pub(crate) fn build_data( + _ctx: &AppContext, + event: &WebhookEvent, + _item: Option<&ItemContext>, +) -> Map { + let mut data = Map::new(); + data.insert( + "NotificationType".to_string(), + Value::String( + event + .notification_type() + .to_string(), + ), + ); + data +} diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs new file mode 100644 index 000000000..8c0d69bf4 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -0,0 +1,23 @@ +//! STUB — filled in by task 5 (HTTP delivery). +//! +//! A plain JSON POST: no custom headers, no destination-specific envelope, no +//! timeout or retry policy yet. + +use crate::db; +use tracing::{debug, warn}; + +/// TODO(task 5): destination headers/envelope, timeout, retries, test support. +pub(crate) async fn deliver(hook: db::Webhook, body: String) { + let result = reqwest::Client::new() + .post(&hook.url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await; + match result { + Ok(response) => { + debug!(webhook = %hook.name, status = %response.status().as_u16(), "webhook delivered") + } + Err(e) => warn!(webhook = %hook.name, error = %e, "webhook delivery failed"), + } +} diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs new file mode 100644 index 000000000..5cf24ea0e --- /dev/null +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -0,0 +1,20 @@ +//! STUB — filled in by task 4 (Handlebars rendering). +//! +//! Renders the raw template with no helpers, no whitespace trimming and no +//! `skip_empty_message_body` handling (which is what `Ok(None)` will mean). + +use crate::db; +use handlebars::Handlebars; +use serde_json::{Map, Value}; + +/// TODO(task 4): register helpers, honour `trim_whitespace` and +/// `skip_empty_message_body`, and shape the body per destination. +pub(crate) fn render( + hook: &db::Webhook, + registry: &Handlebars<'static>, + data: &Map, +) -> anyhow::Result> { + Ok(Some( + registry.render_template(&hook.template, data)?, + )) +} From 113e33fdd5ac20638c5fcdadd5372400f3719deb Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 10:41:21 +0200 Subject: [PATCH 07/29] fix(server): subscribe before spawning the webhook dispatcher spawn_dispatcher called tx.subscribe() inside the spawned task. tokio::spawn only queues the task, and a broadcast channel discards sends made while it has no subscriber, so every event emitted between the spawn in init_app and the task's first poll was dropped. run_startup_tasks runs immediately after that spawn and is the path that will emit ItemAdded, so this would have turned into silently lost startup events. Create the receiver before the spawn and move it into the task. Use PlayMethod for PlaybackEventData.play_method instead of Option. The enum already exists in remux-sdks with EnumString and Display, and it is the value already in hand at the emission site, so a typo can no longer reach a customer webhook unnoticed. Disable handlebars' default features. Its only default is preserve_json_order, which turns on serde_json/preserve_order for the whole build graph and changes serde_json::Map from sorted to insertion order everywhere. Under resolver 3 that also made map semantics depend on whether the build was driven from the workspace or from the dashboard crate. Nothing needed here is feature-gated, and indexmap drops back out of serde_json in the lock file. Record the cache invariant on reload: the dispatcher task is the only writer, so holding the read guard across enrich_item cannot deadlock. Also apply cargo fmt, which the previous commit left dirty in four places. --- Cargo.lock | 1 - crates/remux-server/Cargo.toml | 6 +++- .../src/services/webhooks/events.rs | 6 ++-- .../remux-server/src/services/webhooks/mod.rs | 35 ++++++++++++------- .../src/services/webhooks/template.rs | 4 +-- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a63cab592..860b53cda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7296,7 +7296,6 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", "itoa", "memchr", "serde", diff --git a/crates/remux-server/Cargo.toml b/crates/remux-server/Cargo.toml index 4f57d6f5c..ddecbb038 100644 --- a/crates/remux-server/Cargo.toml +++ b/crates/remux-server/Cargo.toml @@ -131,7 +131,11 @@ tikv-jemallocator = { version = "0.7", optional = true } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } imageproc = { version = "0.26", default-features = false, features = ["text"] } ab_glyph = "0.2" -handlebars = "6" +# `preserve_json_order` (the only default feature) turns on +# `serde_json/preserve_order` for the whole build graph, which would change +# `serde_json::Map` semantics crate-wide — and differ between `cargo build +# --workspace` and a `dx build` from the dashboard crate under resolver 3. +handlebars = { version = "6", default-features = false } [features] default = ["jemalloc"] jemalloc = ["tikv-jemallocator"] diff --git a/crates/remux-server/src/services/webhooks/events.rs b/crates/remux-server/src/services/webhooks/events.rs index 89c66b7b7..b81cbbf7a 100644 --- a/crates/remux-server/src/services/webhooks/events.rs +++ b/crates/remux-server/src/services/webhooks/events.rs @@ -5,7 +5,7 @@ //! resolved later, off the hot path, by the dispatcher. use crate::db; -use remux_sdks::remux::NotificationType; +use remux_sdks::remux::{NotificationType, PlayMethod}; use uuid::Uuid; /// The user a webhook event is attributed to. @@ -32,7 +32,7 @@ pub struct PlaybackEventData { pub device: DeviceEventData, pub position_ticks: i64, pub is_paused: bool, - pub play_method: Option, + pub play_method: Option, } /// Why a `UserDataSaved` event was raised. @@ -210,7 +210,7 @@ mod tests { device: device(), position_ticks: 123, is_paused: false, - play_method: Some("DirectStream".into()), + play_method: Some(PlayMethod::DirectStream), } } diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index 2eed5700a..1f7d40de8 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -90,6 +90,12 @@ impl WebhookService { /// Replace the cached snapshot from the database. On error the previous /// snapshot is kept — a transient DB failure must not silently disable /// every webhook. + /// + /// Invariant: this is the only writer of `cache`, and it is only ever + /// called from the dispatcher task itself, at a point where that task + /// holds no read guard. That is what makes it safe for the dispatcher to + /// hold the read guard across `enrich_item().await` — no other task can be + /// waiting for the write lock. async fn reload(&self, db: &sqlx::SqlitePool) { let hooks = match db::Webhook::get_enabled(db).await { Ok(hooks) => hooks, @@ -166,12 +172,18 @@ impl WebhookService { true } - /// Run the dispatcher until the channel closes. Owns the only receiver. + /// Run the dispatcher for the lifetime of the process. Owns the only + /// receiver. + /// + /// The receiver is created here rather than inside the task: a broadcast + /// channel drops sends that happen while it has no subscriber, and + /// `init_app` starts emitting (library scan, startup tasks) before the + /// spawned task gets its first poll. pub fn spawn_dispatcher(self, ctx: AppContext) -> JoinHandle<()> { + let mut rx = self + .tx + .subscribe(); tokio::spawn(async move { - let mut rx = self - .tx - .subscribe(); self.reload(&ctx.db) .await; @@ -212,7 +224,10 @@ impl WebhookService { let item = payload::enrich_item(&ctx, &event).await; let item_kind = item .as_ref() - .map(|i| &i.media.kind); + .map(|i| { + &i.media + .kind + }); let targets: Vec<&db::Webhook> = cache .hooks .iter() @@ -246,9 +261,7 @@ impl WebhookService { mod tests { use super::*; use chrono::Utc; - use remux_sdks::remux::{ - DiscordMentionType, WebhookDestination, WebhookItemTypes, - }; + use remux_sdks::remux::{DiscordMentionType, WebhookDestination, WebhookItemTypes}; use uuid::Uuid; const NONE_ENABLED: WebhookItemTypes = WebhookItemTypes { @@ -371,11 +384,7 @@ mod tests { #[test] fn empty_user_filter_accepts_any_user() { let hook = permissive(vec![NotificationType::PlaybackStart]); - assert!(WebhookService::matches( - &hook, - &playback_by(alice()), - None - )); + assert!(WebhookService::matches(&hook, &playback_by(alice()), None)); assert!(WebhookService::matches( &hook, &playback_by(Uuid::from_u128(99)), diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index 5cf24ea0e..5ad387323 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -14,7 +14,5 @@ pub(crate) fn render( registry: &Handlebars<'static>, data: &Map, ) -> anyhow::Result> { - Ok(Some( - registry.render_template(&hook.template, data)?, - )) + Ok(Some(registry.render_template(&hook.template, data)?)) } From fa27d1f023569fbaa032afbf7ea853196309aaf4 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 13:56:49 +0200 Subject: [PATCH 08/29] feat(server): webhook payload variables and handlebars templates --- crates/remux-server/src/db/media.rs | 108 ++ .../remux-server/src/services/webhooks/mod.rs | 42 +- .../src/services/webhooks/payload.rs | 1167 ++++++++++++++++- .../src/services/webhooks/template.rs | 564 +++++++- 4 files changed, 1855 insertions(+), 26 deletions(-) diff --git a/crates/remux-server/src/db/media.rs b/crates/remux-server/src/db/media.rs index e7f6e26d1..3514cad3c 100644 --- a/crates/remux-server/src/db/media.rs +++ b/crates/remux-server/src/db/media.rs @@ -2474,6 +2474,21 @@ impl Media { .await?) } + /// Genre titles linked to `id` through `media_relations`, both `Genre` and + /// `MusicGenre` rows, in relation order. + pub async fn genre_names(db: &SqlitePool, id: &Uuid) -> Result> { + let names = sqlx::query_scalar::<_, String>( + "SELECT g.title FROM media_relations mr \ + JOIN media g ON g.id = mr.right_media_id \ + WHERE mr.left_media_id = ? AND g.kind IN ('genre', 'music_genre') \ + ORDER BY COALESCE(mr.weight, 0) ASC, g.title ASC", + ) + .bind(id) + .fetch_all(db) + .await?; + Ok(names) + } + pub async fn get_by_id( db: &SqlitePool, id: &Uuid, @@ -7256,6 +7271,99 @@ mod tests { ); } + /// `genre_names` walks `media_relations` and keeps only genre rows, in + /// relation order. Other related rows (cast, studios…) must not leak in. + #[tokio::test] + async fn genre_names_reads_genre_rows_through_media_relations() { + let db = crate::db::connect("sqlite::memory:", 10_000) + .await + .unwrap(); + crate::db::migrate(&db) + .await + .unwrap(); + + // `save` requires a movie to carry an imdb id and an id derived from it. + let movie_ids = ExternalIds { + imdb: Some(NonEmptyString::try_new("tt9000001".to_string()).unwrap()), + ..Default::default() + }; + let movie_id = uuid::Uuid::from(&MediaIdRaw { + kind: MediaKind::Movie, + external_ids: movie_ids.clone(), + season: None, + episode: None, + }); + let mut rows = vec![ + Media { + id: movie_id, + title: "A Movie".to_string(), + kind: MediaKind::Movie, + external_ids: movie_ids, + ..Default::default() + }, + Media { + id: uuid::Uuid::from_u128(2), + title: "Drama".to_string(), + kind: MediaKind::Genre, + ..Default::default() + }, + Media { + id: uuid::Uuid::from_u128(3), + title: "Electro".to_string(), + kind: MediaKind::MusicGenre, + ..Default::default() + }, + Media { + id: uuid::Uuid::from_u128(4), + title: "Someone".to_string(), + kind: MediaKind::Person, + ..Default::default() + }, + ]; + for row in rows.iter_mut() { + row.save(&db) + .await + .unwrap(); + } + let (movie, drama, electro, person) = (&rows[0], &rows[1], &rows[2], &rows[3]); + + // Weights are deliberately out of title order: the relation order wins. + let relations = [ + (electro, 0, None), + (drama, 1, None), + (person, 2, Some(RelationRole::Actor)), + ] + .into_iter() + .enumerate() + .map(|(i, (right, weight, role))| MediaRelation { + relation_id: uuid::Uuid::from_u128(i as u128 + 100), + left_media_id: movie.id, + right_media_id: right.id, + weight: Some(weight), + role, + character: None, + }) + .collect::>(); + MediaRelation::upsert(&db, &relations) + .await + .unwrap(); + + assert_eq!( + Media::genre_names(&db, &movie.id) + .await + .unwrap(), + vec!["Electro".to_string(), "Drama".to_string()], + "both genre kinds, in relation order, and nothing else" + ); + // Relations are directional: the genre row itself has none. + assert!( + Media::genre_names(&db, &drama.id) + .await + .unwrap() + .is_empty() + ); + } + /// Verifies push_release_date_filter hides movies with a recent theatrical date /// but no digital release date, while still showing movies with an old theatrical /// date (>1 year) or an explicit digital release date. diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index 1f7d40de8..fb51b7b2b 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -36,15 +36,27 @@ const EVENT_CHANNEL_CAPACITY: usize = 4096; /// The enabled webhooks as last read from the database, plus everything derived /// from them that would otherwise be recomputed per event. -#[derive(Default)] pub(crate) struct LoadedWebhooks { pub hooks: Vec, - /// Template registry. Helpers and partials are registered in task 4. + /// Every hook's template, pre-compiled under its id, plus the custom helpers. pub registry: handlebars::Handlebars<'static>, /// Union of every enabled hook's subscriptions — the dispatcher fast-path. pub wanted: HashSet, } +/// The empty snapshot the dispatcher starts from. It must carry the helpers +/// too: a hook whose template uses one would otherwise fail to render until the +/// first reload. +impl Default for LoadedWebhooks { + fn default() -> Self { + Self { + hooks: Vec::new(), + registry: template::fresh_registry(), + wanted: HashSet::new(), + } + } +} + struct Inner { /// Set by the webhook CRUD endpoints; consumed by the dispatcher. dirty: AtomicBool, @@ -118,8 +130,8 @@ impl WebhookService { .write() .await; *cache = LoadedWebhooks { + registry: template::build_registry(&hooks), hooks, - registry: handlebars::Handlebars::new(), wanted, }; } @@ -186,6 +198,8 @@ impl WebhookService { tokio::spawn(async move { self.reload(&ctx.db) .await; + // Read once: every event of this process reports the same server. + let server = payload::ServerInfo::load(&ctx).await; loop { let event = match rx @@ -237,7 +251,9 @@ impl WebhookService { continue; } - let data = payload::build_data(&ctx, &event, item.as_ref()); + // Built once per event; `render` applies the per-hook overlay + // (a Generic destination's operator-defined fields). + let data = payload::build_data(&server, &event, item.as_ref()); for hook in targets { match template::render(hook, &cache.registry, &data) { // Delivery is spawned so one slow endpoint cannot stall @@ -349,6 +365,24 @@ mod tests { } } + // --- cached snapshot ------------------------------------------------- + + /// The registry is built in two places (here and in `reload`). Both must + /// carry the custom helpers, or every template using one breaks until — or + /// from — the first `invalidate()`. + #[test] + fn the_default_snapshot_registry_carries_the_custom_helpers() { + let snapshot = LoadedWebhooks::default(); + let body = snapshot + .registry + .render_template( + "{{#if_equals A \"a\"}}ok{{/if_equals}}", + &serde_json::json!({ "A": "A" }), + ) + .expect("helpers must be registered on the default snapshot"); + assert_eq!(body, "ok"); + } + // --- rule 1: notification types ------------------------------------- /// Deliberate parity with the Jellyfin webhook plugin: a webhook that diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index a10d504d6..7dfe4af50 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -1,39 +1,1176 @@ -//! STUB — filled in by task 4 (payload building). +//! The variable dictionary handed to webhook templates. //! -//! Only the shapes the dispatcher needs exist here. No enrichment is performed -//! yet, so no webhook currently sees item data. +//! Key names are the ones the Jellyfin webhook plugin uses, verbatim: they are +//! the public surface every operator template is written against, so a renamed +//! key is a silent breaking change. +//! +//! [`build_data`] is deliberately pure and synchronous. Everything it needs +//! that lives in the database is resolved beforehand by [`enrich_item`] and +//! [`ServerInfo::load`], both of which run once per event (resp. once per +//! process) rather than once per webhook. -use super::events::WebhookEvent; +use super::events::{DeviceEventData, PlaybackEventData, UserEventData, WebhookEvent}; use crate::{AppContext, db}; +use remux_sdks::remux::{MediaStream, MediaStreamType, WebhookDestination}; use serde_json::{Map, Value}; +use std::borrow::Cow; +use tracing::warn; +use uuid::Uuid; + +/// Jellyfin ticks in one second (a tick is 100 ns). +const TICKS_PER_SECOND: i64 = 10_000_000; + +/// Fraction of the runtime past which playback counts as completed. +const COMPLETION_RATIO: f64 = 0.9; + +/// Name used when the server has none configured. +const DEFAULT_SERVER_NAME: &str = "remux"; + +/// The `ItemType` a template sees. Narrower than [`db::MediaKind`] on purpose: +/// it is the Jellyfin `BaseItemKind` subset the webhook plugin emits, and +/// everything that is not one of the named kinds is reported as `Video`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::Display)] +pub(crate) enum ItemType { + Movie, + Episode, + Series, + Season, + MusicAlbum, + Audio, + Video, +} + +impl From<&db::MediaKind> for ItemType { + fn from(kind: &db::MediaKind) -> Self { + match kind { + db::MediaKind::Movie => Self::Movie, + db::MediaKind::Episode => Self::Episode, + db::MediaKind::Series => Self::Series, + db::MediaKind::Season => Self::Season, + db::MediaKind::Album => Self::MusicAlbum, + db::MediaKind::Track => Self::Audio, + _ => Self::Video, + } + } +} /// The library item an event is about, resolved once per event. pub(crate) struct ItemContext { pub media: db::Media, + /// Season (episode) or album (track). + pub parent: Option, + /// Series (episode) or artist (track). + pub grandparent: Option, + pub genres: Vec, } -/// TODO(task 4): resolve the item (and its parents) for item-scoped events. +/// The identity of this server, resolved once when the dispatcher starts. +#[derive(Debug, Clone)] +pub(crate) struct ServerInfo { + pub id: String, + pub name: String, + pub version: String, + /// Empty: nothing persists a public base URL for this server yet. + pub url: String, +} + +impl ServerInfo { + pub(crate) async fn load(ctx: &AppContext) -> Self { + let config = db::Settings::get_config_or_default(&ctx.db).await; + let name = config + .server_name + .filter(|name| { + !name + .trim() + .is_empty() + }) + .unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string()); + Self { + id: crate::common::server_id(), + name, + version: env!("CARGO_PKG_VERSION").to_string(), + url: String::new(), + } + } +} + +/// Resolve the item an event is about, plus its parents and genres. +/// +/// `ItemDeleted` carries the row captured before the DELETE, so it never needs +/// the item lookup — the row is already gone. pub(crate) async fn enrich_item( - _ctx: &AppContext, - _event: &WebhookEvent, + ctx: &AppContext, + event: &WebhookEvent, ) -> Option { - None + let media = match event { + WebhookEvent::ItemDeleted { item } => (**item).clone(), + other => load_media(ctx, other.item_id()?).await?, + }; + + let parent = load_optional(ctx, media.parent_id).await; + let grandparent = load_optional(ctx, media.grandparent_id).await; + let genres = match db::Media::genre_names(&ctx.db, &media.id).await { + Ok(genres) => genres, + Err(e) => { + warn!(item = %media.id, error = %e, "failed to load webhook item genres"); + Vec::new() + } + }; + + Some(ItemContext { + media, + parent, + grandparent, + genres, + }) +} + +async fn load_media(ctx: &AppContext, id: Uuid) -> Option { + match db::Media::get_by_id(&ctx.db, &id).await { + Ok(media) => media, + Err(e) => { + warn!(item = %id, error = %e, "failed to load webhook item"); + None + } + } } -/// TODO(task 4): the full Jellyfin-plugin variable set. +async fn load_optional(ctx: &AppContext, id: Option) -> Option { + load_media(ctx, id?).await +} + +/// The variables shared by every webhook this event reaches. pub(crate) fn build_data( - _ctx: &AppContext, + server: &ServerInfo, event: &WebhookEvent, - _item: Option<&ItemContext>, + item: Option<&ItemContext>, ) -> Map { let mut data = Map::new(); + + put(&mut data, "ServerId", &server.id); + put(&mut data, "ServerName", &server.name); + put(&mut data, "ServerVersion", &server.version); + put(&mut data, "ServerUrl", &server.url); + put( + &mut data, + "NotificationType", + event + .notification_type() + .to_string(), + ); + put(&mut data, "Timestamp", chrono::Local::now().to_rfc3339()); + put(&mut data, "UtcTimestamp", chrono::Utc::now().to_rfc3339()); + + if let Some(item) = item { + put_item(&mut data, item); + } + put_event(&mut data, event, item); + + data +} + +/// Per-hook overlay: a `Generic` destination's operator-defined fields are +/// visible to that hook's template. Borrowed — and therefore free — for every +/// hook that defines none. +pub(crate) fn with_hook_fields<'a>( + data: &'a Map, + hook: &db::Webhook, +) -> Cow<'a, Map> { + let fields = match &hook.destination { + WebhookDestination::Generic { fields, .. } if !fields.is_empty() => fields, + _ => return Cow::Borrowed(data), + }; + let mut merged = data.clone(); + for field in fields { + merged.insert( + field + .key + .clone(), + Value::String( + field + .value + .clone(), + ), + ); + } + Cow::Owned(merged) +} + +// --- item ----------------------------------------------------------------- + +fn put_item(data: &mut Map, item: &ItemContext) { + let media = &item.media; + + put(data, "Name", &media.title); + if let Some(overview) = media + .description + .as_deref() + { + put(data, "Overview", overview); + } + put(data, "ItemId", simple_id(&media.id)); + put(data, "ItemType", ItemType::from(&media.kind).to_string()); + + if let Some(seconds) = media.runtime { + data.insert("RunTimeTicks".to_string(), Value::from(ticks(seconds))); + put(data, "RunTime", hms(seconds)); + } + + if let Some(released_at) = media.released_at { + let date = released_at.date(); + data.insert( + "Year".to_string(), + Value::from(chrono::Datelike::year(&date)), + ); + put( + data, + "PremiereDate", + date.format("%Y-%m-%d") + .to_string(), + ); + } + + if !item + .genres + .is_empty() + { + put( + data, + "Genres", + item.genres + .join(", "), + ); + } + + match media.kind { + db::MediaKind::Episode => put_episode(data, item), + db::MediaKind::Track => { + if let Some(album) = item + .parent + .as_ref() + { + put(data, "Album", &album.title); + } + if let Some(artist) = item + .grandparent + .as_ref() + { + put(data, "Artist", &artist.title); + } + } + _ => {} + } + + put_providers(data, &media.external_ids); + if let Some(probe) = media + .probe_data + .as_ref() + { + put_streams(data, &probe.media_streams); + } +} + +fn put_episode(data: &mut Map, item: &ItemContext) { + if let Some(series) = item + .grandparent + .as_ref() + { + put(data, "SeriesName", &series.title); + put(data, "SeriesId", simple_id(&series.id)); + } + if let Some(season) = item + .parent + .as_ref() + { + put(data, "SeasonId", simple_id(&season.id)); + } + // On an episode row, `parent_idx` is the season number and `idx` the + // episode number. + put_padded_number( + data, + "SeasonNumber", + item.media + .parent_idx, + ); + put_padded_number( + data, + "EpisodeNumber", + item.media + .idx, + ); +} + +/// `SeasonNumber`, `SeasonNumber00` and `SeasonNumber000` (and the episode +/// equivalents): the raw number plus its two zero-padded renderings. +fn put_padded_number(data: &mut Map, key: &str, number: Option) { + let Some(number) = number else { + return; + }; + data.insert(key.to_string(), Value::from(number)); + put(data, &format!("{key}00"), format!("{number:02}")); + put(data, &format!("{key}000"), format!("{number:03}")); +} + +fn put_providers(data: &mut Map, ids: &db::ExternalIds) { + if let Some(imdb) = ids + .imdb + .as_ref() + { + put(data, "Provider_imdb", imdb.to_string()); + } + if let Some(tmdb) = ids.tmdb { + put(data, "Provider_tmdb", tmdb.to_string()); + } + if let Some(tvdb) = ids.tvdb { + put(data, "Provider_tvdb", tvdb.to_string()); + } +} + +/// `Video_0_*`, `Audio_0_*`, `Subtitle_0_*`: the index counts per type, so the +/// first audio track is always `Audio_0` whatever its container stream index. +fn put_streams(data: &mut Map, streams: &[MediaStream]) { + let (mut videos, mut audios, mut subtitles) = (0usize, 0usize, 0usize); + for stream in streams { + match stream.type_ { + Some(MediaStreamType::Video) => { + let prefix = format!("Video_{videos}"); + videos += 1; + put_opt( + data, + &format!("{prefix}_Codec"), + stream + .codec + .as_deref(), + ); + put_opt_i64(data, &format!("{prefix}_Width"), stream.width); + put_opt_i64(data, &format!("{prefix}_Height"), stream.height); + put_opt_i64(data, &format!("{prefix}_Bitrate"), stream.bit_rate); + } + Some(MediaStreamType::Audio) => { + let prefix = format!("Audio_{audios}"); + audios += 1; + put_opt( + data, + &format!("{prefix}_Codec"), + stream + .codec + .as_deref(), + ); + put_opt( + data, + &format!("{prefix}_Language"), + stream + .language + .as_deref(), + ); + put_opt_i64(data, &format!("{prefix}_Channels"), stream.channels); + put_opt_i64(data, &format!("{prefix}_Bitrate"), stream.bit_rate); + } + Some(MediaStreamType::Subtitle) => { + let prefix = format!("Subtitle_{subtitles}"); + subtitles += 1; + put_opt( + data, + &format!("{prefix}_Codec"), + stream + .codec + .as_deref(), + ); + put_opt( + data, + &format!("{prefix}_Language"), + stream + .language + .as_deref(), + ); + put_opt( + data, + &format!("{prefix}_Title"), + stream + .title + .as_deref(), + ); + } + // Embedded images, data and lyric streams have no plugin variables. + _ => {} + } + } +} + +// --- event ---------------------------------------------------------------- + +fn put_event( + data: &mut Map, + event: &WebhookEvent, + item: Option<&ItemContext>, +) { + match event { + WebhookEvent::Generic { title, extra } => { + put(data, "Name", title); + for (key, value) in extra { + put(data, key, value); + } + } + WebhookEvent::PlaybackStart { playback } + | WebhookEvent::PlaybackProgress { playback } + | WebhookEvent::PlaybackStop { playback } => { + put_user(data, &playback.user); + put_device(data, &playback.device); + put_playback(data, playback, item); + } + WebhookEvent::AuthenticationSuccess { user, device } + | WebhookEvent::SessionStart { user, device } => { + put_user(data, user); + put_device(data, device); + } + WebhookEvent::AuthenticationFailure { + username, + remote_ip, + } => { + put(data, "NotificationUsername", username); + put_opt(data, "RemoteIp", remote_ip.as_deref()); + } + WebhookEvent::TaskCompleted { + key, + name, + succeeded, + elapsed_ms, + } => { + put(data, "TaskName", name); + put(data, "TaskKey", key); + data.insert("TaskSucceeded".to_string(), Value::Bool(*succeeded)); + data.insert("TaskElapsedMs".to_string(), Value::from(*elapsed_ms)); + } + WebhookEvent::UserCreated { user } + | WebhookEvent::UserUpdated { user } + | WebhookEvent::UserPasswordChanged { user } => put_user(data, user), + WebhookEvent::UserDeleted { user_id, username } => { + put(data, "NotificationUsername", username); + put(data, "UserId", simple_id(user_id)); + } + WebhookEvent::UserDataSaved { + user, save_reason, .. + } => { + put_user(data, user); + put(data, "SaveReason", save_reason.to_string()); + } + // Everything these two events expose comes from the item itself. + WebhookEvent::ItemAdded { .. } | WebhookEvent::ItemDeleted { .. } => {} + } +} + +fn put_user(data: &mut Map, user: &UserEventData) { + put(data, "NotificationUsername", &user.username); + put(data, "UserId", simple_id(&user.id)); +} + +fn put_device(data: &mut Map, device: &DeviceEventData) { + put(data, "DeviceId", &device.id); + put(data, "DeviceName", &device.name); + put(data, "ClientName", &device.client_name); + put_opt( + data, + "RemoteIp", + device + .remote_ip + .as_deref(), + ); +} + +fn put_playback( + data: &mut Map, + playback: &PlaybackEventData, + item: Option<&ItemContext>, +) { + data.insert( + "PlaybackPositionTicks".to_string(), + Value::from(playback.position_ticks), + ); + put( + data, + "PlaybackPosition", + hms(playback.position_ticks / TICKS_PER_SECOND), + ); + data.insert("IsPaused".to_string(), Value::Bool(playback.is_paused)); + if let Some(method) = &playback.play_method { + put(data, "PlayMethod", method.to_string()); + } data.insert( - "NotificationType".to_string(), + "PlayedToCompletion".to_string(), + Value::Bool(played_to_completion(playback.position_ticks, item)), + ); +} + +/// Playback counts as completed at 90 % of the runtime. Without a known +/// runtime there is nothing to compare against, so it never completes. +fn played_to_completion(position_ticks: i64, item: Option<&ItemContext>) -> bool { + let Some(runtime) = item + .and_then(|item| { + item.media + .runtime + }) + .filter(|seconds| *seconds > 0) + else { + return false; + }; + position_ticks as f64 >= ticks(runtime) as f64 * COMPLETION_RATIO +} + +// --- primitives ----------------------------------------------------------- + +/// Seconds as ticks. Saturating: a nonsense runtime read out of the database +/// must not overflow (and panic in a debug build) inside the dispatcher. +fn ticks(seconds: i64) -> i64 { + seconds.saturating_mul(TICKS_PER_SECOND) +} + +/// Jellyfin ids carry no dashes. +fn simple_id(id: &Uuid) -> String { + id.simple() + .to_string() +} + +fn hms(total_seconds: i64) -> String { + let seconds = total_seconds.max(0); + format!( + "{:02}:{:02}:{:02}", + seconds / 3600, + (seconds % 3600) / 60, + seconds % 60 + ) +} + +fn put(data: &mut Map, key: &str, value: impl AsRef) { + data.insert( + key.to_string(), Value::String( - event - .notification_type() + value + .as_ref() .to_string(), ), ); - data +} + +fn put_opt(data: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value { + put(data, key, value); + } +} + +fn put_opt_i64(data: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + data.insert(key.to_string(), Value::from(value)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::webhooks::events::UserDataSaveReason; + use remux_sdks::remux::{ + DiscordMentionType, MediaSourceInfo, MediaStream, MediaStreamType, + NotificationType, PlayMethod, WebhookItemTypes, WebhookKeyValue, + }; + use uuid::Uuid; + + fn server() -> ServerInfo { + ServerInfo { + id: "server-abc".into(), + name: "My Server".into(), + version: "1.2.3".into(), + url: "https://media.example.test".into(), + } + } + + const SERIES_ID: u128 = 10; + const SEASON_ID: u128 = 11; + const EPISODE_ID: u128 = 12; + + /// 01:30:45. + const RUNTIME_SECONDS: i64 = 5445; + + fn probe() -> MediaSourceInfo { + MediaSourceInfo { + container: Some("mkv".into()), + media_streams: vec![ + MediaStream { + index: 0, + type_: Some(MediaStreamType::Video), + codec: Some("h264".into()), + width: Some(1920), + height: Some(1080), + bit_rate: Some(8_000_000), + ..Default::default() + }, + MediaStream { + index: 1, + type_: Some(MediaStreamType::Audio), + codec: Some("aac".into()), + language: Some("eng".into()), + channels: Some(6), + bit_rate: Some(640_000), + ..Default::default() + }, + MediaStream { + index: 2, + type_: Some(MediaStreamType::Audio), + codec: Some("ac3".into()), + language: Some("fra".into()), + channels: Some(2), + bit_rate: Some(192_000), + ..Default::default() + }, + MediaStream { + index: 3, + type_: Some(MediaStreamType::Subtitle), + codec: Some("subrip".into()), + language: Some("eng".into()), + title: Some("English (SDH)".into()), + ..Default::default() + }, + ], + ..Default::default() + } + } + + /// S02E05 of a series, with runtime, release date, provider ids and streams. + fn episode() -> ItemContext { + ItemContext { + media: db::Media { + id: Uuid::from_u128(EPISODE_ID), + kind: db::MediaKind::Episode, + title: "The One With The Test".into(), + description: Some("An episode overview.".into()), + runtime: Some(RUNTIME_SECONDS), + released_at: Some( + chrono::NaiveDate::from_ymd_opt(2021, 3, 4) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ), + idx: Some(5), + parent_idx: Some(2), + parent_id: Some(Uuid::from_u128(SEASON_ID)), + grandparent_id: Some(Uuid::from_u128(SERIES_ID)), + external_ids: db::ExternalIds { + imdb: Some( + db::NonEmptyString::try_new("tt1234567".to_string()).unwrap(), + ), + tmdb: Some(42), + tvdb: Some(7), + ..Default::default() + }, + probe_data: Some(probe()), + ..Default::default() + }, + parent: Some(db::Media { + id: Uuid::from_u128(SEASON_ID), + kind: db::MediaKind::Season, + title: "Season 2".into(), + idx: Some(2), + ..Default::default() + }), + grandparent: Some(db::Media { + id: Uuid::from_u128(SERIES_ID), + kind: db::MediaKind::Series, + title: "Test Show".into(), + ..Default::default() + }), + genres: vec!["Drama".into(), "Sci-Fi".into()], + } + } + + fn movie() -> ItemContext { + ItemContext { + media: db::Media { + id: Uuid::from_u128(20), + kind: db::MediaKind::Movie, + title: "A Movie".into(), + runtime: Some(RUNTIME_SECONDS), + ..Default::default() + }, + parent: None, + grandparent: None, + genres: vec![], + } + } + + fn item_added() -> WebhookEvent { + WebhookEvent::ItemAdded { + item_id: Uuid::from_u128(EPISODE_ID), + } + } + + fn user() -> UserEventData { + UserEventData { + id: Uuid::from_u128(1), + username: "alice".into(), + } + } + + fn device() -> DeviceEventData { + DeviceEventData { + id: "device-1".into(), + name: "Living Room".into(), + client_name: "Jellyfin Web".into(), + remote_ip: Some("10.0.0.2".into()), + } + } + + fn playback(position_ticks: i64) -> WebhookEvent { + WebhookEvent::PlaybackStart { + playback: PlaybackEventData { + user: user(), + item_id: Uuid::from_u128(EPISODE_ID), + device: device(), + position_ticks, + is_paused: true, + play_method: Some(PlayMethod::DirectStream), + }, + } + } + + fn str_at(data: &Map, key: &str) -> String { + data.get(key) + .unwrap_or_else(|| panic!("missing key {key}: got {:?}", data.keys())) + .as_str() + .unwrap_or_else(|| panic!("key {key} is not a string: {:?}", data[key])) + .to_string() + } + + fn hook(destination: WebhookDestination) -> db::Webhook { + let now = chrono::Utc::now(); + db::Webhook { + id: Uuid::from_u128(100), + name: "test".into(), + enabled: true, + url: "https://example.test/hook".into(), + template: "{{Name}}".into(), + destination, + notification_types: vec![NotificationType::ItemAdded], + user_filter: vec![], + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: now, + updated_at: now, + } + } + + fn discord() -> db::Webhook { + hook(WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }) + } + + // --- common variables ------------------------------------------------- + + #[test] + fn common_variables_use_the_plugin_key_names() { + let data = build_data(&server(), &item_added(), None); + assert_eq!(str_at(&data, "ServerId"), "server-abc"); + assert_eq!(str_at(&data, "ServerName"), "My Server"); + assert_eq!(str_at(&data, "ServerVersion"), "1.2.3"); + assert_eq!(str_at(&data, "ServerUrl"), "https://media.example.test"); + assert_eq!(str_at(&data, "NotificationType"), "ItemAdded"); + + for key in ["Timestamp", "UtcTimestamp"] { + let raw = str_at(&data, key); + chrono::DateTime::parse_from_rfc3339(&raw) + .unwrap_or_else(|e| panic!("{key} = {raw:?} is not RFC3339: {e}")); + } + } + + // --- item variables --------------------------------------------------- + + #[test] + fn item_variables_use_the_plugin_key_names() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(str_at(&data, "Name"), "The One With The Test"); + assert_eq!(str_at(&data, "Overview"), "An episode overview."); + assert_eq!(str_at(&data, "ItemType"), "Episode"); + assert_eq!(str_at(&data, "Genres"), "Drama, Sci-Fi"); + assert_eq!(data["Year"], Value::from(2021)); + assert_eq!(str_at(&data, "PremiereDate"), "2021-03-04"); + } + + /// Jellyfin ids carry no dashes. + #[test] + fn item_id_is_the_dashless_uuid() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + let id = str_at(&data, "ItemId"); + assert_eq!( + id, + Uuid::from_u128(EPISODE_ID) + .simple() + .to_string() + ); + assert!(!id.contains('-'), "ItemId must not contain dashes: {id}"); + } + + #[test] + fn item_type_maps_every_media_kind() { + let cases = [ + (db::MediaKind::Movie, "Movie"), + (db::MediaKind::Episode, "Episode"), + (db::MediaKind::Series, "Series"), + (db::MediaKind::Season, "Season"), + (db::MediaKind::Album, "MusicAlbum"), + (db::MediaKind::Track, "Audio"), + (db::MediaKind::Stream, "Video"), + (db::MediaKind::TvChannel, "Video"), + ]; + for (kind, expected) in cases { + let item = ItemContext { + media: db::Media { + kind: kind.clone(), + ..movie().media + }, + ..movie() + }; + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!( + str_at(&data, "ItemType"), + expected, + "wrong ItemType for {kind:?}" + ); + } + } + + #[test] + fn runtime_is_exposed_as_ticks_and_hms() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!( + data["RunTimeTicks"], + Value::from(RUNTIME_SECONDS * TICKS_PER_SECOND) + ); + assert_eq!(data["RunTimeTicks"], Value::from(54_450_000_000i64)); + assert_eq!(str_at(&data, "RunTime"), "01:30:45"); + } + + #[test] + fn runtime_variables_are_absent_without_a_runtime() { + let item = ItemContext { + media: db::Media { + runtime: None, + ..movie().media + }, + ..movie() + }; + let data = build_data(&server(), &item_added(), Some(&item)); + assert!(!data.contains_key("RunTime")); + assert!(!data.contains_key("RunTimeTicks")); + } + + // --- episode variables ------------------------------------------------ + + #[test] + fn episode_numbers_are_zero_padded() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(data["SeasonNumber"], Value::from(2)); + assert_eq!(str_at(&data, "SeasonNumber00"), "02"); + assert_eq!(str_at(&data, "SeasonNumber000"), "002"); + assert_eq!(data["EpisodeNumber"], Value::from(5)); + assert_eq!(str_at(&data, "EpisodeNumber00"), "05"); + assert_eq!(str_at(&data, "EpisodeNumber000"), "005"); + } + + #[test] + fn episode_links_series_and_season() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(str_at(&data, "SeriesName"), "Test Show"); + assert_eq!( + str_at(&data, "SeriesId"), + Uuid::from_u128(SERIES_ID) + .simple() + .to_string() + ); + assert_eq!( + str_at(&data, "SeasonId"), + Uuid::from_u128(SEASON_ID) + .simple() + .to_string() + ); + } + + #[test] + fn track_exposes_album_and_artist() { + let item = ItemContext { + media: db::Media { + id: Uuid::from_u128(30), + kind: db::MediaKind::Track, + title: "A Song".into(), + ..Default::default() + }, + parent: Some(db::Media { + kind: db::MediaKind::Album, + title: "An Album".into(), + ..Default::default() + }), + grandparent: Some(db::Media { + kind: db::MediaKind::Artist, + title: "An Artist".into(), + ..Default::default() + }), + genres: vec![], + }; + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(str_at(&data, "Album"), "An Album"); + assert_eq!(str_at(&data, "Artist"), "An Artist"); + } + + // --- providers -------------------------------------------------------- + + #[test] + fn provider_ids_are_exposed() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(str_at(&data, "Provider_imdb"), "tt1234567"); + assert_eq!(str_at(&data, "Provider_tmdb"), "42"); + assert_eq!(str_at(&data, "Provider_tvdb"), "7"); + } + + #[test] + fn absent_provider_ids_produce_no_keys() { + let item = movie(); + let data = build_data(&server(), &item_added(), Some(&item)); + for key in ["Provider_imdb", "Provider_tmdb", "Provider_tvdb"] { + assert!(!data.contains_key(key), "{key} must be absent"); + } + } + + // --- streams ---------------------------------------------------------- + + #[test] + fn stream_variables_are_indexed_per_type() { + let item = episode(); + let data = build_data(&server(), &item_added(), Some(&item)); + + assert_eq!(str_at(&data, "Video_0_Codec"), "h264"); + assert_eq!(data["Video_0_Width"], Value::from(1920)); + assert_eq!(data["Video_0_Height"], Value::from(1080)); + assert_eq!(data["Video_0_Bitrate"], Value::from(8_000_000)); + + assert_eq!(str_at(&data, "Audio_0_Codec"), "aac"); + assert_eq!(str_at(&data, "Audio_0_Language"), "eng"); + assert_eq!(data["Audio_0_Channels"], Value::from(6)); + assert_eq!(data["Audio_0_Bitrate"], Value::from(640_000)); + + // The index counts per type, not the raw media stream index: the second + // audio track is Audio_1 even though its stream index is 2. + assert_eq!(str_at(&data, "Audio_1_Codec"), "ac3"); + assert_eq!(str_at(&data, "Audio_1_Language"), "fra"); + assert!( + !data.contains_key("Audio_2_Codec"), + "only two audio tracks exist" + ); + + assert_eq!(str_at(&data, "Subtitle_0_Codec"), "subrip"); + assert_eq!(str_at(&data, "Subtitle_0_Language"), "eng"); + assert_eq!(str_at(&data, "Subtitle_0_Title"), "English (SDH)"); + } + + #[test] + fn stream_variables_are_absent_without_probe_data() { + let item = movie(); + let data = build_data(&server(), &item_added(), Some(&item)); + for key in ["Video_0_Codec", "Audio_0_Codec", "Subtitle_0_Codec"] { + assert!(!data.contains_key(key), "{key} must be absent"); + } + } + + // --- user / device / playback ----------------------------------------- + + #[test] + fn user_variables_use_the_plugin_key_names() { + let data = + build_data(&server(), &WebhookEvent::UserCreated { user: user() }, None); + assert_eq!(str_at(&data, "NotificationUsername"), "alice"); + assert_eq!( + str_at(&data, "UserId"), + Uuid::from_u128(1) + .simple() + .to_string() + ); + } + + #[test] + fn playback_variables_use_the_plugin_key_names() { + let item = episode(); + // 00:10:00 into the episode. + let data = + build_data(&server(), &playback(600 * TICKS_PER_SECOND), Some(&item)); + assert_eq!(str_at(&data, "DeviceId"), "device-1"); + assert_eq!(str_at(&data, "DeviceName"), "Living Room"); + assert_eq!(str_at(&data, "ClientName"), "Jellyfin Web"); + assert_eq!( + data["PlaybackPositionTicks"], + Value::from(600 * TICKS_PER_SECOND) + ); + assert_eq!(str_at(&data, "PlaybackPosition"), "00:10:00"); + assert_eq!(data["IsPaused"], Value::Bool(true)); + assert_eq!(str_at(&data, "PlayMethod"), "DirectStream"); + assert_eq!(str_at(&data, "NotificationUsername"), "alice"); + } + + /// 90 % of the runtime is the threshold, inclusive. + #[test] + fn played_to_completion_flips_at_ninety_percent() { + let item = episode(); + let full = RUNTIME_SECONDS * TICKS_PER_SECOND; + let cases = [ + (0, false), + (full / 2, false), + (full * 89 / 100, false), + (full * 90 / 100, true), + (full, true), + ]; + for (position, expected) in cases { + let data = build_data(&server(), &playback(position), Some(&item)); + assert_eq!( + data["PlayedToCompletion"], + Value::Bool(expected), + "position {position} of {full}" + ); + } + } + + #[test] + fn played_to_completion_is_false_without_a_runtime() { + let item = ItemContext { + media: db::Media { + runtime: None, + ..movie().media + }, + ..movie() + }; + let data = build_data(&server(), &playback(i64::MAX / 2), Some(&item)); + assert_eq!(data["PlayedToCompletion"], Value::Bool(false)); + } + + #[test] + fn playback_variables_are_absent_for_non_playback_events() { + let data = build_data(&server(), &item_added(), Some(&episode())); + for key in [ + "PlaybackPosition", + "PlaybackPositionTicks", + "IsPaused", + "PlayMethod", + "PlayedToCompletion", + "DeviceId", + ] { + assert!(!data.contains_key(key), "{key} must be absent"); + } + } + + // --- task / auth ------------------------------------------------------ + + #[test] + fn task_completed_variables_use_the_plugin_key_names() { + let data = build_data( + &server(), + &WebhookEvent::TaskCompleted { + key: "scan".into(), + name: "Scan library".into(), + succeeded: true, + elapsed_ms: 4242, + }, + None, + ); + assert_eq!(str_at(&data, "TaskName"), "Scan library"); + assert_eq!(str_at(&data, "TaskKey"), "scan"); + assert_eq!(data["TaskSucceeded"], Value::Bool(true)); + assert_eq!(data["TaskElapsedMs"], Value::from(4242)); + } + + #[test] + fn authentication_failure_exposes_username_and_remote_ip() { + let data = build_data( + &server(), + &WebhookEvent::AuthenticationFailure { + username: "mallory".into(), + remote_ip: Some("10.0.0.9".into()), + }, + None, + ); + assert_eq!(str_at(&data, "NotificationUsername"), "mallory"); + assert_eq!(str_at(&data, "RemoteIp"), "10.0.0.9"); + assert!( + !data.contains_key("UserId"), + "a failed login has no user id" + ); + } + + #[test] + fn user_data_saved_exposes_the_save_reason() { + let data = build_data( + &server(), + &WebhookEvent::UserDataSaved { + user: user(), + item_id: Uuid::from_u128(EPISODE_ID), + save_reason: UserDataSaveReason::PlaybackFinished, + }, + None, + ); + assert_eq!(str_at(&data, "SaveReason"), "PlaybackFinished"); + } + + #[test] + fn generic_event_exposes_its_title_and_extra_pairs() { + let data = build_data( + &server(), + &WebhookEvent::Generic { + title: "Something happened".into(), + extra: vec![("Detail".into(), "42".into())], + }, + None, + ); + assert_eq!(str_at(&data, "Name"), "Something happened"); + assert_eq!(str_at(&data, "Detail"), "42"); + } + + // --- per-hook fields -------------------------------------------------- + + #[test] + fn generic_destination_fields_are_merged() { + let base = build_data(&server(), &item_added(), Some(&episode())); + let hook = hook(WebhookDestination::Generic { + headers: vec![], + fields: vec![ + WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }, + WebhookKeyValue { + key: "kind".into(), + value: "alert".into(), + }, + ], + }); + + let merged = with_hook_fields(&base, &hook); + assert_eq!(str_at(&merged, "channel"), "#general"); + assert_eq!(str_at(&merged, "kind"), "alert"); + // The common dictionary survives the overlay. + assert_eq!(str_at(&merged, "Name"), "The One With The Test"); + // …and the overlay does not mutate it. + assert!(!base.contains_key("channel")); + } + + #[test] + fn hook_fields_are_only_merged_for_generic_destinations() { + let base = build_data(&server(), &item_added(), Some(&episode())); + let merged = with_hook_fields(&base, &discord()); + assert_eq!(merged.len(), base.len()); + assert!(matches!(merged, Cow::Borrowed(_)), "no clone is needed"); + } } diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index 5ad387323..c3c484214 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -1,18 +1,568 @@ -//! STUB — filled in by task 4 (Handlebars rendering). +//! Handlebars rendering for webhook bodies. //! -//! Renders the raw template with no helpers, no whitespace trimming and no -//! `skip_empty_message_body` handling (which is what `Ok(None)` will mean). +//! Templates are compiled once per reload and rendered by name afterwards: the +//! registry cached in [`super::LoadedWebhooks`] only pays off if nothing +//! re-parses the template string per event. +//! +//! The five custom helpers mirror the Jellyfin webhook plugin's, so templates +//! written for it keep working. use crate::db; -use handlebars::Handlebars; +use handlebars::{ + Context, Handlebars, Helper, HelperResult, Output, RenderContext, + RenderErrorReason, Renderable, +}; use serde_json::{Map, Value}; +use tracing::warn; + +/// An empty registry that already knows the custom helpers. +/// +/// Both the startup snapshot and every reload go through here — a registry +/// built any other way silently loses the helpers. +pub(crate) fn fresh_registry() -> Handlebars<'static> { + let mut registry = Handlebars::new(); + // Missing variables render as empty rather than failing the whole body: + // most variables are event-specific and templates are user-written. + registry.set_strict_mode(false); + // Bodies are JSON, not HTML. HTML-escaping would turn `Ocean's` into + // `Ocean's` and break `json_encode` output. + registry.register_escape_fn(handlebars::no_escape); + register_helpers(&mut registry); + registry +} + +/// A registry with every hook's template pre-compiled under its id. +/// +/// A hook whose template does not parse is skipped, not fatal: the others must +/// still be delivered. `render` then reports the missing template per event. +pub(crate) fn build_registry(hooks: &[db::Webhook]) -> Handlebars<'static> { + let mut registry = fresh_registry(); + for hook in hooks { + if let Err(e) = registry.register_template_string( + &hook + .id + .to_string(), + &hook.template, + ) { + warn!(webhook = %hook.name, error = %e, "invalid webhook template, hook will not render"); + } + } + registry +} + +pub(crate) fn register_helpers(registry: &mut Handlebars<'_>) { + registry.register_helper("if_equals", Box::new(if_equals)); + registry.register_helper("if_exist", Box::new(if_exist)); + registry.register_helper("link_to", Box::new(link_to)); + registry.register_helper("url_encode", Box::new(url_encode)); + registry.register_helper("json_encode", Box::new(json_encode)); +} -/// TODO(task 4): register helpers, honour `trim_whitespace` and -/// `skip_empty_message_body`, and shape the body per destination. +/// Render `hook`'s body for `data`, or `None` when the hook asked for empty +/// bodies to be dropped. pub(crate) fn render( hook: &db::Webhook, registry: &Handlebars<'static>, data: &Map, ) -> anyhow::Result> { - Ok(Some(registry.render_template(&hook.template, data)?)) + let data = super::payload::with_hook_fields(data, hook); + + let body = if hook.send_all_properties { + // The whole dictionary, template ignored — this is the "show me every + // variable" mode of the plugin. + serde_json::to_string_pretty(data.as_ref())? + } else { + registry.render( + &hook + .id + .to_string(), + data.as_ref(), + )? + }; + + let body = if hook.trim_whitespace { + body.trim() + .to_string() + } else { + body + }; + + if hook.skip_empty_message_body + && body + .trim() + .is_empty() + { + return Ok(None); + } + Ok(Some(body)) +} + +// --- helpers -------------------------------------------------------------- + +/// `{{#if_equals A B}}…{{else}}…{{/if_equals}}` — case-insensitive comparison +/// of the two parameters rendered as strings. +fn if_equals<'reg, 'rc>( + h: &Helper<'rc>, + registry: &'reg Handlebars<'reg>, + ctx: &'rc Context, + rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, +) -> HelperResult { + let lhs = required_param(h, 0, "if_equals")?; + let rhs = required_param(h, 1, "if_equals")?; + let branch = if lhs.eq_ignore_ascii_case(&rhs) { + h.template() + } else { + h.inverse() + }; + if let Some(template) = branch { + template.render(registry, ctx, rc, out)?; + } + Ok(()) +} + +/// `{{#if_exist A}}…{{else}}…{{/if_exist}}` — renders the block when the value +/// is present and not empty. Present-but-falsy values (`0`, `false`) exist. +fn if_exist<'reg, 'rc>( + h: &Helper<'rc>, + registry: &'reg Handlebars<'reg>, + ctx: &'rc Context, + rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, +) -> HelperResult { + let exists = h + .param(0) + .map(|param| param.value()) + .is_some_and(|value| match value { + Value::Null => false, + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + }); + let branch = if exists { h.template() } else { h.inverse() }; + if let Some(template) = branch { + template.render(registry, ctx, rc, out)?; + } + Ok(()) +} + +/// `{{link_to url text}}` → `text`. +fn link_to( + h: &Helper, + _registry: &Handlebars, + _ctx: &Context, + _rc: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let url = required_param(h, 0, "link_to")?; + let text = required_param(h, 1, "link_to")?; + out.write(&format!("{text}"))?; + Ok(()) +} + +/// `{{url_encode value}}` — percent-encoding, for building query strings. +fn url_encode( + h: &Helper, + _registry: &Handlebars, + _ctx: &Context, + _rc: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let value = required_param(h, 0, "url_encode")?; + out.write(&urlencoding::encode(&value))?; + Ok(()) +} + +/// `{{json_encode value}}` — the value as a JSON literal, quotes included for +/// strings. Lets a template interpolate arbitrary text into a JSON body. +fn json_encode( + h: &Helper, + _registry: &Handlebars, + _ctx: &Context, + _rc: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let value = h + .param(0) + .ok_or(RenderErrorReason::ParamNotFoundForIndex("json_encode", 0))? + .value(); + let encoded = serde_json::to_string(value) + .map_err(|e| RenderErrorReason::NestedError(Box::new(e)))?; + out.write(&encoded)?; + Ok(()) +} + +/// A parameter rendered the way a template would render it: strings as-is, +/// everything else as its JSON form, missing values as empty. +fn required_param( + h: &Helper, + index: usize, + helper: &'static str, +) -> Result { + let value = h + .param(index) + .ok_or(RenderErrorReason::ParamNotFoundForIndex(helper, index))? + .value(); + Ok(match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => other.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use remux_sdks::remux::{ + DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, + WebhookKeyValue, + }; + use serde_json::json; + use uuid::Uuid; + + fn hook(template: &str) -> db::Webhook { + let now = chrono::Utc::now(); + db::Webhook { + id: Uuid::from_u128(100), + name: "test".into(), + enabled: true, + url: "https://example.test/hook".into(), + template: template.into(), + destination: WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }, + notification_types: vec![NotificationType::ItemAdded], + user_filter: vec![], + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: now, + updated_at: now, + } + } + + fn data(pairs: Value) -> Map { + pairs + .as_object() + .expect("test data must be an object") + .clone() + } + + /// Renders `template` against `pairs` through the real registry path + /// (pre-compiled, registered under the hook id). + fn render_template(template: &str, pairs: Value) -> String { + let hook = hook(template); + let registry = build_registry(std::slice::from_ref(&hook)); + render(&hook, ®istry, &data(pairs)) + .expect("render must succeed") + .expect("render must produce a body") + } + + // --- if_equals -------------------------------------------------------- + + const IF_EQUALS: &str = + "{{#if_equals ItemType \"episode\"}}yes{{else}}no{{/if_equals}}"; + + #[test] + fn if_equals_ignores_case() { + assert_eq!( + render_template(IF_EQUALS, json!({ "ItemType": "Episode" })), + "yes" + ); + assert_eq!( + render_template(IF_EQUALS, json!({ "ItemType": "EPISODE" })), + "yes" + ); + } + + #[test] + fn if_equals_takes_the_else_branch_on_different_values() { + assert_eq!( + render_template(IF_EQUALS, json!({ "ItemType": "Movie" })), + "no" + ); + // A missing value must not accidentally equal the literal. + assert_eq!(render_template(IF_EQUALS, json!({})), "no"); + } + + #[test] + fn if_equals_compares_non_string_values() { + assert_eq!( + render_template( + "{{#if_equals SeasonNumber 2}}yes{{else}}no{{/if_equals}}", + json!({ "SeasonNumber": 2 }) + ), + "yes" + ); + assert_eq!( + render_template( + "{{#if_equals SeasonNumber 3}}yes{{else}}no{{/if_equals}}", + json!({ "SeasonNumber": 2 }) + ), + "no" + ); + } + + // --- if_exist --------------------------------------------------------- + + const IF_EXIST: &str = "{{#if_exist Overview}}yes{{else}}no{{/if_exist}}"; + + #[test] + fn if_exist_renders_only_for_a_present_non_empty_value() { + assert_eq!( + render_template(IF_EXIST, json!({ "Overview": "some text" })), + "yes" + ); + // Falsy-but-present values still exist. + assert_eq!(render_template(IF_EXIST, json!({ "Overview": 0 })), "yes"); + assert_eq!( + render_template(IF_EXIST, json!({ "Overview": false })), + "yes" + ); + } + + #[test] + fn if_exist_takes_the_else_branch_for_null_empty_and_missing() { + assert_eq!( + render_template(IF_EXIST, json!({ "Overview": Value::Null })), + "no" + ); + assert_eq!(render_template(IF_EXIST, json!({ "Overview": "" })), "no"); + assert_eq!(render_template(IF_EXIST, json!({})), "no"); + } + + // --- link_to / url_encode / json_encode ------------------------------- + + #[test] + fn link_to_emits_an_anchor() { + assert_eq!( + render_template( + "{{link_to ServerUrl Name}}", + json!({ "ServerUrl": "https://example.test/web", "Name": "Open" }) + ), + "Open" + ); + } + + #[test] + fn url_encode_percent_encodes() { + assert_eq!( + render_template( + "{{url_encode Name}}", + json!({ "Name": "Tom & Jerry / S01?" }) + ), + "Tom%20%26%20Jerry%20%2F%20S01%3F" + ); + } + + #[test] + fn json_encode_produces_a_json_literal() { + assert_eq!( + render_template( + "{{json_encode Name}}", + json!({ "Name": "He said \"hi\"" }) + ), + r#""He said \"hi\"""# + ); + assert_eq!( + render_template( + "{{json_encode SeasonNumber}}", + json!({ "SeasonNumber": 2 }) + ), + "2" + ); + } + + /// Bodies are JSON, not HTML: escaping `'` or `&` would corrupt them. + #[test] + fn plain_substitution_is_not_html_escaped() { + assert_eq!( + render_template("{{Name}}", json!({ "Name": "Ocean's 11 & 12" })), + "Ocean's 11 & 12" + ); + } + + // --- hook flags ------------------------------------------------------- + + #[test] + fn send_all_properties_serializes_the_dictionary_and_ignores_the_template() { + let hook = db::Webhook { + send_all_properties: true, + ..hook("this template must not be used") + }; + let registry = build_registry(std::slice::from_ref(&hook)); + let body = render(&hook, ®istry, &data(json!({ "Name": "A Movie" }))) + .unwrap() + .expect("a body must be produced"); + + assert!( + !body.contains("must not be used"), + "template must be bypassed: {body}" + ); + let parsed: Value = + serde_json::from_str(&body).expect("body must be valid JSON"); + assert_eq!(parsed["Name"], json!("A Movie")); + assert!(body.contains('\n'), "pretty-printed JSON expected: {body}"); + } + + #[test] + fn send_all_properties_includes_the_hook_fields() { + let hook = db::Webhook { + send_all_properties: true, + destination: WebhookDestination::Generic { + headers: vec![], + fields: vec![WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }], + }, + ..hook("") + }; + let registry = build_registry(std::slice::from_ref(&hook)); + let body = render(&hook, ®istry, &data(json!({ "Name": "A Movie" }))) + .unwrap() + .unwrap(); + let parsed: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["channel"], json!("#general")); + } + + #[test] + fn generic_destination_fields_are_available_to_the_template() { + let hook = db::Webhook { + destination: WebhookDestination::Generic { + headers: vec![], + fields: vec![WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }], + }, + ..hook("{{Name}} -> {{channel}}") + }; + let registry = build_registry(std::slice::from_ref(&hook)); + let body = render(&hook, ®istry, &data(json!({ "Name": "A Movie" }))) + .unwrap() + .unwrap(); + assert_eq!(body, "A Movie -> #general"); + } + + #[test] + fn trim_whitespace_trims_the_rendered_body() { + let template = "\n {{Name}} \n"; + let untrimmed = render_template(template, json!({ "Name": "A Movie" })); + assert_eq!(untrimmed, template.replace("{{Name}}", "A Movie")); + + let hook = db::Webhook { + trim_whitespace: true, + ..hook(template) + }; + let registry = build_registry(std::slice::from_ref(&hook)); + assert_eq!( + render(&hook, ®istry, &data(json!({ "Name": "A Movie" }))) + .unwrap() + .unwrap(), + "A Movie" + ); + } + + #[test] + fn skip_empty_message_body_suppresses_a_blank_render() { + // The value is missing, so the body renders to whitespace only. + let hook = db::Webhook { + skip_empty_message_body: true, + ..hook(" {{Name}}\n") + }; + let registry = build_registry(std::slice::from_ref(&hook)); + assert_eq!( + render(&hook, ®istry, &data(json!({}))).unwrap(), + None, + "an empty body must be suppressed" + ); + + // A non-empty body is still delivered. + assert_eq!( + render(&hook, ®istry, &data(json!({ "Name": "A Movie" }))) + .unwrap() + .unwrap() + .trim(), + "A Movie" + ); + } + + #[test] + fn an_empty_body_is_delivered_when_the_flag_is_off() { + let hook = hook(" {{Name}}\n"); + let registry = build_registry(std::slice::from_ref(&hook)); + assert_eq!( + render(&hook, ®istry, &data(json!({}))).unwrap(), + Some(" \n".to_string()) + ); + } + + // --- registry wiring -------------------------------------------------- + + /// The whole point of the cached registry: each hook's template is compiled + /// once, at reload time, and rendered by name afterwards. + #[test] + fn build_registry_precompiles_every_hook_template() { + let first = db::Webhook { + id: Uuid::from_u128(1), + ..hook("first: {{Name}}") + }; + let second = db::Webhook { + id: Uuid::from_u128(2), + ..hook("second: {{Name}}") + }; + let registry = build_registry(&[first.clone(), second.clone()]); + + assert!( + registry.has_template( + &first + .id + .to_string() + ), + "templates must be registered under the hook id" + ); + assert_eq!( + render(&first, ®istry, &data(json!({ "Name": "X" }))) + .unwrap() + .unwrap(), + "first: X" + ); + assert_eq!( + render(&second, ®istry, &data(json!({ "Name": "X" }))) + .unwrap() + .unwrap(), + "second: X" + ); + } + + #[test] + fn fresh_registry_carries_the_custom_helpers() { + let registry = fresh_registry(); + // Rendering exercises every helper: an unregistered one is a render error. + let body = registry + .render_template( + "{{#if_equals A \"a\"}}1{{/if_equals}}\ + {{#if_exist A}}2{{/if_exist}}\ + {{link_to A A}}{{url_encode A}}{{json_encode A}}", + &json!({ "A": "a" }), + ) + .expect("every custom helper must be registered on a fresh registry"); + assert_eq!(body, "12aa\"a\""); + } + + #[test] + fn a_broken_template_is_an_error_not_a_panic() { + let hook = hook("{{#if_equals}}oops{{/if_equals}}"); + let registry = build_registry(std::slice::from_ref(&hook)); + assert!( + render(&hook, ®istry, &data(json!({}))).is_err(), + "a helper called without its parameters must surface as an error" + ); + } } From e5f2647e253df6efca6279a806c7c51dc03e9d06 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 14:25:58 +0200 Subject: [PATCH 09/29] fix(server): webhook template escaping, season parity and public_url config --- crates/remux-server/src/lib.rs | 8 + .../src/services/webhooks/payload.rs | 437 +++++++++++++++++- .../src/services/webhooks/template.rs | 124 ++++- 3 files changed, 527 insertions(+), 42 deletions(-) diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index 1c4c7047d..42c17bc04 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -444,6 +444,13 @@ pub struct Config { /// Base URL for remuxdb. When set, probe results are submitted after each live probe. #[serde(default = "default_remuxdb_url")] pub remuxdb_url: Option, + /// Public base URL clients reach this server on, e.g. + /// `https://media.example.com`. Used to build absolute links that leave the + /// server (webhook `ServerUrl`, deep links, image URLs); unset means "no + /// absolute URL is known", and such links are rendered empty rather than + /// guessed. + #[serde(default)] + pub public_url: Option, } fn default_remuxdb_url() -> Option { @@ -518,6 +525,7 @@ impl Default for Config { tmdb_base_url: default_tmdb_base_url(), trakt_base_url: default_trakt_base_url(), remuxdb_url: Some("https://remuxdb.1632022.xyz".to_string()), + public_url: None, } .resolve() } diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index 7dfe4af50..057309486 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -70,7 +70,7 @@ pub(crate) struct ServerInfo { pub id: String, pub name: String, pub version: String, - /// Empty: nothing persists a public base URL for this server yet. + /// `Config::public_url`, or empty when the operator has not set one. pub url: String, } @@ -89,7 +89,14 @@ impl ServerInfo { id: crate::common::server_id(), name, version: env!("CARGO_PKG_VERSION").to_string(), - url: String::new(), + url: ctx + .config + .public_url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .unwrap_or_default() + .to_string(), } } } @@ -211,21 +218,22 @@ fn put_item(data: &mut Map, item: &ItemContext) { put(data, "ItemId", simple_id(&media.id)); put(data, "ItemType", ItemType::from(&media.kind).to_string()); - if let Some(seconds) = media.runtime { - data.insert("RunTimeTicks".to_string(), Value::from(ticks(seconds))); - put(data, "RunTime", hms(seconds)); - } + // Always emitted, zeroed when unknown: imported templates print them + // unconditionally, so an absent key would render as an empty string. + let runtime = media + .runtime + .unwrap_or(0); + data.insert("RunTimeTicks".to_string(), Value::from(ticks(runtime))); + put(data, "RunTime", hms(runtime)); + put_year(data, item); if let Some(released_at) = media.released_at { - let date = released_at.date(); - data.insert( - "Year".to_string(), - Value::from(chrono::Datelike::year(&date)), - ); put( data, "PremiereDate", - date.format("%Y-%m-%d") + released_at + .date() + .format("%Y-%m-%d") .to_string(), ); } @@ -244,6 +252,7 @@ fn put_item(data: &mut Map, item: &ItemContext) { match media.kind { db::MediaKind::Episode => put_episode(data, item), + db::MediaKind::Season => put_season(data, item), db::MediaKind::Track => { if let Some(album) = item .parent @@ -270,14 +279,36 @@ fn put_item(data: &mut Map, item: &ItemContext) { } } -fn put_episode(data: &mut Map, item: &ItemContext) { - if let Some(series) = item - .grandparent - .as_ref() +/// `Year` for an episode or a season is the *series'* production year, as the +/// plugin reads it off `Series.ProductionYear`. Everything else reports its own +/// release year. +fn put_year(data: &mut Map, item: &ItemContext) { + let released_at = match item + .media + .kind { - put(data, "SeriesName", &series.title); - put(data, "SeriesId", simple_id(&series.id)); + db::MediaKind::Episode | db::MediaKind::Season => item + .grandparent + .as_ref() + .and_then(|series| series.released_at) + .or(item + .media + .released_at), + _ => { + item.media + .released_at + } + }; + if let Some(released_at) = released_at { + data.insert( + "Year".to_string(), + Value::from(chrono::Datelike::year(&released_at.date())), + ); } +} + +fn put_episode(data: &mut Map, item: &ItemContext) { + put_series(data, item); if let Some(season) = item .parent .as_ref() @@ -300,6 +331,29 @@ fn put_episode(data: &mut Map, item: &ItemContext) { ); } +/// A season carries the same series keys as an episode — the plugin's stock +/// template has a dedicated season branch that prints `SeriesName` — and its +/// own `idx` is the season number. +fn put_season(data: &mut Map, item: &ItemContext) { + put_series(data, item); + put_padded_number( + data, + "SeasonNumber", + item.media + .idx, + ); +} + +fn put_series(data: &mut Map, item: &ItemContext) { + if let Some(series) = item + .grandparent + .as_ref() + { + put(data, "SeriesName", &series.title); + put(data, "SeriesId", simple_id(&series.id)); + } +} + /// `SeasonNumber`, `SeasonNumber00` and `SeasonNumber000` (and the episode /// equivalents): the raw number plus its two zero-padded renderings. fn put_padded_number(data: &mut Map, key: &str, number: Option) { @@ -855,8 +909,10 @@ mod tests { assert_eq!(str_at(&data, "RunTime"), "01:30:45"); } + /// Imported templates print the runtime unconditionally, so the keys are + /// always present — zeroed rather than missing when it is unknown. #[test] - fn runtime_variables_are_absent_without_a_runtime() { + fn runtime_variables_fall_back_to_zero() { let item = ItemContext { media: db::Media { runtime: None, @@ -865,8 +921,8 @@ mod tests { ..movie() }; let data = build_data(&server(), &item_added(), Some(&item)); - assert!(!data.contains_key("RunTime")); - assert!(!data.contains_key("RunTimeTicks")); + assert_eq!(data["RunTimeTicks"], Value::from(0)); + assert_eq!(str_at(&data, "RunTime"), "00:00:00"); } // --- episode variables ------------------------------------------------ @@ -902,6 +958,85 @@ mod tests { ); } + /// The plugin reads `Year` off the *series* for an episode, not off the + /// episode's own air date. + #[test] + fn episode_year_comes_from_the_series() { + let base = episode(); + let item = ItemContext { + grandparent: Some(db::Media { + released_at: Some( + chrono::NaiveDate::from_ymd_opt(2019, 9, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ), + ..base + .grandparent + .clone() + .unwrap() + }), + ..base + }; + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!( + data["Year"], + Value::from(2019), + "Year must be the series' production year" + ); + // The episode's own air date still drives PremiereDate. + assert_eq!(str_at(&data, "PremiereDate"), "2021-03-04"); + } + + /// The plugin's stock template has a dedicated Season branch that prints + /// the series name and the season number. + #[test] + fn season_gets_the_series_keys_and_its_own_number() { + let item = ItemContext { + media: db::Media { + id: Uuid::from_u128(SEASON_ID), + kind: db::MediaKind::Season, + title: "Season 2".into(), + // A season's own `idx` is the season number. + idx: Some(2), + grandparent_id: Some(Uuid::from_u128(SERIES_ID)), + ..Default::default() + }, + parent: None, + grandparent: Some(db::Media { + id: Uuid::from_u128(SERIES_ID), + kind: db::MediaKind::Series, + title: "Test Show".into(), + released_at: Some( + chrono::NaiveDate::from_ymd_opt(2019, 9, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ), + ..Default::default() + }), + genres: vec![], + }; + let data = build_data(&server(), &item_added(), Some(&item)); + + assert_eq!(str_at(&data, "ItemType"), "Season"); + assert_eq!(str_at(&data, "SeriesName"), "Test Show"); + assert_eq!( + str_at(&data, "SeriesId"), + Uuid::from_u128(SERIES_ID) + .simple() + .to_string() + ); + assert_eq!(data["SeasonNumber"], Value::from(2)); + assert_eq!(str_at(&data, "SeasonNumber00"), "02"); + assert_eq!(str_at(&data, "SeasonNumber000"), "002"); + assert_eq!(data["Year"], Value::from(2019)); + assert!( + !data.contains_key("EpisodeNumber"), + "a season has no episode number" + ); + } + #[test] fn track_exposes_album_and_artist() { let item = ItemContext { @@ -1173,4 +1308,264 @@ mod tests { assert_eq!(merged.len(), base.len()); assert!(matches!(merged, Cow::Borrowed(_)), "no clone is needed"); } + + // --- enrich_item (against a real database) ----------------------------- + + const SERIES_IMDB: &str = "tt5550001"; + + fn imdb(value: &str) -> db::NonEmptyString { + db::NonEmptyString::try_new(value.to_string()).unwrap() + } + + /// `Media::save` validates that the row id is the one derived from its + /// external ids, so the fixtures have to be keyed the same way. + fn derived_id( + kind: db::MediaKind, + external_ids: &db::ExternalIds, + season: Option, + episode: Option, + ) -> Uuid { + Uuid::from(&db::MediaIdRaw { + kind, + external_ids: external_ids.clone(), + season, + episode, + }) + } + + /// Inserts a `Test Show` / `Season 2` pair and returns + /// `(series, season, unsaved S02E05 episode)`. + async fn seed_show(ctx: &AppContext) -> (db::Media, db::Media, db::Media) { + let series_ids = db::ExternalIds { + imdb: Some(imdb(SERIES_IMDB)), + ..Default::default() + }; + let child_ids = db::ExternalIds { + series_imdb: Some(imdb(SERIES_IMDB)), + ..Default::default() + }; + + let mut series = db::Media { + id: derived_id(db::MediaKind::Series, &series_ids, None, None), + kind: db::MediaKind::Series, + title: "Test Show".into(), + external_ids: series_ids, + ..Default::default() + }; + series + .save(&ctx.db) + .await + .expect("series must insert"); + + let mut season = db::Media { + id: derived_id(db::MediaKind::Season, &child_ids, Some(2), None), + kind: db::MediaKind::Season, + title: "Season 2".into(), + idx: Some(2), + parent_id: Some(series.id), + grandparent_id: Some(series.id), + external_ids: child_ids.clone(), + ..Default::default() + }; + season + .save(&ctx.db) + .await + .expect("season must insert"); + + let episode = db::Media { + id: derived_id(db::MediaKind::Episode, &child_ids, Some(2), Some(5)), + kind: db::MediaKind::Episode, + title: "The One With The Test".into(), + idx: Some(5), + parent_idx: Some(2), + parent_id: Some(season.id), + grandparent_id: Some(series.id), + external_ids: child_ids, + ..Default::default() + }; + + (series, season, episode) + } + + /// Pins the parent/grandparent assignment, which nothing else covers: with + /// the two swapped, `SeriesName` would render the season title on every + /// episode webhook and every hand-built `ItemContext` test would stay green. + #[tokio::test] + async fn enrich_item_resolves_the_season_as_parent_and_the_series_as_grandparent() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .unwrap(); + let ctx = &guard.0; + let (series, season, mut episode) = seed_show(ctx).await; + episode + .save(&ctx.db) + .await + .expect("episode must insert"); + + let item = enrich_item( + ctx, + &WebhookEvent::ItemAdded { + item_id: episode.id, + }, + ) + .await + .expect("the item must be resolved"); + + assert_eq!( + item.media + .id, + episode.id + ); + let parent = item + .parent + .as_ref() + .expect("an episode has a season"); + let grandparent = item + .grandparent + .as_ref() + .expect("an episode has a series"); + assert_eq!(parent.id, season.id, "parent must be the season"); + assert_eq!(parent.kind, db::MediaKind::Season); + assert_eq!(grandparent.id, series.id, "grandparent must be the series"); + assert_eq!(grandparent.kind, db::MediaKind::Series); + + // The consequence a swap would produce, asserted end to end. + let data = build_data( + &server(), + &WebhookEvent::ItemAdded { + item_id: episode.id, + }, + Some(&item), + ); + assert_eq!(str_at(&data, "SeriesName"), "Test Show"); + assert_eq!( + str_at(&data, "SeasonId"), + season + .id + .simple() + .to_string() + ); + assert_eq!( + str_at(&data, "SeriesId"), + series + .id + .simple() + .to_string() + ); + } + + #[tokio::test] + async fn enrich_item_returns_none_for_an_unknown_or_itemless_event() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .unwrap(); + let ctx = &guard.0; + + assert!( + enrich_item( + ctx, + &WebhookEvent::ItemAdded { + item_id: Uuid::from_u128(999), + } + ) + .await + .is_none(), + "an item that is not in the database resolves to nothing" + ); + assert!( + enrich_item(ctx, &WebhookEvent::UserCreated { user: user() }) + .await + .is_none(), + "an event with no item resolves to nothing" + ); + } + + /// `ItemDeleted` must read the row off the event — the DB row is already + /// gone by the time the dispatcher sees it — while still resolving the + /// parents, which are not deleted. + #[tokio::test] + async fn enrich_item_uses_the_row_embedded_in_item_deleted() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .unwrap(); + let ctx = &guard.0; + // The episode is deliberately never saved: it stands for a row that has + // just been deleted. + let (series, season, episode) = seed_show(ctx).await; + + assert!( + enrich_item( + ctx, + &WebhookEvent::ItemAdded { + item_id: episode.id + } + ) + .await + .is_none(), + "guard: the episode row really is absent from the database" + ); + + let item = enrich_item( + ctx, + &WebhookEvent::ItemDeleted { + item: Box::new(episode.clone()), + }, + ) + .await + .expect("the embedded row must be used"); + + assert_eq!( + item.media + .title, + "The One With The Test" + ); + assert_eq!( + item.parent + .as_ref() + .map(|p| p.id), + Some(season.id) + ); + assert_eq!( + item.grandparent + .as_ref() + .map(|g| g.id), + Some(series.id) + ); + } + + /// `ServerUrl` comes from `Config::public_url`. + #[tokio::test] + async fn server_info_reads_the_public_url_from_config() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .unwrap(); + let ctx = &guard.0; + + assert_eq!( + ServerInfo::load(ctx) + .await + .url, + "", + "unset public_url renders as empty, never as a guess" + ); + + let configured = AppContext { + config: crate::Config { + public_url: Some(" https://media.example.com ".into()), + ..ctx + .config + .clone() + }, + ..ctx.clone() + }; + let info = ServerInfo::load(&configured).await; + assert_eq!(info.url, "https://media.example.com"); + assert!( + !info + .name + .is_empty(), + "ServerName must never be empty" + ); + assert_eq!(info.version, env!("CARGO_PKG_VERSION")); + } } diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index c3c484214..360ad1dc8 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -24,13 +24,24 @@ pub(crate) fn fresh_registry() -> Handlebars<'static> { // Missing variables render as empty rather than failing the whole body: // most variables are event-specific and templates are user-written. registry.set_strict_mode(false); - // Bodies are JSON, not HTML. HTML-escaping would turn `Ocean's` into - // `Ocean's` and break `json_encode` output. - registry.register_escape_fn(handlebars::no_escape); + // Bodies are JSON, not HTML: `{{Var}}` almost always sits inside a JSON + // string literal, so that is what values are escaped for. HTML escaping + // would mangle `Ocean's` into `Ocean's`, and no escaping at all would + // let a title like `The "Burbs` break the body. `{{{Var}}}` stays the raw + // escape hatch, exactly as in the Jellyfin plugin's stock templates. + registry.register_escape_fn(escape_json_string); register_helpers(&mut registry); registry } +/// Escape `value` for insertion inside a JSON string literal: the JSON +/// encoding of the string, minus its surrounding quotes. +fn escape_json_string(value: &str) -> String { + let encoded = Value::String(value.to_string()).to_string(); + // `Value::String` always serializes as `"…"`, so both quotes are present. + encoded[1..encoded.len() - 1].to_string() +} + /// A registry with every hook's template pre-compiled under its id. /// /// A hook whose template does not parse is skipped, not fatal: the others must @@ -147,7 +158,10 @@ fn if_exist<'reg, 'rc>( Ok(()) } -/// `{{link_to url text}}` → `text`. +/// `{{link_to url text}}` → `text`. +/// +/// Single quotes on purpose, as in the plugin: the tag has to survive inside a +/// JSON string literal, which double quotes would terminate. fn link_to( h: &Helper, _registry: &Handlebars, @@ -157,7 +171,7 @@ fn link_to( ) -> HelperResult { let url = required_param(h, 0, "link_to")?; let text = required_param(h, 1, "link_to")?; - out.write(&format!("{text}"))?; + out.write(&format!("{text}"))?; Ok(()) } @@ -174,8 +188,12 @@ fn url_encode( Ok(()) } -/// `{{json_encode value}}` — the value as a JSON literal, quotes included for -/// strings. Lets a template interpolate arbitrary text into a JSON body. +/// `{{json_encode value}}` — the value escaped for a JSON string literal, +/// **without** surrounding quotes. +/// +/// The plugin idiom is `"title": "{{json_encode Name}}"`, i.e. the template +/// supplies the quotes: emitting them here would produce `""…""` and an +/// invalid body. fn json_encode( h: &Helper, _registry: &Handlebars, @@ -187,8 +205,12 @@ fn json_encode( .param(0) .ok_or(RenderErrorReason::ParamNotFoundForIndex("json_encode", 0))? .value(); - let encoded = serde_json::to_string(value) - .map_err(|e| RenderErrorReason::NestedError(Box::new(e)))?; + let encoded = match value { + // Non-string values are already valid JSON literals as they stand. + Value::String(s) => escape_json_string(s), + other => serde_json::to_string(other) + .map_err(|e| RenderErrorReason::NestedError(Box::new(e)))?, + }; out.write(&encoded)?; Ok(()) } @@ -338,15 +360,27 @@ mod tests { // --- link_to / url_encode / json_encode ------------------------------- + /// Single-quoted `href`, as the plugin emits: the anchor has to survive + /// inside a JSON string literal, which a double quote would terminate. #[test] - fn link_to_emits_an_anchor() { - assert_eq!( - render_template( - "{{link_to ServerUrl Name}}", - json!({ "ServerUrl": "https://example.test/web", "Name": "Open" }) - ), - "Open" + fn link_to_emits_a_single_quoted_anchor() { + let body = render_template( + "{{link_to ServerUrl Name}}", + json!({ "ServerUrl": "https://example.test/web", "Name": "Open" }), ); + assert_eq!(body, "Open"); + assert!( + !body.contains('"'), + "a double quote would break the JSON body: {body}" + ); + + // The canonical use: inside a JSON string. + let json_body = render_template( + r#"{"content": "{{link_to ServerUrl Name}}"}"#, + json!({ "ServerUrl": "https://example.test/web", "Name": "Open" }), + ); + serde_json::from_str::(&json_body) + .unwrap_or_else(|e| panic!("{json_body} must stay valid JSON: {e}")); } #[test] @@ -360,14 +394,16 @@ mod tests { ); } + /// The template supplies the quotes (`"title": "{{json_encode Name}}"`), so + /// the helper must not add its own — that is what the plugin does. #[test] - fn json_encode_produces_a_json_literal() { + fn json_encode_escapes_without_adding_quotes() { assert_eq!( render_template( "{{json_encode Name}}", json!({ "Name": "He said \"hi\"" }) ), - r#""He said \"hi\"""# + r#"He said \"hi\""# ); assert_eq!( render_template( @@ -376,15 +412,61 @@ mod tests { ), "2" ); + + // The canonical plugin idiom must produce valid JSON. + let body = render_template( + r#"{"title": "{{json_encode Name}}"}"#, + json!({ "Name": "He said \"hi\"" }), + ); + let parsed: Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("{body} must be valid JSON: {e}")); + assert_eq!(parsed["title"], json!("He said \"hi\"")); } - /// Bodies are JSON, not HTML: escaping `'` or `&` would corrupt them. + /// Bodies are JSON, not HTML: `'` and `&` must stay readable, while `"`, + /// `\` and control characters must be escaped for the string literal the + /// value almost always sits in. #[test] - fn plain_substitution_is_not_html_escaped() { + fn plain_substitution_is_escaped_for_a_json_string() { assert_eq!( render_template("{{Name}}", json!({ "Name": "Ocean's 11 & 12" })), "Ocean's 11 & 12" ); + assert_eq!( + render_template("{{Name}}", json!({ "Name": "The \"Burbs" })), + r#"The \"Burbs"# + ); + assert_eq!( + render_template("{{Name}}", json!({ "Name": r"C:\media\x" })), + r"C:\\media\\x" + ); + assert_eq!( + render_template("{{Name}}", json!({ "Name": "line\nbreak" })), + r"line\nbreak" + ); + + // A body built the usual way survives a hostile title. + let body = render_template( + r#"{"title": "{{Name}}"}"#, + json!({ "Name": "The \"Burbs\\" }), + ); + let parsed: Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("{body} must be valid JSON: {e}")); + assert_eq!(parsed["title"], json!("The \"Burbs\\")); + } + + /// Triple braces stay the raw escape hatch, as in the plugin's stock + /// templates — which is also why the double-brace form must escape. + #[test] + fn triple_braces_bypass_the_escaping() { + assert_eq!( + render_template("{{{Name}}}", json!({ "Name": "The \"Burbs" })), + "The \"Burbs" + ); + assert_eq!( + render_template("{{{Name}}}", json!({ "Name": r"C:\media\x" })), + r"C:\media\x" + ); } // --- hook flags ------------------------------------------------------- @@ -553,7 +635,7 @@ mod tests { &json!({ "A": "a" }), ) .expect("every custom helper must be registered on a fresh registry"); - assert_eq!(body, "12aa\"a\""); + assert_eq!(body, "12aaa"); } #[test] From 91878a169b822990df85728876919027f1ccb87b Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 14:46:56 +0200 Subject: [PATCH 10/29] feat(server): webhook delivery with generic and discord destinations --- .../remux-server/src/services/webhooks/mod.rs | 5 +- .../src/services/webhooks/sender.rs | 768 +++++++++++++++++- 2 files changed, 759 insertions(+), 14 deletions(-) diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index fb51b7b2b..755b0c471 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -257,9 +257,10 @@ impl WebhookService { for hook in targets { match template::render(hook, &cache.registry, &data) { // Delivery is spawned so one slow endpoint cannot stall - // the dispatcher or the hooks behind it. + // the dispatcher or the hooks behind it, and bounded so + // a dead one cannot grow tasks without limit. Ok(Some(body)) => { - tokio::spawn(sender::deliver(hook.clone(), body)); + sender::spawn_delivery(hook.clone(), body); } // `skip_empty_message_body` suppressed the delivery. Ok(None) => {} diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index 8c0d69bf4..9c92fd1a2 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -1,23 +1,767 @@ -//! STUB — filled in by task 5 (HTTP delivery). +//! HTTP delivery of a rendered webhook body. //! -//! A plain JSON POST: no custom headers, no destination-specific envelope, no -//! timeout or retry policy yet. +//! Everything that decides *what* goes on the wire lives in pure functions +//! ([`shape_request`], [`build_discord_body`], [`parse_embed_color`], +//! [`detect_content_type`]); [`send_once`] only performs the POST. A new +//! destination is a new `WebhookDestination` variant plus an arm in +//! [`shape_request`]. +//! +//! Delivery is fire-and-forget: [`spawn_delivery`] never blocks its caller and +//! every error is logged and swallowed, so a broken endpoint can neither stall +//! the dispatcher nor surface anywhere in the server. use crate::db; +use remux_sdks::remux::{DiscordMentionType, WebhookDestination}; +use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue}; +use serde_json::{Map, Value}; +use std::{ + sync::{Arc, LazyLock}, + time::Duration, +}; +use tokio::sync::Semaphore; use tracing::{debug, warn}; -/// TODO(task 5): destination headers/envelope, timeout, retries, test support. +/// Per-request timeout. Without one an endpoint that accepts the connection and +/// then blackholes it would hold its task — and its concurrency slot — forever. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Ceiling on deliveries in flight at once. A dead endpoint plus a sustained +/// `PlaybackProgress` stream would otherwise spawn tasks without bound; past +/// this many, events are dropped with a warning rather than queued. +const MAX_CONCURRENT_DELIVERIES: usize = 16; + +/// Discord's own default embed colour, as used by the Jellyfin webhook plugin's +/// stock Discord templates (`0x3399FF`). +const DEFAULT_EMBED_COLOR: u32 = 3_381_759; + +/// `Encoding.UTF8` on the plugin's `StringContent` puts the charset on the +/// header; these are the two defaults [`detect_content_type`] picks between. +const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; +const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8"; + +/// At most this many bytes of a failed response body make it into the log line. +const MAX_LOGGED_RESPONSE: usize = 512; + +/// One client for the whole process: a client per delivery would rebuild the +/// TLS config and throw away the connection pool on every event. +static WEBHOOK_CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .user_agent("remux-server/1.0") + .timeout(REQUEST_TIMEOUT) + .build() + .expect("failed to build the webhook HTTP client") +}); + +static DELIVERY_SLOTS: LazyLock> = + LazyLock::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT_DELIVERIES))); + +/// How hard a single delivery tries. Extracted so tests can shrink the backoff. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DeliveryPolicy { + pub attempts: u32, + /// Base delay in milliseconds; `retry!` grows it exponentially with jitter. + pub retry_delay_ms: u64, +} + +impl Default for DeliveryPolicy { + fn default() -> Self { + Self { + attempts: 3, + retry_delay_ms: 500, + } + } +} + +/// Hand a rendered body to the delivery pool. +/// +/// Returns immediately. When every slot is busy the delivery is dropped rather +/// than queued: an unbounded backlog behind a dead endpoint is worse than a +/// missed notification, and the dispatcher must never wait here. +pub(crate) fn spawn_delivery(hook: db::Webhook, body: String) { + let Ok(permit) = DELIVERY_SLOTS + .clone() + .try_acquire_owned() + else { + warn!( + webhook = %hook.name, + limit = MAX_CONCURRENT_DELIVERIES, + "webhook delivery slots exhausted, dropping event" + ); + return; + }; + tokio::spawn(async move { + // Held for the whole delivery, retries included. + let _permit = permit; + deliver(hook, body).await; + }); +} + +/// Deliver `body` to `hook`, retrying transient failures. Never fails: a broken +/// webhook is a log line, nothing more. pub(crate) async fn deliver(hook: db::Webhook, body: String) { - let result = reqwest::Client::new() + if let Err(e) = deliver_with(&hook, &body, &DeliveryPolicy::default()).await { + warn!(webhook = %hook.name, url = %hook.url, error = %e, "webhook delivery failed, giving up"); + } +} + +/// The retried delivery, with its outcome still visible. `deliver` is this plus +/// the logging. +pub(crate) async fn deliver_with( + hook: &db::Webhook, + body: &str, + policy: &DeliveryPolicy, +) -> anyhow::Result<()> { + let response = remux_utils::retry! { + attempts: policy.attempts, + delay: policy.retry_delay_ms, + { send_once(hook, body).await } + }?; + debug!( + webhook = %hook.name, + status = %response.status().as_u16(), + "webhook delivered" + ); + Ok(()) +} + +/// A single POST. +/// +/// `reqwest` treats a 4xx/5xx as a perfectly good response, so the status is +/// checked here: without this every failed delivery would be reported as a +/// success and the retry would never fire. +pub(crate) async fn send_once( + hook: &db::Webhook, + body: &str, +) -> anyhow::Result { + let shaped = shape_request(hook, body); + let mut request = WEBHOOK_CLIENT .post(&hook.url) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body) + .header(CONTENT_TYPE, shaped.content_type); + for (name, value) in shaped.headers { + request = request.header(name, value); + } + // An unparseable URL surfaces here as an error, not a panic. + let response = request + .body(shaped.body) .send() - .await; - match result { - Ok(response) => { - debug!(webhook = %hook.name, status = %response.status().as_u16(), "webhook delivered") + .await?; + + let status = response.status(); + if !status.is_success() { + let detail = response + .text() + .await + .unwrap_or_default(); + anyhow::bail!( + "webhook endpoint returned {status}: {}", + truncate(detail.trim(), MAX_LOGGED_RESPONSE) + ); + } + Ok(response) +} + +/// Everything a destination decides about the request, resolved without I/O. +pub(crate) struct ShapedRequest { + pub body: String, + pub content_type: HeaderValue, + /// Extra headers, `Content-Type` excluded — it belongs on the content. + pub headers: Vec<(HeaderName, HeaderValue)>, +} + +/// Turn a rendered body into the request `hook`'s destination expects. +pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest { + match &hook.destination { + // The rendered body goes out verbatim; the operator's headers are + // applied on top, with `Content-Type` pulled out because it describes + // the content rather than being a header of its own. + WebhookDestination::Generic { headers, .. } => { + let mut content_type = + HeaderValue::from_static(detect_content_type(rendered)); + let mut extra = Vec::with_capacity(headers.len()); + for pair in headers { + let (key, value) = ( + pair.key + .as_str(), + pair.value + .as_str(), + ); + if key.is_empty() || value.is_empty() { + continue; + } + if key.eq_ignore_ascii_case(CONTENT_TYPE.as_str()) { + match HeaderValue::from_str(value) { + Ok(value) => content_type = value, + Err(_) => warn!( + webhook = %hook.name, + "invalid Content-Type on webhook, using the detected one" + ), + } + continue; + } + match ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + (Ok(name), Ok(value)) => extra.push((name, value)), + _ => { + warn!(webhook = %hook.name, header = %key, "skipping invalid webhook header") + } + } + } + ShapedRequest { + body: rendered.to_string(), + content_type, + headers: extra, + } + } + // The rendered body is the embed description; everything around it is + // built from the destination's own settings. + WebhookDestination::Discord { + avatar_url, + bot_username, + embed_color, + mention_type, + } => ShapedRequest { + body: build_discord_body( + avatar_url.as_deref(), + bot_username.as_deref(), + embed_color.as_deref(), + *mention_type, + rendered, + ) + .to_string(), + content_type: HeaderValue::from_static(JSON_CONTENT_TYPE), + headers: Vec::new(), + }, + } +} + +/// The Discord execute-webhook payload wrapping `rendered`. +/// +/// `content` carries the mention (empty for `None`); the rendered text becomes +/// the description of a single embed. `username` and `avatar_url` are omitted +/// when unset rather than sent empty. +pub(crate) fn build_discord_body( + avatar_url: Option<&str>, + bot_username: Option<&str>, + embed_color: Option<&str>, + mention_type: DiscordMentionType, + rendered: &str, +) -> Value { + let mut embed = Map::new(); + embed.insert("description".into(), Value::String(rendered.to_string())); + embed.insert( + "color".into(), + Value::Number(parse_embed_color(embed_color).into()), + ); + + let mut body = Map::new(); + body.insert( + "content".into(), + Value::String(mention_content(mention_type).to_string()), + ); + if let Some(username) = non_empty(bot_username) { + body.insert("username".into(), Value::String(username.to_string())); + } + if let Some(avatar) = non_empty(avatar_url) { + body.insert("avatar_url".into(), Value::String(avatar.to_string())); + } + body.insert("embeds".into(), Value::Array(vec![Value::Object(embed)])); + Value::Object(body) +} + +/// What a mention type puts in Discord's `content` field. +fn mention_content(mention_type: DiscordMentionType) -> &'static str { + match mention_type { + DiscordMentionType::None => "", + DiscordMentionType::Here => "@here", + DiscordMentionType::Everyone => "@everyone", + } +} + +/// `#RRGGBB` (or bare `RRGGBB`) as the integer Discord wants. Anything else — +/// including a missing colour — falls back to [`DEFAULT_EMBED_COLOR`]: the +/// value is operator input and must never fail a delivery. +pub(crate) fn parse_embed_color(hex: Option<&str>) -> u32 { + hex.map(|hex| { + hex.trim() + .trim_start_matches('#') + }) + .filter(|hex| { + hex.len() == 6 + && hex + .bytes() + .all(|b| b.is_ascii_hexdigit()) + }) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .unwrap_or(DEFAULT_EMBED_COLOR) +} + +/// The content type a rendered body should be sent as when the operator has not +/// named one: templates that produce JSON are the common case, but a template +/// is free to produce anything. +pub(crate) fn detect_content_type(body: &str) -> &'static str { + if serde_json::from_str::(body).is_ok() { + JSON_CONTENT_TYPE + } else { + TEXT_CONTENT_TYPE + } +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} + +/// Truncate on a char boundary — response bodies are arbitrary bytes. +fn truncate(value: &str, max: usize) -> &str { + if value.len() <= max { + return value; + } + let mut end = max; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::MockServer; + use remux_sdks::remux::{ + DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, + WebhookKeyValue, + }; + use serde_json::{Value, json}; + use std::time::{Duration, Instant}; + use uuid::Uuid; + + /// Short enough that the suite does not crawl, long enough that the two + /// backoff sleeps are observable. + const FAST: DeliveryPolicy = DeliveryPolicy { + attempts: 3, + retry_delay_ms: 20, + }; + + fn hook(url: &str, destination: WebhookDestination) -> db::Webhook { + let now = chrono::Utc::now(); + db::Webhook { + id: Uuid::from_u128(100), + name: "test".into(), + enabled: true, + url: url.into(), + template: "{{Name}}".into(), + destination, + notification_types: vec![NotificationType::ItemAdded], + user_filter: vec![], + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: now, + updated_at: now, + } + } + + fn generic(url: &str, headers: &[(&str, &str)]) -> db::Webhook { + hook( + url, + WebhookDestination::Generic { + headers: headers + .iter() + .map(|(key, value)| WebhookKeyValue { + key: (*key).into(), + value: (*value).into(), + }) + .collect(), + fields: vec![], + }, + ) + } + + fn discord_hook(url: &str, mention_type: DiscordMentionType) -> db::Webhook { + hook( + url, + WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type, + }, + ) + } + + fn content_type(hook: &db::Webhook, body: &str) -> String { + shape_request(hook, body) + .content_type + .to_str() + .expect("content type must be a valid header value") + .to_string() + } + + // --- parse_embed_color ------------------------------------------------ + + #[test] + fn parse_embed_color_reads_a_six_digit_hex() { + assert_eq!(parse_embed_color(Some("#AA5CC3")), 11_164_867); + // Lower case and a missing '#' are both accepted. + assert_eq!(parse_embed_color(Some("aa5cc3")), 11_164_867); + assert_eq!(parse_embed_color(Some("#000000")), 0); + assert_eq!(parse_embed_color(Some("#FFFFFF")), 16_777_215); + } + + #[test] + fn parse_embed_color_falls_back_to_the_default() { + for input in [ + None, + Some(""), + Some("#"), + Some("#AA5CC"), // too short + Some("#AA5CC3F"), // too long + Some("#GGGGGG"), // not hex + Some("rebeccapurple"), + ] { + assert_eq!( + parse_embed_color(input), + DEFAULT_EMBED_COLOR, + "{input:?} must fall back to the default colour" + ); } - Err(e) => warn!(webhook = %hook.name, error = %e, "webhook delivery failed"), + } + + // --- build_discord_body ----------------------------------------------- + + #[test] + fn discord_body_content_carries_the_mention_type() { + for (mention_type, expected) in [ + (DiscordMentionType::None, ""), + (DiscordMentionType::Here, "@here"), + (DiscordMentionType::Everyone, "@everyone"), + ] { + let body = build_discord_body(None, None, None, mention_type, "hi"); + assert_eq!( + body["content"], + json!(expected), + "{mention_type:?} must produce {expected:?}" + ); + } + } + + #[test] + fn discord_body_wraps_the_rendered_text_in_a_single_embed() { + let body = build_discord_body( + None, + None, + Some("#AA5CC3"), + DiscordMentionType::None, + "a line", + ); + let embeds = body["embeds"] + .as_array() + .expect("embeds must be an array"); + assert_eq!(embeds.len(), 1); + assert_eq!(embeds[0]["description"], json!("a line")); + assert_eq!(embeds[0]["color"], json!(11_164_867)); + } + + #[test] + fn discord_body_carries_the_bot_identity_only_when_set() { + let with = build_discord_body( + Some("https://example.test/a.png"), + Some("remux"), + None, + DiscordMentionType::None, + "hi", + ); + assert_eq!(with["avatar_url"], json!("https://example.test/a.png")); + assert_eq!(with["username"], json!("remux")); + + let without = + build_discord_body(None, None, None, DiscordMentionType::None, "hi"); + assert!( + !without + .as_object() + .unwrap() + .contains_key("avatar_url"), + "an unset avatar must not be sent as an empty string" + ); + assert!( + !without + .as_object() + .unwrap() + .contains_key("username") + ); + } + + // --- detect_content_type ---------------------------------------------- + + #[test] + fn detect_content_type_recognises_json() { + assert!(detect_content_type(r#"{"a": 1}"#).starts_with("application/json")); + assert!(detect_content_type("[1, 2]").starts_with("application/json")); + } + + #[test] + fn detect_content_type_falls_back_to_text() { + assert!(detect_content_type("a plain line").starts_with("text/plain")); + assert!(detect_content_type("{not json").starts_with("text/plain")); + assert!(detect_content_type("").starts_with("text/plain")); + } + + // --- shape_request: generic ------------------------------------------- + + #[test] + fn generic_sends_the_rendered_body_verbatim_with_a_detected_content_type() { + let hook = generic("https://example.test/hook", &[]); + let shaped = shape_request(&hook, "hello"); + assert_eq!(shaped.body, "hello"); + assert!( + content_type(&hook, "hello").starts_with("text/plain"), + "a non-JSON body must be sent as text" + ); + assert!(content_type(&hook, r#"{"a":1}"#).starts_with("application/json")); + } + + #[test] + fn generic_content_type_header_overrides_the_detected_one() { + let hook = generic( + "https://example.test/hook", + &[("content-type", "application/x-www-form-urlencoded")], + ); + assert_eq!( + content_type(&hook, r#"{"a":1}"#), + "application/x-www-form-urlencoded", + "the operator's Content-Type wins, case-insensitively" + ); + assert!( + shape_request(&hook, "x") + .headers + .is_empty(), + "Content-Type belongs on the content, not the header map" + ); + } + + #[test] + fn generic_applies_the_operator_headers() { + let hook = generic( + "https://example.test/hook", + &[("X-Token", "s3cret"), ("X-Other", "v")], + ); + let names: Vec = shape_request(&hook, "x") + .headers + .iter() + .map(|(name, _)| { + name.as_str() + .to_string() + }) + .collect(); + assert_eq!(names, vec!["x-token", "x-other"]); + } + + /// Header names and values are operator input: a malformed pair must be + /// dropped, never panic. + #[test] + fn generic_skips_empty_and_malformed_headers() { + let hook = generic( + "https://example.test/hook", + &[ + ("", "no key"), + ("X-No-Value", ""), + ("Bad Name", "v"), + ("X-Bad-Value", "line\nbreak"), + ("X-Good", "v"), + ], + ); + let shaped = shape_request(&hook, "x"); + assert_eq!( + shaped + .headers + .len(), + 1 + ); + assert_eq!( + shaped.headers[0] + .0 + .as_str(), + "x-good" + ); + } + + #[test] + fn a_malformed_operator_content_type_falls_back_to_the_detected_one() { + let hook = generic("https://example.test/hook", &[("Content-Type", "a\nb")]); + assert!(content_type(&hook, "plain").starts_with("text/plain")); + } + + // --- shape_request: discord ------------------------------------------- + + #[test] + fn discord_posts_the_envelope_as_json() { + let hook = + discord_hook("https://example.test/hook", DiscordMentionType::Everyone); + let shaped = shape_request(&hook, "a line"); + assert!( + content_type(&hook, "a line").starts_with("application/json"), + "Discord always takes JSON" + ); + let parsed: Value = + serde_json::from_str(&shaped.body).expect("the body must be valid JSON"); + assert_eq!(parsed["content"], json!("@everyone")); + assert_eq!(parsed["embeds"][0]["description"], json!("a line")); + assert!( + shaped + .headers + .is_empty() + ); + } + + // --- send_once: status handling --------------------------------------- + + #[tokio::test] + async fn send_once_reports_a_non_2xx_as_an_error() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(500) + .body("boom"); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let error = send_once(&hook, "ping") + .await + .expect_err("a 500 must not be reported as a success"); + let message = error.to_string(); + assert!( + message.contains("500"), + "the error must name the status: {message}" + ); + mock.assert_hits_async(1) + .await; + } + + #[tokio::test] + async fn send_once_accepts_any_2xx() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/hook"); + then.status(204); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + assert!( + send_once(&hook, "ping") + .await + .is_ok() + ); + } + + // --- retry ------------------------------------------------------------- + + #[tokio::test] + async fn delivery_retries_until_the_attempt_budget_is_spent() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(500); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + assert!( + deliver_with(&hook, "ping", &FAST) + .await + .is_err() + ); + assert_eq!( + mock.hits_async() + .await, + 3, + "a persistent 5xx must be retried up to the attempt budget" + ); + } + + #[tokio::test] + async fn delivery_stops_at_the_first_success() { + let server = MockServer::start_async().await; + let failing = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(500); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let policy = DeliveryPolicy { + attempts: 3, + retry_delay_ms: 100, + }; + let task = + tokio::spawn(async move { deliver_with(&hook, "ping", &policy).await }); + + // Let the first two attempts fail, then make the endpoint healthy again + // while the last backoff sleep is still running. + let deadline = Instant::now() + Duration::from_secs(10); + while failing + .hits_async() + .await + < 2 + { + assert!( + Instant::now() < deadline, + "the retry never reached attempt 2" + ); + tokio::time::sleep(Duration::from_millis(2)).await; + } + let healthy = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(200); + }) + .await; + let failed_attempts = failing + .hits_async() + .await; + failing + .delete_async() + .await; + + task.await + .expect("the delivery task must not panic") + .expect("the third attempt succeeded, so the delivery must succeed"); + assert_eq!(failed_attempts, 2); + assert_eq!( + healthy + .hits_async() + .await, + 1, + "the retry must stop at the first success" + ); + } + + /// A failing webhook must never propagate: `deliver` logs and swallows. + #[tokio::test] + async fn deliver_swallows_every_error() { + // Nothing is listening on this port, so every attempt fails at connect. + let hook = generic("http://127.0.0.1:1/hook", &[]); + deliver_with(&hook, "ping", &FAST) + .await + .expect_err("a connection failure must surface as an error internally"); + // …but the fire-and-forget entry point returns quietly. + deliver(hook, "ping".into()).await; + } + + #[tokio::test] + async fn an_unparseable_url_is_an_error_not_a_panic() { + let hook = generic("not a url", &[]); + assert!( + send_once(&hook, "ping") + .await + .is_err() + ); } } From f0adfbcdad30253a5725cda0d056eccb791e2f1a Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 14:58:32 +0200 Subject: [PATCH 11/29] refactor(server): discord webhook options become template variables Strict parity with jellyfin-plugin-webhook's DiscordClient: the destination's options are injected into the handlebars dictionary (MentionType, EmbedColor, AvatarUrl, Username, BotUsername) and the operator's template renders the whole Discord payload, so a template copied from the plugin works verbatim. The server-side envelope in the sender is removed. --- .../src/services/webhooks/payload.rs | 288 ++++++++++++++++-- .../src/services/webhooks/sender.rs | 235 +++----------- 2 files changed, 297 insertions(+), 226 deletions(-) diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index 057309486..8d01ff674 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -11,7 +11,9 @@ use super::events::{DeviceEventData, PlaybackEventData, UserEventData, WebhookEvent}; use crate::{AppContext, db}; -use remux_sdks::remux::{MediaStream, MediaStreamType, WebhookDestination}; +use remux_sdks::remux::{ + DiscordMentionType, MediaStream, MediaStreamType, WebhookDestination, +}; use serde_json::{Map, Value}; use std::borrow::Cow; use tracing::warn; @@ -26,6 +28,11 @@ const COMPLETION_RATIO: f64 = 0.9; /// Name used when the server has none configured. const DEFAULT_SERVER_NAME: &str = "remux"; +/// Discord's own default embed colour (`0x3399FF`), as hardcoded by the +/// Jellyfin webhook plugin's stock Discord templates. Used when a hook names no +/// colour or names an unparseable one. +const DEFAULT_EMBED_COLOR: u32 = 3_381_759; + /// The `ItemType` a template sees. Narrower than [`db::MediaKind`] on purpose: /// it is the Jellyfin `BaseItemKind` subset the webhook plugin emits, and /// everything that is not one of the named kinds is reported as `Video`. @@ -176,31 +183,108 @@ pub(crate) fn build_data( data } -/// Per-hook overlay: a `Generic` destination's operator-defined fields are -/// visible to that hook's template. Borrowed — and therefore free — for every -/// hook that defines none. +/// Per-hook overlay: the destination's own settings become template variables, +/// exactly as the Jellyfin webhook plugin's clients do before rendering. +/// +/// - `Generic` contributes the operator-defined `fields` under their own keys +/// (`GenericClient.SendAsync`). +/// - `Discord` contributes `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` +/// and `BotUsername` (`DiscordClient.SendAsync`) — which is what lets a +/// Discord template copied from the plugin render the whole payload itself. +/// +/// Borrowed — and therefore free — when the hook contributes nothing. pub(crate) fn with_hook_fields<'a>( data: &'a Map, hook: &db::Webhook, ) -> Cow<'a, Map> { - let fields = match &hook.destination { - WebhookDestination::Generic { fields, .. } if !fields.is_empty() => fields, - _ => return Cow::Borrowed(data), - }; - let mut merged = data.clone(); - for field in fields { - merged.insert( - field - .key - .clone(), - Value::String( - field - .value - .clone(), - ), - ); + match &hook.destination { + WebhookDestination::Generic { fields, .. } => { + if fields.is_empty() { + return Cow::Borrowed(data); + } + let mut merged = data.clone(); + for field in fields { + merged.insert( + field + .key + .clone(), + Value::String( + field + .value + .clone(), + ), + ); + } + Cow::Owned(merged) + } + // Key spellings, value formats and presence rules follow + // `DiscordClient.SendAsync` literally: `MentionType` is always set (to + // the empty string for `None`), the other three only when configured, + // and a username lands under both `Username` and `BotUsername`. + WebhookDestination::Discord { + avatar_url, + bot_username, + embed_color, + mention_type, + } => { + let mut merged = data.clone(); + merged.insert( + "MentionType".into(), + Value::String(mention_type_variable(*mention_type).to_string()), + ); + if let Some(hex) = non_empty(embed_color.as_deref()) { + merged.insert( + "EmbedColor".into(), + Value::Number(parse_embed_color(hex).into()), + ); + } + if let Some(url) = non_empty(avatar_url.as_deref()) { + merged.insert("AvatarUrl".into(), Value::String(url.to_string())); + } + if let Some(username) = non_empty(bot_username.as_deref()) { + merged.insert("Username".into(), Value::String(username.to_string())); + merged + .insert("BotUsername".into(), Value::String(username.to_string())); + } + Cow::Owned(merged) + } } - Cow::Owned(merged) +} + +/// What `{{MentionType}}` renders to. Empty for `None`, as in the plugin's +/// `DiscordClient.GetMentionType`. +fn mention_type_variable(mention_type: DiscordMentionType) -> &'static str { + match mention_type { + DiscordMentionType::None => "", + DiscordMentionType::Here => "@here", + DiscordMentionType::Everyone => "@everyone", + } +} + +/// `#RRGGBB` (or bare `RRGGBB`) as the integer Discord wants, mirroring the +/// plugin's `FormatColorCode` — except that the plugin slices `hexCode[1..6]` +/// and silently drops the last hex digit, turning `#AA5CC3` into 697 804. That +/// bug is deliberately **not** reproduced: an admin gets the colour they pick. +/// +/// Anything unparseable falls back to [`DEFAULT_EMBED_COLOR`] rather than +/// throwing as the plugin does — this is operator input and must never fail a +/// delivery. +pub(crate) fn parse_embed_color(hex: &str) -> u32 { + let hex = hex + .trim() + .trim_start_matches('#'); + if hex.len() != 6 + || !hex + .bytes() + .all(|b| b.is_ascii_hexdigit()) + { + return DEFAULT_EMBED_COLOR; + } + u32::from_str_radix(hex, 16).unwrap_or(DEFAULT_EMBED_COLOR) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) } // --- item ----------------------------------------------------------------- @@ -812,15 +896,6 @@ mod tests { } } - fn discord() -> db::Webhook { - hook(WebhookDestination::Discord { - avatar_url: None, - bot_username: None, - embed_color: None, - mention_type: DiscordMentionType::None, - }) - } - // --- common variables ------------------------------------------------- #[test] @@ -1302,13 +1377,162 @@ mod tests { } #[test] - fn hook_fields_are_only_merged_for_generic_destinations() { + fn a_generic_hook_with_no_fields_borrows_the_dictionary() { let base = build_data(&server(), &item_added(), Some(&episode())); - let merged = with_hook_fields(&base, &discord()); + let merged = with_hook_fields( + &base, + &hook(WebhookDestination::Generic { + headers: vec![], + fields: vec![], + }), + ); assert_eq!(merged.len(), base.len()); assert!(matches!(merged, Cow::Borrowed(_)), "no clone is needed"); } + // --- discord destination variables ------------------------------------- + + fn discord_with( + avatar_url: Option<&str>, + bot_username: Option<&str>, + embed_color: Option<&str>, + mention_type: DiscordMentionType, + ) -> db::Webhook { + hook(WebhookDestination::Discord { + avatar_url: avatar_url.map(str::to_string), + bot_username: bot_username.map(str::to_string), + embed_color: embed_color.map(str::to_string), + mention_type, + }) + } + + fn discord_vars(hook: &db::Webhook) -> Map { + let base = build_data(&server(), &item_added(), Some(&episode())); + with_hook_fields(&base, hook).into_owned() + } + + /// `DiscordClient.SendAsync` always sets `MentionType`, empty for `None`. + /// This is what `{{MentionType}}` in a plugin template resolves against. + #[test] + fn discord_always_exposes_the_mention_type() { + for (mention_type, expected) in [ + (DiscordMentionType::None, ""), + (DiscordMentionType::Here, "@here"), + (DiscordMentionType::Everyone, "@everyone"), + ] { + let data = discord_vars(&discord_with(None, None, None, mention_type)); + assert_eq!( + str_at(&data, "MentionType"), + expected, + "{mention_type:?} must render as {expected:?}" + ); + } + } + + /// The plugin sets a username under **both** `Username` and `BotUsername`, + /// and only when it is non-empty. Its stock templates use `{{BotUsername}}`. + #[test] + fn discord_exposes_the_bot_identity_under_the_plugin_keys() { + let data = discord_vars(&discord_with( + Some("https://example.test/a.png"), + Some("remux"), + Some("#AA5CC3"), + DiscordMentionType::None, + )); + assert_eq!(str_at(&data, "AvatarUrl"), "https://example.test/a.png"); + assert_eq!(str_at(&data, "Username"), "remux"); + assert_eq!(str_at(&data, "BotUsername"), "remux"); + } + + /// Presence parity: the plugin only inserts these keys when configured, so + /// an unset one must be *missing*, not present-and-empty — that is what + /// makes `{{#if_exist AvatarUrl}}` behave as it does in the plugin. + #[test] + fn discord_omits_the_unset_options() { + for hook in [ + discord_with(None, None, None, DiscordMentionType::None), + discord_with(Some(""), Some(""), Some(""), DiscordMentionType::None), + ] { + let data = discord_vars(&hook); + for key in ["AvatarUrl", "Username", "BotUsername", "EmbedColor"] { + assert!( + !data.contains_key(key), + "{key} must be absent when it is not configured" + ); + } + // …but the mention type is always there. + assert!(data.contains_key("MentionType")); + } + } + + /// The plugin formats the colour into an integer before it reaches the + /// template (`FormatColorCode`), so `{{EmbedColor}}` is a number. + #[test] + fn discord_exposes_the_embed_color_as_an_integer() { + let data = discord_vars(&discord_with( + None, + None, + Some("#AA5CC3"), + DiscordMentionType::None, + )); + assert_eq!(data["EmbedColor"], Value::from(11_164_867)); + } + + /// A `Generic` hook must not gain Discord keys, and vice versa. + #[test] + fn discord_variables_are_not_exposed_to_generic_hooks() { + let data = with_hook_fields( + &build_data(&server(), &item_added(), Some(&episode())), + &hook(WebhookDestination::Generic { + headers: vec![], + fields: vec![WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }], + }), + ) + .into_owned(); + for key in ["MentionType", "AvatarUrl", "Username", "BotUsername"] { + assert!(!data.contains_key(key), "{key} is Discord-only"); + } + } + + // --- parse_embed_color ------------------------------------------------- + + #[test] + fn parse_embed_color_reads_a_six_digit_hex() { + assert_eq!(parse_embed_color("#AA5CC3"), 11_164_867); + // Lower case and a missing '#' are both accepted. + assert_eq!(parse_embed_color("aa5cc3"), 11_164_867); + assert_eq!(parse_embed_color("#000000"), 0); + assert_eq!(parse_embed_color("#FFFFFF"), 16_777_215); + } + + /// The plugin's `FormatColorCode` slices `hexCode[1..6]` and drops the last + /// digit, so `#AA5CC3` reaches Discord as 697 804. That bug is not ours. + #[test] + fn parse_embed_color_does_not_reproduce_the_plugin_truncation() { + assert_ne!(parse_embed_color("#AA5CC3"), 697_804); + } + + #[test] + fn parse_embed_color_falls_back_to_the_default() { + for input in [ + "", + "#", + "#AA5CC", // too short + "#AA5CC3F", // too long + "#GGGGGG", // not hex + "rebeccapurple", + ] { + assert_eq!( + parse_embed_color(input), + DEFAULT_EMBED_COLOR, + "{input:?} must fall back to the default colour" + ); + } + } + // --- enrich_item (against a real database) ----------------------------- const SERIES_IMDB: &str = "tt5550001"; diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index 9c92fd1a2..075375cad 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -1,19 +1,24 @@ //! HTTP delivery of a rendered webhook body. //! //! Everything that decides *what* goes on the wire lives in pure functions -//! ([`shape_request`], [`build_discord_body`], [`parse_embed_color`], -//! [`detect_content_type`]); [`send_once`] only performs the POST. A new -//! destination is a new `WebhookDestination` variant plus an arm in -//! [`shape_request`]. +//! ([`shape_request`], [`detect_content_type`]); [`send_once`] only performs +//! the POST. A new destination is a new `WebhookDestination` variant plus an +//! arm in [`shape_request`]. +//! +//! The rendered body is never rewrapped here. Destination-specific *content* — +//! the Discord payload, a Generic hook's extra fields — is produced by the +//! template, from the variables [`super::payload::with_hook_fields`] puts in +//! scope; that is how the Jellyfin webhook plugin works, and it is what lets a +//! template written for the plugin render verbatim. //! //! Delivery is fire-and-forget: [`spawn_delivery`] never blocks its caller and //! every error is logged and swallowed, so a broken endpoint can neither stall //! the dispatcher nor surface anywhere in the server. use crate::db; -use remux_sdks::remux::{DiscordMentionType, WebhookDestination}; +use remux_sdks::remux::WebhookDestination; use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue}; -use serde_json::{Map, Value}; +use serde_json::Value; use std::{ sync::{Arc, LazyLock}, time::Duration, @@ -30,10 +35,6 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// this many, events are dropped with a warning rather than queued. const MAX_CONCURRENT_DELIVERIES: usize = 16; -/// Discord's own default embed colour, as used by the Jellyfin webhook plugin's -/// stock Discord templates (`0x3399FF`). -const DEFAULT_EMBED_COLOR: u32 = 3_381_759; - /// `Encoding.UTF8` on the plugin's `StringContent` puts the charset on the /// header; these are the two defaults [`detect_content_type`] picks between. const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; @@ -214,89 +215,18 @@ pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest headers: extra, } } - // The rendered body is the embed description; everything around it is - // built from the destination's own settings. - WebhookDestination::Discord { - avatar_url, - bot_username, - embed_color, - mention_type, - } => ShapedRequest { - body: build_discord_body( - avatar_url.as_deref(), - bot_username.as_deref(), - embed_color.as_deref(), - *mention_type, - rendered, - ) - .to_string(), + // Same as the plugin's `DiscordClient`: the template already rendered + // the whole Discord payload — post it as-is, as JSON, with no headers + // of its own. The destination's settings reached the template through + // `payload::with_hook_fields`, not through this function. + WebhookDestination::Discord { .. } => ShapedRequest { + body: rendered.to_string(), content_type: HeaderValue::from_static(JSON_CONTENT_TYPE), headers: Vec::new(), }, } } -/// The Discord execute-webhook payload wrapping `rendered`. -/// -/// `content` carries the mention (empty for `None`); the rendered text becomes -/// the description of a single embed. `username` and `avatar_url` are omitted -/// when unset rather than sent empty. -pub(crate) fn build_discord_body( - avatar_url: Option<&str>, - bot_username: Option<&str>, - embed_color: Option<&str>, - mention_type: DiscordMentionType, - rendered: &str, -) -> Value { - let mut embed = Map::new(); - embed.insert("description".into(), Value::String(rendered.to_string())); - embed.insert( - "color".into(), - Value::Number(parse_embed_color(embed_color).into()), - ); - - let mut body = Map::new(); - body.insert( - "content".into(), - Value::String(mention_content(mention_type).to_string()), - ); - if let Some(username) = non_empty(bot_username) { - body.insert("username".into(), Value::String(username.to_string())); - } - if let Some(avatar) = non_empty(avatar_url) { - body.insert("avatar_url".into(), Value::String(avatar.to_string())); - } - body.insert("embeds".into(), Value::Array(vec![Value::Object(embed)])); - Value::Object(body) -} - -/// What a mention type puts in Discord's `content` field. -fn mention_content(mention_type: DiscordMentionType) -> &'static str { - match mention_type { - DiscordMentionType::None => "", - DiscordMentionType::Here => "@here", - DiscordMentionType::Everyone => "@everyone", - } -} - -/// `#RRGGBB` (or bare `RRGGBB`) as the integer Discord wants. Anything else — -/// including a missing colour — falls back to [`DEFAULT_EMBED_COLOR`]: the -/// value is operator input and must never fail a delivery. -pub(crate) fn parse_embed_color(hex: Option<&str>) -> u32 { - hex.map(|hex| { - hex.trim() - .trim_start_matches('#') - }) - .filter(|hex| { - hex.len() == 6 - && hex - .bytes() - .all(|b| b.is_ascii_hexdigit()) - }) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .unwrap_or(DEFAULT_EMBED_COLOR) -} - /// The content type a rendered body should be sent as when the operator has not /// named one: templates that produce JSON are the common case, but a template /// is free to produce anything. @@ -308,10 +238,6 @@ pub(crate) fn detect_content_type(body: &str) -> &'static str { } } -fn non_empty(value: Option<&str>) -> Option<&str> { - value.filter(|value| !value.is_empty()) -} - /// Truncate on a char boundary — response bodies are arbitrary bytes. fn truncate(value: &str, max: usize) -> &str { if value.len() <= max { @@ -399,100 +325,6 @@ mod tests { .to_string() } - // --- parse_embed_color ------------------------------------------------ - - #[test] - fn parse_embed_color_reads_a_six_digit_hex() { - assert_eq!(parse_embed_color(Some("#AA5CC3")), 11_164_867); - // Lower case and a missing '#' are both accepted. - assert_eq!(parse_embed_color(Some("aa5cc3")), 11_164_867); - assert_eq!(parse_embed_color(Some("#000000")), 0); - assert_eq!(parse_embed_color(Some("#FFFFFF")), 16_777_215); - } - - #[test] - fn parse_embed_color_falls_back_to_the_default() { - for input in [ - None, - Some(""), - Some("#"), - Some("#AA5CC"), // too short - Some("#AA5CC3F"), // too long - Some("#GGGGGG"), // not hex - Some("rebeccapurple"), - ] { - assert_eq!( - parse_embed_color(input), - DEFAULT_EMBED_COLOR, - "{input:?} must fall back to the default colour" - ); - } - } - - // --- build_discord_body ----------------------------------------------- - - #[test] - fn discord_body_content_carries_the_mention_type() { - for (mention_type, expected) in [ - (DiscordMentionType::None, ""), - (DiscordMentionType::Here, "@here"), - (DiscordMentionType::Everyone, "@everyone"), - ] { - let body = build_discord_body(None, None, None, mention_type, "hi"); - assert_eq!( - body["content"], - json!(expected), - "{mention_type:?} must produce {expected:?}" - ); - } - } - - #[test] - fn discord_body_wraps_the_rendered_text_in_a_single_embed() { - let body = build_discord_body( - None, - None, - Some("#AA5CC3"), - DiscordMentionType::None, - "a line", - ); - let embeds = body["embeds"] - .as_array() - .expect("embeds must be an array"); - assert_eq!(embeds.len(), 1); - assert_eq!(embeds[0]["description"], json!("a line")); - assert_eq!(embeds[0]["color"], json!(11_164_867)); - } - - #[test] - fn discord_body_carries_the_bot_identity_only_when_set() { - let with = build_discord_body( - Some("https://example.test/a.png"), - Some("remux"), - None, - DiscordMentionType::None, - "hi", - ); - assert_eq!(with["avatar_url"], json!("https://example.test/a.png")); - assert_eq!(with["username"], json!("remux")); - - let without = - build_discord_body(None, None, None, DiscordMentionType::None, "hi"); - assert!( - !without - .as_object() - .unwrap() - .contains_key("avatar_url"), - "an unset avatar must not be sent as an empty string" - ); - assert!( - !without - .as_object() - .unwrap() - .contains_key("username") - ); - } - // --- detect_content_type ---------------------------------------------- #[test] @@ -595,23 +427,38 @@ mod tests { // --- shape_request: discord ------------------------------------------- + /// Parity with the plugin's `DiscordClient`: the template renders the whole + /// Discord payload, so the sender must post it byte for byte. Wrapping it + /// in a server-built envelope would break every template copied from the + /// plugin. #[test] - fn discord_posts_the_envelope_as_json() { + fn discord_posts_the_rendered_body_unmodified() { + let rendered = r#"{"content": "@everyone", "embeds": [{"title": "A Movie"}]}"#; let hook = discord_hook("https://example.test/hook", DiscordMentionType::Everyone); - let shaped = shape_request(&hook, "a line"); + let shaped = shape_request(&hook, rendered); + assert_eq!(shaped.body, rendered, "the body must not be rewrapped"); assert!( - content_type(&hook, "a line").starts_with("application/json"), - "Discord always takes JSON" + content_type(&hook, rendered).starts_with("application/json"), + "Discord always takes JSON, whatever the body looks like" ); - let parsed: Value = - serde_json::from_str(&shaped.body).expect("the body must be valid JSON"); - assert_eq!(parsed["content"], json!("@everyone")); - assert_eq!(parsed["embeds"][0]["description"], json!("a line")); assert!( shaped .headers - .is_empty() + .is_empty(), + "the plugin sends no custom headers to Discord" + ); + + // Even a body that is not valid JSON goes out untouched, as JSON: the + // template — not the sender — owns the payload. + let broken = shape_request(&hook, "not json at all"); + assert_eq!(broken.body, "not json at all"); + assert!( + broken + .content_type + .to_str() + .unwrap() + .starts_with("application/json") ); } From 401bf99ebd4d1c10bd7e237b3d2a4652d7575794 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 15:22:06 +0200 Subject: [PATCH 12/29] fix(server): redact webhook urls in logs, bound deliveries per hook, retry only transient failures - never log a webhook URL path or query: it is the credential (Discord's token lives there). Scheme and host only, plus the hook id. Also strips the URL that reqwest::Error embeds in its own Display. - delivery slots are keyed by hook id instead of a single global pool, so one blackholing endpoint can no longer starve every other webhook. - retry only transport errors, 5xx, 408 and 429, and honour Retry-After (and Discord's X-RateLimit-Reset-After) instead of hammering a rate limit. - always expose EmbedColor to Discord templates, defaulted, so the plugin's stock template cannot render an invalid "color": "". - skip generic destination fields with an empty key or value, as the plugin does. - tests now assert the bytes actually posted, and cover both branches of the bounded spawn. --- .../src/services/webhooks/payload.rs | 110 ++- .../src/services/webhooks/sender.rs | 779 ++++++++++++++++-- 2 files changed, 792 insertions(+), 97 deletions(-) diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index 8d01ff674..7819e3d29 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -191,6 +191,7 @@ pub(crate) fn build_data( /// - `Discord` contributes `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` /// and `BotUsername` (`DiscordClient.SendAsync`) — which is what lets a /// Discord template copied from the plugin render the whole payload itself. +/// `EmbedColor` is the one intended deviation: always present, see below. /// /// Borrowed — and therefore free — when the hook contributes nothing. pub(crate) fn with_hook_fields<'a>( @@ -204,23 +205,30 @@ pub(crate) fn with_hook_fields<'a>( } let mut merged = data.clone(); for field in fields { - merged.insert( - field - .key - .clone(), - Value::String( + // `GenericClient.SendAsync` skips a pair when either half is + // empty — the same rule the headers half of that method applies. + let (Some(key), Some(value)) = ( + non_empty(Some( + field + .key + .as_str(), + )), + non_empty(Some( field .value - .clone(), - ), - ); + .as_str(), + )), + ) else { + continue; + }; + merged.insert(key.to_string(), Value::String(value.to_string())); } Cow::Owned(merged) } // Key spellings, value formats and presence rules follow // `DiscordClient.SendAsync` literally: `MentionType` is always set (to - // the empty string for `None`), the other three only when configured, - // and a username lands under both `Username` and `BotUsername`. + // the empty string for `None`) and a username lands under both + // `Username` and `BotUsername`, present only when configured. WebhookDestination::Discord { avatar_url, bot_username, @@ -232,12 +240,25 @@ pub(crate) fn with_hook_fields<'a>( "MentionType".into(), Value::String(mention_type_variable(*mention_type).to_string()), ); - if let Some(hex) = non_empty(embed_color.as_deref()) { - merged.insert( - "EmbedColor".into(), - Value::Number(parse_embed_color(hex).into()), - ); - } + // Intended deviation: the plugin omits `EmbedColor` when the hook + // names no colour, which makes its own stock `Discord.handlebars` + // render `"color": ""` and Discord reject the payload with a 400. + // The key is therefore always present, defaulted. This costs no + // template-behaviour parity: across all five stock Discord + // templates `{{EmbedColor}}` appears exactly once, as a bare + // interpolation, never guarded by `if_exist` — the four per-event + // templates hardcode a literal colour and ignore the variable. + merged.insert( + "EmbedColor".into(), + Value::Number( + non_empty(embed_color.as_deref()) + .map_or(DEFAULT_EMBED_COLOR, parse_embed_color) + .into(), + ), + ); + // `AvatarUrl` / `Username` / `BotUsername` keep strict presence + // parity: the stock templates *do* guard these with `if_exist`, so + // an always-present empty string would flip those blocks. if let Some(url) = non_empty(avatar_url.as_deref()) { merged.insert("AvatarUrl".into(), Value::String(url.to_string())); } @@ -1376,6 +1397,38 @@ mod tests { assert!(!base.contains_key("channel")); } + /// `GenericClient.SendAsync` skips a field when either half is empty — the + /// same rule its header loop applies. + #[test] + fn generic_destination_fields_skip_empty_halves() { + let base = build_data(&server(), &item_added(), Some(&episode())); + let hook = hook(WebhookDestination::Generic { + headers: vec![], + fields: vec![ + WebhookKeyValue { + key: "".into(), + value: "orphan".into(), + }, + WebhookKeyValue { + key: "blank".into(), + value: "".into(), + }, + WebhookKeyValue { + key: "channel".into(), + value: "#general".into(), + }, + ], + }); + + let merged = with_hook_fields(&base, &hook); + assert_eq!(str_at(&merged, "channel"), "#general"); + assert!(!merged.contains_key(""), "an empty key must be skipped"); + assert!( + !merged.contains_key("blank"), + "an empty value must be skipped" + ); + } + #[test] fn a_generic_hook_with_no_fields_borrows_the_dictionary() { let base = build_data(&server(), &item_added(), Some(&episode())); @@ -1448,13 +1501,13 @@ mod tests { /// an unset one must be *missing*, not present-and-empty — that is what /// makes `{{#if_exist AvatarUrl}}` behave as it does in the plugin. #[test] - fn discord_omits_the_unset_options() { + fn discord_omits_the_unset_identity_options() { for hook in [ discord_with(None, None, None, DiscordMentionType::None), discord_with(Some(""), Some(""), Some(""), DiscordMentionType::None), ] { let data = discord_vars(&hook); - for key in ["AvatarUrl", "Username", "BotUsername", "EmbedColor"] { + for key in ["AvatarUrl", "Username", "BotUsername"] { assert!( !data.contains_key(key), "{key} must be absent when it is not configured" @@ -1478,6 +1531,27 @@ mod tests { assert_eq!(data["EmbedColor"], Value::from(11_164_867)); } + /// Intended deviation from the plugin, which omits the key: the stock + /// `Discord.handlebars` interpolates `{{EmbedColor}}` bare, so an absent + /// key renders `"color": ""` and Discord rejects the payload. The + /// invariant belongs here, not in a dashboard form three crates away. + #[test] + fn discord_always_exposes_an_embed_color() { + for embed_color in [None, Some(""), Some("nonsense")] { + let data = discord_vars(&discord_with( + None, + None, + embed_color, + DiscordMentionType::None, + )); + assert_eq!( + data["EmbedColor"], + Value::from(DEFAULT_EMBED_COLOR), + "{embed_color:?} must still yield a usable colour" + ); + } + } + /// A `Generic` hook must not gain Discord keys, and vice versa. #[test] fn discord_variables_are_not_exposed_to_generic_hooks() { diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index 075375cad..e28a8fa1a 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -1,9 +1,10 @@ //! HTTP delivery of a rendered webhook body. //! //! Everything that decides *what* goes on the wire lives in pure functions -//! ([`shape_request`], [`detect_content_type`]); [`send_once`] only performs -//! the POST. A new destination is a new `WebhookDestination` variant plus an -//! arm in [`shape_request`]. +//! ([`shape_request`], [`detect_content_type`], [`classify_status`], +//! [`parse_retry_after`]); [`attempt_once`] only performs the POST. A new +//! destination is a new `WebhookDestination` variant plus an arm in +//! [`shape_request`]. //! //! The rendered body is never rewrapped here. Destination-specific *content* — //! the Discord payload, a Generic hook's extra fields — is produced by the @@ -14,26 +15,39 @@ //! Delivery is fire-and-forget: [`spawn_delivery`] never blocks its caller and //! every error is logged and swallowed, so a broken endpoint can neither stall //! the dispatcher nor surface anywhere in the server. +//! +//! **A webhook URL is a credential.** Discord's is +//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the entire +//! authentication — anyone holding it can post as the webhook indefinitely. No +//! log line in this module may contain a URL path or query; see [`redact_url`]. use crate::db; use remux_sdks::remux::WebhookDestination; -use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue}; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, +}; use serde_json::Value; use std::{ - sync::{Arc, LazyLock}, + collections::HashMap, + sync::{Arc, LazyLock, Mutex}, time::Duration, }; -use tokio::sync::Semaphore; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tracing::{debug, warn}; +use uuid::Uuid; /// Per-request timeout. Without one an endpoint that accepts the connection and -/// then blackholes it would hold its task — and its concurrency slot — forever. +/// then blackholes it would hold its task — and its delivery slot — forever. const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -/// Ceiling on deliveries in flight at once. A dead endpoint plus a sustained -/// `PlaybackProgress` stream would otherwise spawn tasks without bound; past -/// this many, events are dropped with a warning rather than queued. -const MAX_CONCURRENT_DELIVERIES: usize = 16; +/// Ceiling on deliveries in flight **per hook**. +const MAX_CONCURRENT_DELIVERIES_PER_HOOK: usize = 4; + +/// Upper bound on a `Retry-After` we will obey. The value is remote input and +/// the waiter holds a delivery slot while it sleeps, so an endpoint must not be +/// able to pin one indefinitely. +const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); /// `Encoding.UTF8` on the plugin's `StringContent` puts the charset on the /// header; these are the two defaults [`detect_content_type`] picks between. @@ -53,87 +67,200 @@ static WEBHOOK_CLIENT: LazyLock = LazyLock::new(|| { .expect("failed to build the webhook HTTP client") }); -static DELIVERY_SLOTS: LazyLock> = - LazyLock::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT_DELIVERIES))); +static DELIVERY_SLOTS: LazyLock = + LazyLock::new(|| DeliverySlots::new(MAX_CONCURRENT_DELIVERIES_PER_HOOK)); -/// How hard a single delivery tries. Extracted so tests can shrink the backoff. -#[derive(Debug, Clone, Copy)] -pub(crate) struct DeliveryPolicy { - pub attempts: u32, - /// Base delay in milliseconds; `retry!` grows it exponentially with jitter. - pub retry_delay_ms: u64, +// --- concurrency ------------------------------------------------------------ + +/// Delivery slots, counted **per hook**. +/// +/// A single process-wide pool would let one blackholing endpoint hold every +/// slot for its full retry window — three attempts of up to 30 s each, plus +/// backoff — after which deliveries to every *healthy* hook are dropped too. +/// Keying by hook id keeps a broken Discord URL from disabling an operator's +/// working Slack and Gotify hooks; the total is still bounded, at +/// `enabled hooks × limit`. +pub(crate) struct DeliverySlots { + limit: usize, + per_hook: Mutex>>, } -impl Default for DeliveryPolicy { - fn default() -> Self { +impl DeliverySlots { + pub(crate) fn new(limit: usize) -> Self { Self { - attempts: 3, - retry_delay_ms: 500, + limit, + per_hook: Mutex::new(HashMap::new()), } } + + /// A slot for `hook_id`, or `None` when that hook already has `limit` + /// deliveries in flight. Never blocks and never waits. + pub(crate) fn try_acquire(&self, hook_id: Uuid) -> Option { + let semaphore = { + // Short, await-free critical section. A poisoned lock is recovered + // rather than propagated: a panic elsewhere must not disable + // webhooks for the rest of the process. + let mut per_hook = self + .per_hook + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + per_hook + .entry(hook_id) + .or_insert_with(|| Arc::new(Semaphore::new(self.limit))) + .clone() + }; + semaphore + .try_acquire_owned() + .ok() + } } /// Hand a rendered body to the delivery pool. /// -/// Returns immediately. When every slot is busy the delivery is dropped rather -/// than queued: an unbounded backlog behind a dead endpoint is worse than a -/// missed notification, and the dispatcher must never wait here. +/// Returns immediately. When the hook already has its share of deliveries in +/// flight the event is dropped rather than queued: an unbounded backlog behind +/// a dead endpoint is worse than a missed notification, and the dispatcher must +/// never wait here. pub(crate) fn spawn_delivery(hook: db::Webhook, body: String) { - let Ok(permit) = DELIVERY_SLOTS - .clone() - .try_acquire_owned() - else { + spawn_delivery_with(&DELIVERY_SLOTS, hook, body, DeliveryPolicy::default()); +} + +/// [`spawn_delivery`] with its collaborators injected, and the accept/drop +/// decision returned so both branches are observable. +/// +/// The permit is taken **before** the spawn, on purpose: acquiring it inside +/// the task would bound concurrent sockets but let tasks pile up parked on the +/// semaphore — the same unbounded growth in a different allocation. +pub(crate) fn spawn_delivery_with( + slots: &DeliverySlots, + hook: db::Webhook, + body: String, + policy: DeliveryPolicy, +) -> bool { + let Some(permit) = slots.try_acquire(hook.id) else { warn!( webhook = %hook.name, - limit = MAX_CONCURRENT_DELIVERIES, - "webhook delivery slots exhausted, dropping event" + webhook_id = %hook.id, + limit = slots.limit, + "webhook already has its share of deliveries in flight, dropping event" ); - return; + return false; }; tokio::spawn(async move { - // Held for the whole delivery, retries included. + // Held for the whole delivery, retries included: the slot is the + // ceiling on work owed to one endpoint, not on one HTTP round-trip. let _permit = permit; - deliver(hook, body).await; + deliver_logged(hook, body, policy).await; }); + true +} + +// --- delivery --------------------------------------------------------------- + +/// How hard a single delivery tries. Extracted so tests can shrink the backoff. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DeliveryPolicy { + pub attempts: u32, + /// Base delay in milliseconds, grown exponentially with jitter. + pub retry_delay_ms: u64, +} + +impl Default for DeliveryPolicy { + fn default() -> Self { + Self { + attempts: 3, + retry_delay_ms: 500, + } + } } /// Deliver `body` to `hook`, retrying transient failures. Never fails: a broken /// webhook is a log line, nothing more. pub(crate) async fn deliver(hook: db::Webhook, body: String) { - if let Err(e) = deliver_with(&hook, &body, &DeliveryPolicy::default()).await { - warn!(webhook = %hook.name, url = %hook.url, error = %e, "webhook delivery failed, giving up"); + deliver_logged(hook, body, DeliveryPolicy::default()).await; +} + +async fn deliver_logged(hook: db::Webhook, body: String, policy: DeliveryPolicy) { + if let Err(e) = deliver_with(&hook, &body, &policy).await { + // No URL path, ever: it is the webhook's credential. + warn!( + webhook = %hook.name, + webhook_id = %hook.id, + endpoint = %redact_url(&hook.url), + error = %e, + "webhook delivery failed, giving up" + ); } } -/// The retried delivery, with its outcome still visible. `deliver` is this plus -/// the logging. +/// The retried delivery, with its outcome still visible. [`deliver`] is this +/// plus the logging. +/// +/// Only *transient* failures are retried. Hand-rolled rather than built on +/// `remux_utils::retry!` because that macro retries every error +/// unconditionally, which would spend three attempts on a 401 and — worse for +/// Discord — hammer a 429 on a fixed backoff while ignoring the `Retry-After` +/// the endpoint just sent, escalating the very rate limit it is reacting to. pub(crate) async fn deliver_with( hook: &db::Webhook, body: &str, policy: &DeliveryPolicy, ) -> anyhow::Result<()> { - let response = remux_utils::retry! { - attempts: policy.attempts, - delay: policy.retry_delay_ms, - { send_once(hook, body).await } - }?; - debug!( - webhook = %hook.name, - status = %response.status().as_u16(), - "webhook delivered" - ); - Ok(()) + let attempts = policy + .attempts + .max(1); + let mut last: Option = None; + for attempt in 0..attempts { + match attempt_once(hook, body).await { + Ok(response) => { + debug!( + webhook = %hook.name, + webhook_id = %hook.id, + status = %response.status().as_u16(), + "webhook delivered" + ); + return Ok(()); + } + // Nothing about a second identical request would change the answer. + Err(e) if e.retryability == Retryability::Fatal => { + return Err(e.into()); + } + Err(e) => { + if attempt + 1 < attempts { + // The endpoint's own instruction wins over our backoff. + let wait = e + .retry_after + .unwrap_or_else(|| backoff(policy.retry_delay_ms, attempt)); + tokio::time::sleep(wait).await; + } + last = Some(e); + } + } + } + Err(last + .expect("at least one attempt is always made") + .into()) +} + +/// A single POST, for callers that want one attempt and no retry policy. +pub(crate) async fn send_once( + hook: &db::Webhook, + body: &str, +) -> anyhow::Result { + attempt_once(hook, body) + .await + .map_err(anyhow::Error::from) } -/// A single POST. +/// One POST, classified. /// /// `reqwest` treats a 4xx/5xx as a perfectly good response, so the status is /// checked here: without this every failed delivery would be reported as a /// success and the retry would never fire. -pub(crate) async fn send_once( +async fn attempt_once( hook: &db::Webhook, body: &str, -) -> anyhow::Result { +) -> Result { let shaped = shape_request(hook, body); let mut request = WEBHOOK_CLIENT .post(&hook.url) @@ -141,26 +268,127 @@ pub(crate) async fn send_once( for (name, value) in shaped.headers { request = request.header(name, value); } - // An unparseable URL surfaces here as an error, not a panic. let response = request .body(shaped.body) .send() - .await?; + .await + .map_err(|e| SendError { + // DNS, connect and timeout failures are exactly what a retry is + // for; a URL that does not parse fails identically every time. + retryability: if e.is_builder() { + Retryability::Fatal + } else { + Retryability::Transient + }, + retry_after: None, + // `reqwest`'s Display includes the URL, which is the credential. + message: format!("request failed: {}", redact_reqwest_error(&e)), + })?; let status = response.status(); - if !status.is_success() { - let detail = response - .text() - .await - .unwrap_or_default(); - anyhow::bail!( - "webhook endpoint returned {status}: {}", + if status.is_success() { + return Ok(response); + } + let retryability = classify_status(status); + let retry_after = retry_after(response.headers()); + let detail = response + .text() + .await + .unwrap_or_default(); + Err(SendError { + retryability, + retry_after, + message: format!( + "endpoint returned {status}: {}", truncate(detail.trim(), MAX_LOGGED_RESPONSE) - ); + ), + }) +} + +/// Whether a failed attempt is worth repeating. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Retryability { + Transient, + Fatal, +} + +/// A failed attempt, plus what the caller should do about it. +#[derive(Debug)] +pub(crate) struct SendError { + pub retryability: Retryability, + /// The endpoint's own instruction, when it sent one. + pub retry_after: Option, + message: String, +} + +impl std::fmt::Display for SendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) } - Ok(response) } +impl std::error::Error for SendError {} + +/// Only failures a later attempt could plausibly survive are retried: 5xx, +/// `408 Request Timeout` and `429 Too Many Requests`. A 400/401/403/404 is the +/// endpoint telling us the request itself is wrong — repeating it verbatim +/// wastes attempts and, on Discord, counts against the rate limit. +pub(crate) fn classify_status(status: StatusCode) -> Retryability { + if status.is_server_error() + || matches!( + status, + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS + ) + { + Retryability::Transient + } else { + Retryability::Fatal + } +} + +/// How long the endpoint asked us to wait. `Retry-After` first, then Discord's +/// `X-RateLimit-Reset-After`, which it sends alongside every 429. +fn retry_after(headers: &HeaderMap) -> Option { + ["retry-after", "x-ratelimit-reset-after"] + .into_iter() + .filter_map(|name| { + headers + .get(name)? + .to_str() + .ok() + }) + .find_map(parse_retry_after) +} + +/// `Retry-After` as a delay, capped at [`MAX_RETRY_AFTER`]. +/// +/// Only the delta-seconds form is understood — that is what Discord sends, and +/// it may be fractional. An HTTP-date, or anything unparseable, yields `None` +/// and the normal backoff applies. +pub(crate) fn parse_retry_after(value: &str) -> Option { + let seconds: f64 = value + .trim() + .parse() + .ok()?; + if !seconds.is_finite() || seconds < 0.0 { + return None; + } + Some(Duration::from_secs_f64(seconds).min(MAX_RETRY_AFTER)) +} + +/// `base * 2^attempt` plus jitter in `[0, base/2)`, mirroring +/// `remux_utils::retry!`. +fn backoff(base_ms: u64, attempt: u32) -> Duration { + let exponential = base_ms.saturating_mul(1u64 << attempt.min(10)); + let jitter = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.subsec_nanos() as u64 % (base_ms / 2 + 1)) + .unwrap_or(0); + Duration::from_millis(exponential.saturating_add(jitter)) +} + +// --- request shaping -------------------------------------------------------- + /// Everything a destination decides about the request, resolved without I/O. pub(crate) struct ShapedRequest { pub body: String, @@ -204,6 +432,8 @@ pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest HeaderValue::from_str(value), ) { (Ok(name), Ok(value)) => extra.push((name, value)), + // The name is operator-chosen and safe to log; the value + // may be a token and is not. _ => { warn!(webhook = %hook.name, header = %key, "skipping invalid webhook header") } @@ -238,6 +468,37 @@ pub(crate) fn detect_content_type(body: &str) -> &'static str { } } +// --- redaction -------------------------------------------------------------- + +/// Scheme and host only. +/// +/// A webhook URL's path is a credential: Discord's is +/// `https://discord.com/api/webhooks/{id}/{token}`, and that token is the whole +/// authentication. Log excerpts end up in bug reports, so nothing past the host +/// may appear in one. +pub(crate) fn redact_url(url: &str) -> String { + match reqwest::Url::parse(url) { + Ok(parsed) => match parsed.host_str() { + Some(host) => format!("{}://{host}", parsed.scheme()), + None => parsed + .scheme() + .to_string(), + }, + Err(_) => "".to_string(), + } +} + +/// `reqwest::Error`'s `Display` embeds the request URL, so it is stripped +/// before the message reaches a log line. +fn redact_reqwest_error(error: &reqwest::Error) -> String { + match error.url() { + Some(url) => error + .to_string() + .replace(url.as_str(), &redact_url(url.as_str())), + None => error.to_string(), + } +} + /// Truncate on a char boundary — response bodies are arbitrary bytes. fn truncate(value: &str, max: usize) -> &str { if value.len() <= max { @@ -258,9 +519,7 @@ mod tests { DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, WebhookKeyValue, }; - use serde_json::{Value, json}; - use std::time::{Duration, Instant}; - use uuid::Uuid; + use std::time::Instant; /// Short enough that the suite does not crawl, long enough that the two /// backoff sleeps are observable. @@ -325,6 +584,54 @@ mod tests { .to_string() } + /// Poll `condition` until it holds, failing the test rather than hanging. + async fn eventually(what: &str, mut condition: impl AsyncFnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !condition().await { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(2)).await; + } + } + + // --- redaction -------------------------------------------------------- + + /// A Discord webhook token is the entire credential — it must never reach a + /// log line, and log lines are what operators paste into issue trackers. + #[test] + fn redact_url_keeps_only_the_scheme_and_host() { + let secret = "https://discord.com/api/webhooks/123456789/aVerySecretToken"; + let redacted = redact_url(secret); + assert_eq!(redacted, "https://discord.com"); + assert!( + !redacted.contains("aVerySecretToken"), + "the token must not survive redaction: {redacted}" + ); + assert!(!redacted.contains("123456789")); + + // Query strings are credentials too (Slack, Gotify, Teams). + assert_eq!( + redact_url("https://hooks.example.test/services/T/B/xyz?token=abc"), + "https://hooks.example.test" + ); + // Operator input may not parse at all. + assert_eq!(redact_url("not a url"), ""); + } + + /// The transport error's own `Display` embeds the URL; the message we log + /// must not. + #[tokio::test] + async fn a_transport_error_message_carries_no_url_path() { + let hook = generic("http://127.0.0.1:1/api/webhooks/123/secret-token", &[]); + let error = send_once(&hook, "ping") + .await + .expect_err("nothing is listening on port 1"); + let message = error.to_string(); + assert!( + !message.contains("secret-token"), + "the URL path leaked into the error: {message}" + ); + } + // --- detect_content_type ---------------------------------------------- #[test] @@ -462,6 +769,128 @@ mod tests { ); } + // --- the actual wire request ------------------------------------------ + + /// `shape_request` deciding something is worthless if the decision never + /// reaches the socket. This matches on the received bytes, so deleting the + /// header loop in `attempt_once` — silently dropping an operator's auth + /// token from every delivery — fails here. + #[tokio::test] + async fn the_posted_request_carries_the_body_headers_and_content_type() { + let server = MockServer::start_async().await; + let body = r#"{"text":"A Movie & \"friends\""}"#; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path("/hook") + .header("x-auth-token", "s3cret") + .header("x-other", "v") + .header("content-type", "application/json; charset=utf-8") + .body(body); + then.status(200); + }) + .await; + + let hook = generic( + &server.url("/hook"), + &[("X-Auth-Token", "s3cret"), ("X-Other", "v")], + ); + send_once(&hook, body) + .await + .expect("the request must match the mock exactly"); + mock.assert_hits_async(1) + .await; + } + + /// The operator's `Content-Type` must reach the wire too, not just + /// `ShapedRequest`. + #[tokio::test] + async fn the_operator_content_type_reaches_the_wire() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook") + .header("content-type", "application/x-www-form-urlencoded") + .body("a=1&b=2"); + then.status(200); + }) + .await; + + let hook = generic( + &server.url("/hook"), + &[("Content-Type", "application/x-www-form-urlencoded")], + ); + send_once(&hook, "a=1&b=2") + .await + .expect("the operator's content type must be the one sent"); + mock.assert_hits_async(1) + .await; + } + + /// Discord gets the rendered bytes and nothing else. + #[tokio::test] + async fn a_discord_delivery_posts_the_template_output_byte_for_byte() { + let server = MockServer::start_async().await; + let rendered = + "{\n \"content\": \"@here\",\n \"embeds\": [{\"color\": 3381759}]\n}"; + let mock = server + .mock_async(|when, then| { + when.path("/hook") + .header("content-type", "application/json; charset=utf-8") + .body(rendered); + then.status(204); + }) + .await; + + let hook = discord_hook(&server.url("/hook"), DiscordMentionType::Here); + send_once(&hook, rendered) + .await + .expect("the rendered payload must be posted unchanged"); + mock.assert_hits_async(1) + .await; + } + + // --- status classification -------------------------------------------- + + #[test] + fn only_recoverable_statuses_are_retried() { + for status in [500u16, 502, 503, 504, 408, 429] { + assert_eq!( + classify_status(StatusCode::from_u16(status).unwrap()), + Retryability::Transient, + "{status} must be retried" + ); + } + for status in [400u16, 401, 403, 404, 405, 410, 422] { + assert_eq!( + classify_status(StatusCode::from_u16(status).unwrap()), + Retryability::Fatal, + "{status} must not be retried" + ); + } + } + + #[test] + fn parse_retry_after_reads_delta_seconds() { + assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5))); + assert_eq!( + parse_retry_after(" 0.25 "), + Some(Duration::from_millis(250)) + ); + assert_eq!(parse_retry_after("0"), Some(Duration::ZERO)); + } + + /// The value is remote input: an HTTP-date, junk, or a hostile number must + /// not pin a delivery slot. + #[test] + fn parse_retry_after_rejects_what_it_cannot_trust() { + assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None); + assert_eq!(parse_retry_after(""), None); + assert_eq!(parse_retry_after("-1"), None); + assert_eq!(parse_retry_after("NaN"), None); + assert_eq!(parse_retry_after("999999"), Some(MAX_RETRY_AFTER)); + } + // --- send_once: status handling --------------------------------------- #[tokio::test] @@ -532,6 +961,73 @@ mod tests { ); } + /// A 400 means the request itself is wrong: repeating it verbatim cannot + /// help, and on Discord it burns rate limit. + #[tokio::test] + async fn a_fatal_status_is_attempted_exactly_once() { + for status in [400u16, 401, 403, 404] { + let server = MockServer::start_async().await; + let mock = server + .mock_async(move |when, then| { + when.path("/hook"); + then.status(status); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + assert!( + deliver_with(&hook, "ping", &FAST) + .await + .is_err(), + "{status} must still be reported as a failure" + ); + assert_eq!( + mock.hits_async() + .await, + 1, + "{status} must not be retried" + ); + } + } + + /// A 429 *is* retried — and on the endpoint's own schedule. + #[tokio::test] + async fn a_rate_limit_is_retried_on_the_endpoint_schedule() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(429) + .header("retry-after", "0.05"); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + // A base delay far larger than the endpoint's instruction: if + // `Retry-After` were ignored, this would take ~30 s instead of ~0.1 s. + let policy = DeliveryPolicy { + attempts: 3, + retry_delay_ms: 10_000, + }; + let started = Instant::now(); + assert!( + deliver_with(&hook, "ping", &policy) + .await + .is_err() + ); + assert_eq!( + mock.hits_async() + .await, + 3, + "a 429 must be retried" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "Retry-After must override the backoff, took {:?}", + started.elapsed() + ); + } + #[tokio::test] async fn delivery_stops_at_the_first_success() { let server = MockServer::start_async().await; @@ -552,18 +1048,13 @@ mod tests { // Let the first two attempts fail, then make the endpoint healthy again // while the last backoff sleep is still running. - let deadline = Instant::now() + Duration::from_secs(10); - while failing - .hits_async() - .await - < 2 - { - assert!( - Instant::now() < deadline, - "the retry never reached attempt 2" - ); - tokio::time::sleep(Duration::from_millis(2)).await; - } + eventually("two failed attempts", async || { + failing + .hits_async() + .await + >= 2 + }) + .await; let healthy = server .mock_async(|when, then| { when.path("/hook"); @@ -611,4 +1102,134 @@ mod tests { .is_err() ); } + + // --- delivery slots ---------------------------------------------------- + + /// The point of keying by hook: a saturated endpoint must not consume the + /// slots of a healthy one. + #[test] + fn slots_are_counted_per_hook() { + let slots = DeliverySlots::new(2); + let busy = Uuid::from_u128(1); + let healthy = Uuid::from_u128(2); + + let first = slots + .try_acquire(busy) + .expect("a fresh hook has slots"); + let _second = slots + .try_acquire(busy) + .expect("up to the limit"); + assert!( + slots + .try_acquire(busy) + .is_none(), + "past the limit a hook gets nothing" + ); + assert!( + slots + .try_acquire(healthy) + .is_some(), + "a saturated hook must not starve another hook" + ); + + drop(first); + assert!( + slots + .try_acquire(busy) + .is_some(), + "a released slot comes back" + ); + } + + /// Entry condition 3: the drop branch drops, and it drops silently rather + /// than queueing — a saturated hook must produce no request at all. + #[tokio::test] + async fn spawn_delivery_drops_the_event_when_the_hook_is_saturated() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(200); + }) + .await; + + let slots = DeliverySlots::new(1); + let hook = generic(&server.url("/hook"), &[]); + let held = slots + .try_acquire(hook.id) + .expect("a fresh hook has a slot"); + + assert!( + !spawn_delivery_with(&slots, hook.clone(), "ping".into(), FAST), + "with no slot the delivery must be dropped" + ); + // A different hook is untouched by the first one's saturation. + let other = db::Webhook { + id: Uuid::from_u128(200), + ..hook.clone() + }; + assert!( + spawn_delivery_with(&slots, other, "ping".into(), FAST), + "another hook must still be delivered" + ); + + drop(held); + assert!( + spawn_delivery_with(&slots, hook, "ping".into(), FAST), + "the slot is available again once the delivery finishes" + ); + + // Exactly the two accepted deliveries reached the endpoint. + eventually("both accepted deliveries", async || { + mock.hits_async() + .await + >= 2 + }) + .await; + assert_eq!( + mock.hits_async() + .await, + 2, + "the dropped delivery must not have been queued" + ); + } + + /// The permit is taken *before* the spawn: acquiring it inside the task + /// would bound sockets but let tasks pile up parked on the semaphore. + /// Nothing is awaited between the call and the assertion, so the spawned + /// task cannot have run — this observes the synchronous acquisition only. + #[tokio::test] + async fn spawn_delivery_takes_its_permit_before_spawning() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/hook"); + then.status(200) + .delay(Duration::from_millis(200)); + }) + .await; + + let slots = DeliverySlots::new(1); + let hook = generic(&server.url("/hook"), &[]); + assert!(spawn_delivery_with( + &slots, + hook.clone(), + "ping".into(), + FAST + )); + assert!( + slots + .try_acquire(hook.id) + .is_none(), + "the permit must already be held before the task is polled" + ); + + // And it is held for the whole delivery, then released. + eventually("the slot to come back", async || { + slots + .try_acquire(hook.id) + .is_some() + }) + .await; + } } From 220a7d8afa710c0fba89ba002db6ee1b1cebf923 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 15:38:09 +0200 Subject: [PATCH 13/29] fix(server): clamp Retry-After before converting, and honour it only on 429 Duration::from_secs_f64 panics outside Duration's range and the cap was applied after the conversion, so a remote endpoint answering with `Retry-After: 1e30` panicked the delivery task. Clamp the f64 first. Also restrict the rate-limit headers to 429: Discord sends X-RateLimit-Reset-After on responses generally, so a 5xx carrying 0 was collapsing the exponential backoff into three immediate retries. --- .../src/services/webhooks/sender.rs | 114 +++++++++++++++++- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index e28a8fa1a..692d20777 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -80,6 +80,12 @@ static DELIVERY_SLOTS: LazyLock = /// Keying by hook id keeps a broken Discord URL from disabling an operator's /// working Slack and Gotify hooks; the total is still bounded, at /// `enabled hooks × limit`. +/// +/// TODO: entries are never removed, so a delete-and-recreate cycle leaves the +/// old hook's semaphore behind forever. It is tens of bytes per entry and only +/// an operator can create one, so it is not worth a mechanism today; when it +/// is, `WebhookService::reload` in `mod.rs` already knows the live hook set and +/// is the natural place to prune from. pub(crate) struct DeliverySlots { limit: usize, per_hook: Mutex>>, @@ -290,7 +296,7 @@ async fn attempt_once( return Ok(response); } let retryability = classify_status(status); - let retry_after = retry_after(response.headers()); + let retry_after = retry_after(status, response.headers()); let detail = response .text() .await @@ -346,9 +352,17 @@ pub(crate) fn classify_status(status: StatusCode) -> Retryability { } } -/// How long the endpoint asked us to wait. `Retry-After` first, then Discord's -/// `X-RateLimit-Reset-After`, which it sends alongside every 429. -fn retry_after(headers: &HeaderMap) -> Option { +/// How long the endpoint asked us to wait, for a rate limit only. +/// +/// `Retry-After` first, then Discord's `X-RateLimit-Reset-After`. Restricted to +/// 429 on purpose: Discord attaches its rate-limit headers to responses +/// generally, so honouring them on a 5xx would let a `x-ratelimit-reset-after: +/// 0` collapse the exponential backoff into three immediate retries against an +/// endpoint that is already struggling. +fn retry_after(status: StatusCode, headers: &HeaderMap) -> Option { + if status != StatusCode::TOO_MANY_REQUESTS { + return None; + } ["retry-after", "x-ratelimit-reset-after"] .into_iter() .filter_map(|name| { @@ -365,6 +379,11 @@ fn retry_after(headers: &HeaderMap) -> Option { /// Only the delta-seconds form is understood — that is what Discord sends, and /// it may be fractional. An HTTP-date, or anything unparseable, yields `None` /// and the normal backoff applies. +/// +/// The cap is applied to the `f64` **before** the conversion: +/// `Duration::from_secs_f64` panics outside `Duration`'s range, and this value +/// comes straight off a remote response header — `Retry-After: 1e30` must be a +/// clamped wait, not a panic in the delivery task. pub(crate) fn parse_retry_after(value: &str) -> Option { let seconds: f64 = value .trim() @@ -373,7 +392,9 @@ pub(crate) fn parse_retry_after(value: &str) -> Option { if !seconds.is_finite() || seconds < 0.0 { return None; } - Some(Duration::from_secs_f64(seconds).min(MAX_RETRY_AFTER)) + Some(Duration::from_secs_f64( + seconds.min(MAX_RETRY_AFTER.as_secs_f64()), + )) } /// `base * 2^attempt` plus jitter in `[0, base/2)`, mirroring @@ -891,6 +912,49 @@ mod tests { assert_eq!(parse_retry_after("999999"), Some(MAX_RETRY_AFTER)); } + /// `Duration::from_secs_f64` panics outside `Duration`'s range, so the cap + /// has to be applied to the `f64` before the conversion. These all parse as + /// finite, positive floats and would otherwise panic the delivery task — + /// remotely, from a response header, on any failing status. + #[test] + fn parse_retry_after_clamps_instead_of_panicking_on_huge_values() { + for value in [ + "1e30", + "99999999999999999999999", + "179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + &f64::MAX.to_string(), + ] { + assert_eq!( + parse_retry_after(value), + Some(MAX_RETRY_AFTER), + "{value} must clamp to the cap, not panic" + ); + } + } + + /// Discord attaches rate-limit headers to responses generally. Obeying them + /// on a 5xx would turn three spaced attempts into an immediate burst + /// against an endpoint that is already failing. + #[test] + fn rate_limit_headers_are_only_honoured_on_a_429() { + let mut headers = HeaderMap::new(); + headers.insert("x-ratelimit-reset-after", HeaderValue::from_static("0")); + headers.insert("retry-after", HeaderValue::from_static("0")); + + assert_eq!( + retry_after(StatusCode::TOO_MANY_REQUESTS, &headers), + Some(Duration::ZERO), + "a 429 is exactly what these headers are for" + ); + for status in [500u16, 502, 503, 408] { + assert_eq!( + retry_after(StatusCode::from_u16(status).unwrap(), &headers), + None, + "{status} must fall back to the exponential backoff" + ); + } + } + // --- send_once: status handling --------------------------------------- #[tokio::test] @@ -1028,6 +1092,46 @@ mod tests { ); } + /// The same headers on a 5xx must be ignored: the exponential schedule has + /// to survive an endpoint that advertises a zero rate-limit reset while it + /// is failing for an unrelated reason. + #[tokio::test] + async fn a_server_error_keeps_the_exponential_schedule() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(500) + .header("x-ratelimit-reset-after", "0") + .header("retry-after", "0"); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let policy = DeliveryPolicy { + attempts: 3, + retry_delay_ms: 150, + }; + let started = Instant::now(); + assert!( + deliver_with(&hook, "ping", &policy) + .await + .is_err() + ); + assert_eq!( + mock.hits_async() + .await, + 3 + ); + // Two backoff sleeps of at least 150 ms and 300 ms. Honouring the + // headers would collapse this to a burst of three immediate requests. + assert!( + started.elapsed() >= Duration::from_millis(400), + "the backoff was skipped, took only {:?}", + started.elapsed() + ); + } + #[tokio::test] async fn delivery_stops_at_the_first_success() { let server = MockServer::start_async().await; From a0a89a373ccf1d10a90b3f2b28e254de8a53a00b Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 16:04:46 +0200 Subject: [PATCH 14/29] feat(server): webhook admin crud api --- crates/remux-server/src/api/mod.rs | 1 + crates/remux-server/src/api/webhooks.rs | 931 ++++++++++++++++++ .../remux-server/src/services/webhooks/mod.rs | 116 ++- .../src/services/webhooks/sender.rs | 40 +- 4 files changed, 1086 insertions(+), 2 deletions(-) create mode 100644 crates/remux-server/src/api/webhooks.rs diff --git a/crates/remux-server/src/api/mod.rs b/crates/remux-server/src/api/mod.rs index 1606229ac..b0afc8c2e 100644 --- a/crates/remux-server/src/api/mod.rs +++ b/crates/remux-server/src/api/mod.rs @@ -35,6 +35,7 @@ pub mod subtitles; pub mod system; pub mod tasks; pub mod users; +pub mod webhooks; use axum::{Json, extract::State, response::IntoResponse}; use http::StatusCode; diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs new file mode 100644 index 000000000..bff8ebc91 --- /dev/null +++ b/crates/remux-server/src/api/webhooks.rs @@ -0,0 +1,931 @@ +//! Admin CRUD over outgoing webhooks, plus the synchronous "test this webhook" +//! endpoint the dashboard uses for immediate feedback. +//! +//! Two invariants hold across every handler here. +//! +//! **Every route is admin-only.** A webhook URL is a credential — Discord's is +//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the entire +//! authentication — so read access is as sensitive as write access. `session: +//! auth::AdminSession` in the signature is the whole mechanism; there is no +//! path into this module without it. +//! +//! **Every mutation ends in `state.ctx.webhooks.invalidate()`.** The dispatcher +//! caches the enabled hook set and reloads only when that flag is set, so a +//! write that skips the call returns a perfect 200 and then silently does +//! nothing until the process restarts. + +use axum::{ + Json, + extract::{Path, State}, + response::IntoResponse, +}; +use http::StatusCode; +use remux_macros::{delete, get, post}; +use remux_sdks::remux::{WebhookDto, WebhookTestResult}; +use std::str::FromStr; +use url::Url; +use uuid::Uuid; + +use crate::{ + AppState, IntoApiError, OptionExt, + db::{self, auth}, + services::webhooks, +}; +use axum_anyhow::ApiResult as Result; + +/// A URL the server is willing to POST a webhook to. +/// +/// Parse, don't validate: the value cannot be constructed from anything but an +/// absolute `http(s)` URL with a host, so nothing downstream — the DB row, the +/// dispatcher's cached snapshot, the delivery task — has to re-check. The +/// scheme restriction is not cosmetic: `Url::parse` cheerfully accepts +/// `file:///etc/shadow` and `javascript:alert(1)`, and neither belongs anywhere +/// near the delivery path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebhookUrl(Url); + +impl WebhookUrl { + /// The canonical serialization — this, not the operator's raw string, is + /// what gets stored. + fn into_stored(self) -> String { + self.0 + .into() + } +} + +/// Why a webhook URL was refused. No variant embeds the offending URL: the +/// message travels back to the browser and into logs, and the URL is a secret. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum WebhookUrlError { + #[error("webhook url must be an absolute URL")] + Malformed, + #[error("webhook url must use http or https")] + UnsupportedScheme, + #[error("webhook url must have a host")] + MissingHost, +} + +impl FromStr for WebhookUrl { + type Err = WebhookUrlError; + + fn from_str(raw: &str) -> std::result::Result { + let url = Url::parse(raw.trim()).map_err(|_| WebhookUrlError::Malformed)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(WebhookUrlError::UnsupportedScheme); + } + if url + .host_str() + .is_none_or(str::is_empty) + { + return Err(WebhookUrlError::MissingHost); + } + Ok(Self(url)) + } +} + +/// `payload` with its URL replaced by the parsed, canonical form — or a 400. +fn with_parsed_url(payload: WebhookDto) -> Result { + match payload + .url + .parse::() + { + Ok(url) => Ok(WebhookDto { + url: url.into_stored(), + ..payload + }), + Err(e) => { + let detail = e.to_string(); + Err(e.context_bad_request(&detail)) + } + } +} + +/// The stored webhook, or a 404. Every by-id route starts here so a missing row +/// is a 404 rather than a 500 out of the repository's re-read. +async fn load(state: &AppState, id: &Uuid) -> Result { + db::Webhook::get_by_id( + &state + .ctx + .db, + id, + ) + .await? + .context_not_found("webhook not found") +} + +/// List every webhook, enabled or not. +#[get("/remux/webhooks")] +pub async fn get_webhooks( + State(state): State, + _session: auth::AdminSession, +) -> Result { + let hooks = db::Webhook::get_all( + &state + .ctx + .db, + ) + .await?; + let dtos: Vec = hooks + .into_iter() + .map(db::Webhook::into_dto) + .collect(); + Ok(Json(dtos)) +} + +/// Read one webhook by id. +#[get("/remux/webhooks/{id}")] +pub async fn get_webhook( + State(state): State, + _session: auth::AdminSession, + Path(id): Path, +) -> Result { + let hook = load(&state, &id).await?; + Ok(Json(hook.into_dto())) +} + +/// Create a webhook. The id carried by the payload is ignored. +#[post("/remux/webhooks")] +pub async fn create_webhook( + State(state): State, + _session: auth::AdminSession, + Json(payload): Json, +) -> Result { + let payload = with_parsed_url(payload)?; + let created = db::Webhook::create( + &state + .ctx + .db, + &payload, + ) + .await?; + state + .ctx + .webhooks + .invalidate(); + Ok(Json(created.into_dto())) +} + +/// Replace every mutable field of a webhook. POST, not PUT — the SDK's +/// `UpdateWebhook` endpoint declares POST and that contract is already merged. +#[post("/remux/webhooks/{id}")] +pub async fn update_webhook( + State(state): State, + _session: auth::AdminSession, + Path(id): Path, + Json(payload): Json, +) -> Result { + load(&state, &id).await?; + let payload = with_parsed_url(payload)?; + let updated = db::Webhook::update( + &state + .ctx + .db, + &id, + &payload, + ) + .await?; + state + .ctx + .webhooks + .invalidate(); + Ok(Json(updated.into_dto())) +} + +/// Delete a webhook. +#[delete("/remux/webhooks/{id}")] +pub async fn delete_webhook( + State(state): State, + _session: auth::AdminSession, + Path(id): Path, +) -> Result { + load(&state, &id).await?; + db::Webhook::delete( + &state + .ctx + .db, + &id, + ) + .await?; + state + .ctx + .webhooks + .invalidate(); + Ok(StatusCode::NO_CONTENT) +} + +/// Send one synthetic `Generic` event to a webhook, right now, and report what +/// the endpoint said. +/// +/// Synchronous and outside the broadcast channel on purpose — see +/// [`webhooks::deliver_test`]. A refusing or unreachable endpoint is a `200` +/// carrying `success: false`: the *request* worked, the *test* did not, and the +/// dashboard needs the difference. +#[post("/remux/webhooks/{id}/test")] +pub async fn test_webhook( + State(state): State, + _session: auth::AdminSession, + Path(id): Path, +) -> Result { + let hook = load(&state, &id).await?; + let result: WebhookTestResult = webhooks::deliver_test(&state.ctx, &hook).await; + Ok(Json(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + integration_test::{ + AUTH_HEADER, auth_header_with_token, authenticated_server, new_test_server, + }, + services::webhooks::WebhookEvent, + }; + use axum_test::TestServer; + use http::header::{HeaderName, HeaderValue}; + use httpmock::{Method::POST, Mock, MockServer}; + use remux_sdks::remux::{ + NotificationType, WebhookDestination, WebhookItemTypes, WebhookKeyValue, + }; + use serde_json::json; + use std::time::{Duration, Instant}; + + /// A body that is valid JSON and echoes exactly one variable, so a received + /// request pins both the template output and the variable dictionary. + const TEMPLATE: &str = r#"{"content":"{{Name}}"}"#; + + fn auth(token: &str) -> (HeaderName, HeaderValue) { + ( + http::header::AUTHORIZATION, + HeaderValue::from_str(&auth_header_with_token(token)).unwrap(), + ) + } + + /// A fully populated create payload. `id` is deliberately non-nil so the + /// round-trip proves the server assigns its own. + fn hook_dto(name: &str, url: &str) -> WebhookDto { + WebhookDto { + id: Uuid::from_u128(0xdead_beef), + name: name.into(), + enabled: true, + url: url.into(), + template: TEMPLATE.into(), + destination: WebhookDestination::Generic { + headers: vec![], + fields: vec![], + }, + notification_types: vec![NotificationType::Generic], + user_filter: vec![], + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + created_at: None, + updated_at: None, + } + } + + async fn create( + server: &TestServer, + h: &HeaderName, + v: &HeaderValue, + dto: &WebhookDto, + ) -> WebhookDto { + server + .post("/remux/webhooks") + .add_header(h.clone(), v.clone()) + .json(dto) + .await + .json() + } + + async fn list( + server: &TestServer, + h: &HeaderName, + v: &HeaderValue, + ) -> Vec { + server + .get("/remux/webhooks") + .add_header(h.clone(), v.clone()) + .await + .json() + } + + fn generic_event() -> WebhookEvent { + WebhookEvent::Generic { + title: "dispatcher probe".into(), + extra: vec![], + } + } + + /// Poll `condition` until it holds, failing the test rather than hanging. + async fn eventually(what: &str, mut condition: impl AsyncFnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !condition().await { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + + /// Give an unwanted delivery every chance to arrive before asserting it did + /// not. The dispatcher spawns deliveries, so "the canary was hit" only + /// proves the event was *dispatched*, not that a stray socket has settled. + async fn settle() { + tokio::time::sleep(Duration::from_millis(250)).await; + } + + async fn hits(mock: &Mock<'_>) -> usize { + mock.hits_async() + .await + } + + // --- WebhookUrl ------------------------------------------------------- + + #[test] + fn a_webhook_url_accepts_http_and_https_and_canonicalises() { + for (raw, stored) in [ + ("https://example.test/hook", "https://example.test/hook"), + ("http://example.test/hook", "http://example.test/hook"), + // Trimmed, and given the path `Url` considers canonical. + (" https://example.test ", "https://example.test/"), + ( + "https://discord.com/api/webhooks/1/tok?wait=true", + "https://discord.com/api/webhooks/1/tok?wait=true", + ), + ] { + let parsed: WebhookUrl = raw + .parse() + .unwrap_or_else(|e| panic!("{raw} must parse: {e}")); + assert_eq!(parsed.into_stored(), stored); + } + } + + #[test] + fn a_webhook_url_rejects_what_cannot_be_posted_to() { + for (raw, expected) in [ + ("", WebhookUrlError::Malformed), + ("not a url", WebhookUrlError::Malformed), + ("example.test/hook", WebhookUrlError::Malformed), + ("/hook", WebhookUrlError::Malformed), + ("file:///etc/shadow", WebhookUrlError::UnsupportedScheme), + ("javascript:alert(1)", WebhookUrlError::UnsupportedScheme), + ( + "ftp://example.test/hook", + WebhookUrlError::UnsupportedScheme, + ), + ] { + assert_eq!( + raw.parse::(), + Err(expected), + "{raw} must be refused" + ); + } + } + + /// The rejection travels back to the browser and into logs, so it must not + /// carry the URL it is rejecting. + #[test] + fn a_url_rejection_never_echoes_the_url() { + let secret = "gopher://discord.com/api/webhooks/1/aVerySecretToken"; + let message = secret + .parse::() + .expect_err("gopher is not a webhook scheme") + .to_string(); + assert!(!message.contains("aVerySecretToken"), "{message}"); + assert!(!message.contains("discord.com"), "{message}"); + } + + // --- CRUD ------------------------------------------------------------- + + #[tokio::test] + async fn crud_round_trip_over_http() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + assert!( + list(&server, &h, &v) + .await + .is_empty(), + "a fresh server has no webhooks" + ); + + let payload = hook_dto("discord", "https://example.test/hook"); + let created = create(&server, &h, &v, &payload).await; + assert_ne!(created.id, payload.id, "the server assigns the id"); + assert_eq!(created.name, "discord"); + assert_eq!(created.url, "https://example.test/hook"); + assert_eq!(created.template, TEMPLATE); + assert!(created.enabled); + assert!( + created + .created_at + .is_some(), + "the stored timestamps come back to the dashboard" + ); + + let all = list(&server, &h, &v).await; + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, created.id); + + let fetched: WebhookDto = server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .await + .json(); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.name, "discord"); + + let update = WebhookDto { + name: "renamed".into(), + enabled: false, + notification_types: vec![NotificationType::ItemAdded], + destination: WebhookDestination::Generic { + headers: vec![WebhookKeyValue { + key: "X-Auth-Token".into(), + value: "s3cret".into(), + }], + fields: vec![], + }, + ..hook_dto("renamed", "https://example.test/other") + }; + let updated: WebhookDto = server + .post(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .json(&update) + .await + .json(); + assert_eq!(updated.id, created.id, "update must not re-key the row"); + assert_eq!(updated.name, "renamed"); + assert_eq!(updated.url, "https://example.test/other"); + assert!(!updated.enabled); + assert_eq!( + updated.notification_types, + vec![NotificationType::ItemAdded] + ); + assert_eq!(updated.destination, update.destination); + + // The update is persisted, not just echoed. + let refetched: WebhookDto = server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .await + .json(); + assert_eq!(refetched.name, "renamed"); + assert_eq!(refetched.destination, update.destination); + + server + .delete(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .await + .assert_status(StatusCode::NO_CONTENT); + + assert!( + list(&server, &h, &v) + .await + .is_empty(), + "the webhook is gone after the delete" + ); + server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(h, v) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn an_unknown_id_is_a_404_on_every_route_that_takes_one() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let missing = Uuid::new_v4(); + + server + .get(&format!("/remux/webhooks/{missing}")) + .add_header(h.clone(), v.clone()) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + + server + .post(&format!("/remux/webhooks/{missing}")) + .add_header(h.clone(), v.clone()) + .expect_failure() + .json(&hook_dto("ghost", "https://example.test/hook")) + .await + .assert_status(StatusCode::NOT_FOUND); + + server + .delete(&format!("/remux/webhooks/{missing}")) + .add_header(h.clone(), v.clone()) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + + server + .post(&format!("/remux/webhooks/{missing}/test")) + .add_header(h, v) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + } + + // --- url validation --------------------------------------------------- + + /// The URL is parsed, not trusted: a hook whose URL cannot be posted to is + /// a hook that fails silently in a background task forever after. + #[tokio::test] + async fn a_url_that_does_not_parse_is_rejected_on_create_and_on_update() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + for bad in [ + "not a url", + "", + "example.test/hook", + "file:///etc/shadow", + "javascript:alert(1)", + ] { + server + .post("/remux/webhooks") + .add_header(h.clone(), v.clone()) + .expect_failure() + .json(&hook_dto("bad", bad)) + .await + .assert_status(StatusCode::BAD_REQUEST); + } + assert!( + list(&server, &h, &v) + .await + .is_empty(), + "a rejected create must not store anything" + ); + + let created = create( + &server, + &h, + &v, + &hook_dto("good", "https://example.test/hook"), + ) + .await; + server + .post(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .expect_failure() + .json(&hook_dto("bad", "not a url")) + .await + .assert_status(StatusCode::BAD_REQUEST); + + let unchanged: WebhookDto = server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(h, v) + .await + .json(); + assert_eq!( + unchanged.url, "https://example.test/hook", + "a rejected update must not touch the stored row" + ); + } + + // --- authorization ---------------------------------------------------- + + #[tokio::test] + async fn every_route_requires_a_session() { + let (server, _guard) = new_test_server() + .await + .unwrap(); + let id = Uuid::new_v4(); + + for response in [ + server + .get("/remux/webhooks") + .expect_failure() + .await, + server + .get(&format!("/remux/webhooks/{id}")) + .expect_failure() + .await, + server + .post("/remux/webhooks") + .expect_failure() + .json(&hook_dto("x", "https://example.test/hook")) + .await, + server + .post(&format!("/remux/webhooks/{id}")) + .expect_failure() + .json(&hook_dto("x", "https://example.test/hook")) + .await, + server + .delete(&format!("/remux/webhooks/{id}")) + .expect_failure() + .await, + server + .post(&format!("/remux/webhooks/{id}/test")) + .expect_failure() + .await, + ] { + response.assert_status(StatusCode::UNAUTHORIZED); + } + } + + /// A webhook URL embeds a credential (Discord's is + /// `.../webhooks/{id}/{token}`), so a non-admin must not be able to read + /// one — not through the list, not through a by-id read. + #[tokio::test] + async fn a_non_admin_cannot_read_or_write_webhooks() { + let (server, _guard, admin_token) = authenticated_server().await; + let (h, v) = auth(&admin_token); + + let created = create( + &server, + &h, + &v, + &hook_dto("secret", "https://discord.test/api/webhooks/1/s3cret"), + ) + .await; + + server + .post("/users/new") + .add_header(h.clone(), v.clone()) + .json(&json!({ "Name": "viewer", "Password": "pass1234" })) + .await + .assert_status_ok(); + let token = server + .post("/users/authenticatebyname") + .add_header( + http::header::AUTHORIZATION, + HeaderValue::from_static(AUTH_HEADER), + ) + .json(&json!({ "Username": "viewer", "Pw": "pass1234" })) + .await + .json::()["AccessToken"] + .as_str() + .unwrap() + .to_string(); + let (uh, uv) = auth(&token); + + for response in [ + server + .get("/remux/webhooks") + .add_header(uh.clone(), uv.clone()) + .expect_failure() + .await, + server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(uh.clone(), uv.clone()) + .expect_failure() + .await, + server + .post("/remux/webhooks") + .add_header(uh.clone(), uv.clone()) + .expect_failure() + .json(&hook_dto("x", "https://example.test/hook")) + .await, + server + .post(&format!("/remux/webhooks/{}", created.id)) + .add_header(uh.clone(), uv.clone()) + .expect_failure() + .json(&hook_dto("x", "https://example.test/hook")) + .await, + server + .delete(&format!("/remux/webhooks/{}", created.id)) + .add_header(uh.clone(), uv.clone()) + .expect_failure() + .await, + server + .post(&format!("/remux/webhooks/{}/test", created.id)) + .add_header(uh, uv) + .expect_failure() + .await, + ] { + response.assert_status(StatusCode::UNAUTHORIZED); + assert!( + !response + .text() + .contains("s3cret"), + "the rejection must not echo the webhook URL" + ); + } + } + + // --- the test endpoint ------------------------------------------------ + + /// The test endpoint bypasses the broadcast channel entirely: the hook here + /// is disabled and subscribes to nothing, so the dispatcher would never + /// deliver to it. It must still be tested, synchronously, and the endpoint's + /// answer must come back to the caller. + #[tokio::test] + async fn the_test_endpoint_delivers_once_and_reports_the_status() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/hook") + .header("x-auth-token", "s3cret") + .header("content-type", "application/json; charset=utf-8") + .body(r#"{"content":"Test notification"}"#); + then.status(202); + }) + .await; + + let dto = WebhookDto { + enabled: false, + notification_types: vec![], + destination: WebhookDestination::Generic { + headers: vec![WebhookKeyValue { + key: "X-Auth-Token".into(), + value: "s3cret".into(), + }], + fields: vec![], + }, + ..hook_dto("under test", &endpoint_server.url("/hook")) + }; + let created = create(&server, &h, &v, &dto).await; + + let result: WebhookTestResult = server + .post(&format!("/remux/webhooks/{}/test", created.id)) + .add_header(h, v) + .await + .json(); + + endpoint + .assert_hits_async(1) + .await; + assert!(result.success, "202 is a success: {result:?}"); + assert_eq!(result.status_code, Some(202)); + assert_eq!(result.error, None); + } + + /// A failing endpoint is a failed *test*, not a failed request: the + /// dashboard needs the status to show it. And it is one attempt — the retry + /// policy belongs to background delivery, not to an operator waiting on an + /// answer. + #[tokio::test] + async fn the_test_endpoint_reports_a_rejecting_endpoint_without_retrying() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/hook"); + then.status(500) + .body("nope"); + }) + .await; + + let created = create( + &server, + &h, + &v, + &hook_dto("under test", &endpoint_server.url("/hook")), + ) + .await; + + let result: WebhookTestResult = server + .post(&format!("/remux/webhooks/{}/test", created.id)) + .add_header(h, v) + .await + .json(); + + assert!(!result.success); + assert_eq!(result.status_code, Some(500)); + let error = result + .error + .expect("a failed test must carry an error"); + assert!(error.contains("500"), "{error}"); + endpoint + .assert_hits_async(1) + .await; + } + + /// An unreachable endpoint must come back as a failed test rather than + /// hanging the handler or leaking the URL path into the response. + #[tokio::test] + async fn the_test_endpoint_reports_an_unreachable_endpoint() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + let created = create( + &server, + &h, + &v, + &hook_dto("dead", "http://127.0.0.1:1/api/webhooks/1/s3cret"), + ) + .await; + + let result: WebhookTestResult = server + .post(&format!("/remux/webhooks/{}/test", created.id)) + .add_header(h, v) + .await + .json(); + + assert!(!result.success); + assert_eq!(result.status_code, None); + let error = result + .error + .expect("a failed test must carry an error"); + assert!( + !error.contains("s3cret"), + "the URL path is a credential and must not be echoed: {error}" + ); + } + + // --- dispatcher cache invalidation ------------------------------------ + + /// `invalidate()` is how a saved webhook reaches the *running* dispatcher: + /// it caches the enabled hook set and reloads only when that flag is set. + /// A create, update or delete that forgets the call looks perfect over HTTP + /// and silently does nothing until the process restarts — so this drives + /// the real cycle (write over HTTP, emit an event, watch the socket). + /// + /// The canary hook is never touched after its creation. Its hit count is + /// the synchronisation point: once it has seen event N, the dispatcher has + /// finished dispatching event N, which is what makes the negative + /// assertions below meaningful rather than a race. + #[tokio::test] + async fn create_update_and_delete_each_reach_the_running_dispatcher() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + + let mut endpoint = |path: &'static str| { + endpoint_server.mock(|when, then| { + when.method(POST) + .path(path); + then.status(200); + }) + }; + let canary_ep = endpoint("/canary"); + let first_ep = endpoint("/first"); + let second_ep = endpoint("/second"); + + create( + &server, + &h, + &v, + &hook_dto("canary", &endpoint_server.url("/canary")), + ) + .await; + let created = create( + &server, + &h, + &v, + &hook_dto("under test", &endpoint_server.url("/first")), + ) + .await; + + // 1. create — the dispatcher booted with an empty cache. + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the created webhooks to be delivered", async || { + hits(&canary_ep).await == 1 && hits(&first_ep).await == 1 + }) + .await; + + // 2. update — the new URL is only reachable through a reload. + server + .post(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .json(&hook_dto("under test", &endpoint_server.url("/second"))) + .await + .assert_status_ok(); + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the updated webhook to be delivered", async || { + hits(&canary_ep).await == 2 && hits(&second_ep).await == 1 + }) + .await; + settle().await; + assert_eq!( + hits(&first_ep).await, + 1, + "the pre-update URL must not be posted to again" + ); + + // 3. delete — the hook must stop being delivered to. + server + .delete(&format!("/remux/webhooks/{}", created.id)) + .add_header(h, v) + .await + .assert_status(StatusCode::NO_CONTENT); + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the canary to see the third event", async || { + hits(&canary_ep).await == 3 + }) + .await; + settle().await; + assert_eq!( + hits(&second_ep).await, + 1, + "a deleted webhook must stop receiving events" + ); + } +} diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index 755b0c471..5fd3f7b8e 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -16,7 +16,7 @@ pub use events::{ }; use crate::{AppContext, db}; -use remux_sdks::remux::NotificationType; +use remux_sdks::remux::{NotificationType, WebhookTestResult}; use std::{ collections::HashSet, sync::{ @@ -34,6 +34,9 @@ use tracing::warn; /// (one enrichment round-trip) never drops events under normal playback load. const EVENT_CHANNEL_CAPACITY: usize = 4096; +/// `Name` seen by the template of the synthetic event [`deliver_test`] sends. +pub const TEST_EVENT_TITLE: &str = "Test notification"; + /// The enabled webhooks as last read from the database, plus everything derived /// from them that would otherwise be recomputed per event. pub(crate) struct LoadedWebhooks { @@ -274,6 +277,58 @@ impl WebhookService { } } +// --- the admin "test this webhook" path -------------------------------------- + +/// Render `hook`'s body for the synthetic test event. +/// +/// The template is compiled here rather than taken from the dispatcher's cached +/// registry: the hook being tested was very likely saved a moment ago, and that +/// cache only reloads when the dispatcher next sees an event. Testing a hook +/// against a stale template would be worse than not testing it. +fn test_body( + server: &payload::ServerInfo, + hook: &db::Webhook, +) -> anyhow::Result> { + let event = WebhookEvent::Generic { + title: TEST_EVENT_TITLE.to_string(), + extra: Vec::new(), + }; + let data = payload::build_data(server, &event, None); + let registry = template::build_registry(std::slice::from_ref(hook)); + template::render(hook, ®istry, &data) +} + +/// Deliver one synthetic `Generic` event to `hook` and report what happened. +/// +/// Deliberately not routed through [`WebhookService::emit`]: the broadcast path +/// is fire-and-forget, filtered by the hook's own subscription and retried in +/// the background, and none of that can answer "did *this* webhook work?". +/// A hook that is disabled, or subscribes to nothing, is still testable — that +/// is the point of the button. +/// +/// One attempt, no retry, and the answer handed straight back to the caller. +pub async fn deliver_test(ctx: &AppContext, hook: &db::Webhook) -> WebhookTestResult { + let server = payload::ServerInfo::load(ctx).await; + match test_body(&server, hook) { + Ok(Some(body)) => sender::send_test(hook, &body).await, + // `skip_empty_message_body` would drop this delivery in production, so + // reporting a success here would be a lie. + Ok(None) => WebhookTestResult { + success: false, + status_code: None, + error: Some( + "the template rendered an empty body and this webhook skips empty bodies" + .to_string(), + ), + }, + Err(e) => WebhookTestResult { + success: false, + status_code: None, + error: Some(format!("template render failed: {e}")), + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -366,6 +421,65 @@ mod tests { } } + // --- the test event --------------------------------------------------- + + fn test_server_info() -> payload::ServerInfo { + payload::ServerInfo { + id: "server-1".into(), + name: "remux".into(), + version: "0.0.0".into(), + url: "https://remux.test".into(), + } + } + + /// The dashboard's test button is only useful if the body it sends is the + /// body a real event would send, built from the same dictionary. + #[test] + fn the_test_event_renders_the_title_and_the_server_variables() { + let hook = db::Webhook { + template: r#"{"content":"{{Name}}","server":"{{ServerName}}","type":"{{NotificationType}}"}"#.into(), + ..permissive(vec![NotificationType::Generic]) + }; + + let body = test_body(&test_server_info(), &hook) + .expect("the fixture template must render") + .expect("a non-empty body must be produced"); + + assert_eq!( + body, + r#"{"content":"Test notification","server":"remux","type":"Generic"}"# + ); + } + + /// The hook's template is compiled for this call, so a hook the dispatcher + /// has never seen is still testable. + #[test] + fn a_template_that_does_not_compile_is_reported_not_panicked() { + let hook = db::Webhook { + template: "{{#if_equals A}}unclosed".into(), + ..permissive(vec![NotificationType::Generic]) + }; + assert!( + test_body(&test_server_info(), &hook).is_err(), + "an uncompilable template must surface as an error" + ); + } + + /// `skip_empty_message_body` drops the delivery in production; the test + /// endpoint must say so rather than claim a success it never attempted. + #[test] + fn an_empty_body_is_reported_rather_than_posted() { + let hook = db::Webhook { + template: " ".into(), + skip_empty_message_body: true, + ..permissive(vec![NotificationType::Generic]) + }; + assert_eq!( + test_body(&test_server_info(), &hook).expect("rendering must succeed"), + None + ); + } + // --- cached snapshot ------------------------------------------------- /// The registry is built in two places (here and in `reload`). Both must diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index 692d20777..4bf619c04 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -22,7 +22,7 @@ //! log line in this module may contain a URL path or query; see [`redact_url`]. use crate::db; -use remux_sdks::remux::WebhookDestination; +use remux_sdks::remux::{WebhookDestination, WebhookTestResult}; use reqwest::{ StatusCode, header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, @@ -258,6 +258,37 @@ pub(crate) async fn send_once( .map_err(anyhow::Error::from) } +/// One POST, reported as the admin API's "test this webhook" result. +/// +/// A single attempt on purpose: the retry policy exists so a transient failure +/// does not lose a *notification*, but here an operator is waiting on the +/// answer and what they need to see is what the endpoint said just now. The +/// process-wide [`REQUEST_TIMEOUT`] still applies, so a hostile URL cannot pin +/// the request handler. +/// +/// The error text comes from [`SendError`], which is already redacted — a +/// webhook URL is a credential and must not travel back to the browser. +pub(crate) async fn send_test(hook: &db::Webhook, body: &str) -> WebhookTestResult { + match attempt_once(hook, body).await { + Ok(response) => WebhookTestResult { + success: true, + status_code: Some( + response + .status() + .as_u16(), + ), + error: None, + }, + Err(e) => WebhookTestResult { + success: false, + status_code: e + .status + .map(|status| status.as_u16()), + error: Some(e.to_string()), + }, + } +} + /// One POST, classified. /// /// `reqwest` treats a 4xx/5xx as a perfectly good response, so the status is @@ -287,6 +318,8 @@ async fn attempt_once( Retryability::Transient }, retry_after: None, + // Nothing reached the endpoint, so there is no status to report. + status: None, // `reqwest`'s Display includes the URL, which is the credential. message: format!("request failed: {}", redact_reqwest_error(&e)), })?; @@ -304,6 +337,7 @@ async fn attempt_once( Err(SendError { retryability, retry_after, + status: Some(status), message: format!( "endpoint returned {status}: {}", truncate(detail.trim(), MAX_LOGGED_RESPONSE) @@ -324,6 +358,10 @@ pub(crate) struct SendError { pub retryability: Retryability, /// The endpoint's own instruction, when it sent one. pub retry_after: Option, + /// The status the endpoint answered with, or `None` when the request never + /// got that far. Reported by [`send_test`]; the retry loop only cares about + /// [`Retryability`]. + pub status: Option, message: String, } From eb16c978255507c652f834b36e330fad4b20bffa Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 16:25:46 +0200 Subject: [PATCH 15/29] fix(server): stop echoing webhook responses, block redirects, bound test timeout --- crates/remux-server/src/api/webhooks.rs | 22 +- .../src/services/webhooks/sender.rs | 227 +++++++++++++++++- 2 files changed, 234 insertions(+), 15 deletions(-) diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index bff8ebc91..c67bba1ef 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -760,17 +760,24 @@ mod tests { /// dashboard needs the status to show it. And it is one attempt — the retry /// policy belongs to background delivery, not to an operator waiting on an /// answer. + /// + /// The remote's **response body** must not come back. The URL is + /// admin-controlled and unrestricted by host, so echoing what the endpoint + /// said would make this route a read primitive against anything the server + /// can reach; this asserts on the raw HTTP response, not just the parsed + /// field, so no route out of the handler is missed. #[tokio::test] async fn the_test_endpoint_reports_a_rejecting_endpoint_without_retrying() { let (server, _guard, token) = authenticated_server().await; let (h, v) = auth(&token); let endpoint_server = MockServer::start_async().await; + let leak = "consul-token=s3cret internal detail"; let endpoint = endpoint_server .mock_async(|when, then| { when.method(POST) .path("/hook"); then.status(500) - .body("nope"); + .body(leak); }) .await; @@ -782,18 +789,25 @@ mod tests { ) .await; - let result: WebhookTestResult = server + let response = server .post(&format!("/remux/webhooks/{}/test", created.id)) .add_header(h, v) - .await - .json(); + .await; + let raw = response.text(); + let result: WebhookTestResult = response.json(); assert!(!result.success); assert_eq!(result.status_code, Some(500)); let error = result .error + .clone() .expect("a failed test must carry an error"); assert!(error.contains("500"), "{error}"); + assert!( + !raw.contains("consul-token"), + "the remote response body must not reach the admin API: {raw}" + ); + assert!(!raw.contains("internal detail"), "{raw}"); endpoint .assert_hits_async(1) .await; diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index 4bf619c04..a82565f9f 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -41,6 +41,11 @@ use uuid::Uuid; /// then blackholes it would hold its task — and its delivery slot — forever. const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Timeout for the admin "test this webhook" request, applied per request so +/// [`REQUEST_TIMEOUT`] keeps governing background delivery. Shorter because the +/// caller is an operator watching a button, not a retrying background task. +const TEST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + /// Ceiling on deliveries in flight **per hook**. const MAX_CONCURRENT_DELIVERIES_PER_HOOK: usize = 4; @@ -59,10 +64,18 @@ const MAX_LOGGED_RESPONSE: usize = 512; /// One client for the whole process: a client per delivery would rebuild the /// TLS config and throw away the connection pool on every event. +/// +/// Redirects are **not** followed. `reqwest`'s default is up to ten hops, which +/// would let a webhook URL the operator vetted hand the request to a host they +/// never saw — the redirect target is chosen by the remote server, at request +/// time, and would be reached from inside the network the server runs in. No +/// real webhook receiver (Discord, Slack, Gotify, Teams) redirects, so there is +/// nothing to trade away: a 3xx is simply reported as the non-2xx it is. static WEBHOOK_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .user_agent("remux-server/1.0") .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) .build() .expect("failed to build the webhook HTTP client") }); @@ -262,14 +275,30 @@ pub(crate) async fn send_once( /// /// A single attempt on purpose: the retry policy exists so a transient failure /// does not lose a *notification*, but here an operator is waiting on the -/// answer and what they need to see is what the endpoint said just now. The -/// process-wide [`REQUEST_TIMEOUT`] still applies, so a hostile URL cannot pin -/// the request handler. +/// answer and what they need to see is what the endpoint said just now. /// -/// The error text comes from [`SendError`], which is already redacted — a -/// webhook URL is a credential and must not travel back to the browser. +/// **The remote response body never travels back to the caller.** The hook's +/// URL, headers and body are all admin-controlled and unrestricted by host, so +/// echoing the response would turn this endpoint into a read primitive: point a +/// hook at an internal service, press Test, and read its reply out of the admin +/// API — from where it reaches browser devtools and support tickets. Only the +/// status line comes back; the body stays in the server-side log line that +/// [`deliver_logged`] writes. +/// +/// The transport-error text is [`SendError`]'s, which is already redacted — a +/// webhook URL is a credential and must not travel back either. pub(crate) async fn send_test(hook: &db::Webhook, body: &str) -> WebhookTestResult { - match attempt_once(hook, body).await { + send_test_with(hook, body, TEST_REQUEST_TIMEOUT).await +} + +/// [`send_test`] with the timeout injected, so a test can prove it is applied +/// without waiting for the real one. +async fn send_test_with( + hook: &db::Webhook, + body: &str, + timeout: Duration, +) -> WebhookTestResult { + match attempt_once_within(hook, body, Some(timeout)).await { Ok(response) => WebhookTestResult { success: true, status_code: Some( @@ -279,24 +308,47 @@ pub(crate) async fn send_test(hook: &db::Webhook, body: &str) -> WebhookTestResu ), error: None, }, + // Status only. `e.message` carries up to MAX_LOGGED_RESPONSE bytes of + // the remote body and must not leave the server. + Err(SendError { + status: Some(status), + .. + }) => WebhookTestResult { + success: false, + status_code: Some(status.as_u16()), + error: Some(format!("endpoint returned {status}")), + }, + // Nothing reached the endpoint: DNS, connect, TLS or timeout. The + // message is ours, not the remote's. Err(e) => WebhookTestResult { success: false, - status_code: e - .status - .map(|status| status.as_u16()), + status_code: None, error: Some(e.to_string()), }, } } +/// One POST under the client's own [`REQUEST_TIMEOUT`]. +async fn attempt_once( + hook: &db::Webhook, + body: &str, +) -> Result { + attempt_once_within(hook, body, None).await +} + /// One POST, classified. /// /// `reqwest` treats a 4xx/5xx as a perfectly good response, so the status is /// checked here: without this every failed delivery would be reported as a -/// success and the retry would never fire. -async fn attempt_once( +/// success and the retry would never fire. Redirects are not followed (see +/// [`WEBHOOK_CLIENT`]), so a 3xx lands in the same non-2xx branch. +/// +/// `timeout` overrides the client default for this request only; `None` keeps +/// [`REQUEST_TIMEOUT`]. +async fn attempt_once_within( hook: &db::Webhook, body: &str, + timeout: Option, ) -> Result { let shaped = shape_request(hook, body); let mut request = WEBHOOK_CLIENT @@ -305,6 +357,9 @@ async fn attempt_once( for (name, value) in shaped.headers { request = request.header(name, value); } + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } let response = request .body(shaped.body) .send() @@ -1037,6 +1092,156 @@ mod tests { ); } + // --- send_test: what reaches the admin API ----------------------------- + + /// The hook's URL, headers and body are all admin-controlled and no host + /// policy restricts them, so returning the endpoint's *response body* would + /// make the test button a read primitive: aim a hook at an internal service, + /// press Test, and read its reply out of the admin API response. Only the + /// status may come back. + #[tokio::test] + async fn send_test_reports_the_status_without_the_remote_response_body() { + let server = MockServer::start_async().await; + let secret = "consul-token=s3cret internal detail"; + let mock = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(500) + .body(secret); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let result = send_test(&hook, "ping").await; + + assert!(!result.success); + assert_eq!(result.status_code, Some(500)); + let error = result + .error + .expect("a failed test must carry an error"); + assert_eq!(error, "endpoint returned 500 Internal Server Error"); + assert!( + !error.contains("consul-token"), + "the remote body must not reach the caller: {error}" + ); + assert!(!error.contains("internal detail"), "{error}"); + mock.assert_hits_async(1) + .await; + } + + #[tokio::test] + async fn send_test_reports_a_2xx_as_a_success() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/hook"); + then.status(202); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let result = send_test(&hook, "ping").await; + assert!(result.success); + assert_eq!(result.status_code, Some(202)); + assert_eq!(result.error, None); + } + + /// The test request carries its own, shorter timeout so a blackholing + /// endpoint cannot hold an admin request handler for the full + /// [`REQUEST_TIMEOUT`]. Injected here so the test costs milliseconds. + #[tokio::test] + async fn send_test_applies_its_own_timeout_to_the_request() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/hook"); + then.status(200) + .delay(Duration::from_secs(5)); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let started = Instant::now(); + let result = send_test_with(&hook, "ping", Duration::from_millis(100)).await; + let elapsed = started.elapsed(); + + assert!(!result.success, "a timed-out request is not a success"); + assert_eq!(result.status_code, None, "nothing answered"); + assert!( + elapsed < Duration::from_secs(2), + "the per-request timeout must fire long before the response: {elapsed:?}" + ); + } + + // --- redirects ---------------------------------------------------------- + + /// `reqwest` follows up to ten redirects by default, which would let a + /// vetted webhook URL hand the request to a host chosen by the remote server + /// at request time — reached from inside the network the server runs in. + /// The redirect is reported as the non-2xx it is instead. + #[tokio::test] + async fn a_redirect_is_not_followed() { + let server = MockServer::start_async().await; + let redirect = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(302) + .header("location", "/internal"); + }) + .await; + let target = server + .mock_async(|when, then| { + when.path("/internal"); + then.status(200) + .body("secrets"); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + let result = send_test(&hook, "ping").await; + + redirect + .assert_hits_async(1) + .await; + target + .assert_hits_async(0) + .await; + assert!(!result.success, "a 302 is not a delivered webhook"); + assert_eq!(result.status_code, Some(302)); + } + + /// The same policy has to hold on the background delivery path, and a 302 + /// must not be retried — repeating it verbatim would never succeed. + #[tokio::test] + async fn a_redirect_is_not_followed_or_retried_during_delivery() { + let server = MockServer::start_async().await; + let redirect = server + .mock_async(|when, then| { + when.path("/hook"); + then.status(302) + .header("location", "/internal"); + }) + .await; + let target = server + .mock_async(|when, then| { + when.path("/internal"); + then.status(200); + }) + .await; + + let hook = generic(&server.url("/hook"), &[]); + deliver_with(&hook, "ping", &FAST) + .await + .expect_err("a redirect is not a delivery"); + + redirect + .assert_hits_async(1) + .await; + target + .assert_hits_async(0) + .await; + } + // --- retry ------------------------------------------------------------- #[tokio::test] From d0f29f10e535e19f1d428545efecb51f96633c11 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 17:00:29 +0200 Subject: [PATCH 16/29] feat(server): emit webhook events across server --- crates/remux-server/src/api/items.rs | 31 +++ crates/remux-server/src/api/session.rs | 176 ++++++++++++++- crates/remux-server/src/api/startup.rs | 6 + crates/remux-server/src/api/system.rs | 7 + crates/remux-server/src/api/users.rs | 183 +++++++++++++++- crates/remux-server/src/api/webhooks.rs | 205 ++++++++++++++++++ crates/remux-server/src/db/auth.rs | 57 ++--- crates/remux-server/src/lib.rs | 3 + crates/remux-server/src/playback_session.rs | 85 +++++++- .../src/services/webhooks/events.rs | 30 +++ .../remux-server/src/services/webhooks/mod.rs | 154 ++++++++++++- .../src/services/webhooks/payload.rs | 9 +- .../src/tasks/catalog_import_shared.rs | 19 +- crates/remux-server/src/tasks/mod.rs | 12 +- 14 files changed, 926 insertions(+), 51 deletions(-) diff --git a/crates/remux-server/src/api/items.rs b/crates/remux-server/src/api/items.rs index b8a69da6a..25d9ca499 100644 --- a/crates/remux-server/src/api/items.rs +++ b/crates/remux-server/src/api/items.rs @@ -24,6 +24,7 @@ use crate::{ }; use axum_anyhow::ApiResult as Result; use chrono::{Datelike, Utc}; +use remux_sdks::remux::NotificationType; use sqlx::SqlitePool; use super::{mock_items, stub_json}; @@ -935,6 +936,28 @@ pub async fn delete_item( _session: auth::AdminSession, Path(id): Path, ) -> Result { + // The payload is built from the row itself (name, kind, overview, …), so it + // has to be captured before the DELETE — afterwards there is nothing left + // to describe. Best-effort and only when a webhook subscribes: this is an + // extra read on a path that otherwise does none, and a failed read must not + // turn a working delete into an error. + let deleted = if state + .ctx + .webhooks + .wants(NotificationType::ItemDeleted) + { + db::Media::get_by_id( + &state + .ctx + .db, + &id, + ) + .await + .ok() + .flatten() + } else { + None + }; db::Media::delete( &state .ctx @@ -946,6 +969,14 @@ pub async fn delete_item( .ctx .ws_tx .send(crate::ws::WsEvent::LibraryChanged); + if let Some(item) = deleted { + state + .ctx + .webhooks + .emit(crate::services::webhooks::WebhookEvent::ItemDeleted { + item: Box::new(item), + }); + } Ok(StatusCode::NO_CONTENT) } diff --git a/crates/remux-server/src/api/session.rs b/crates/remux-server/src/api/session.rs index c16f29e68..d86d790c6 100644 --- a/crates/remux-server/src/api/session.rs +++ b/crates/remux-server/src/api/session.rs @@ -21,8 +21,36 @@ use crate::{ db, db::auth, playback::session::TranscodeSession, - services::MediaResolveService, + services::{ + MediaResolveService, + webhooks::{PlaybackEventData, UserDataSaveReason, WebhookEvent}, + }, }; +use remux_sdks::remux::NotificationType; + +/// The playback state the three playback webhook events share, read off the +/// authenticated session and the report the client just sent. +/// +/// Only ever called behind a `webhooks.wants(..)` probe: it clones the username +/// and the device strings, which is not something a progress tick should pay +/// for when no webhook is listening. +fn playback_event( + session: &auth::AuthSession, + data: &api::PlaybackInfo, + item_id: Uuid, + position_ticks: i64, +) -> PlaybackEventData { + PlaybackEventData { + user: (&session.user).into(), + item_id, + device: (&session.device).into(), + position_ticks, + is_paused: data.is_paused, + play_method: data + .play_method + .clone(), + } +} #[post("/sessions/logout")] pub async fn sessions_logout( @@ -113,6 +141,29 @@ pub async fn report_playback_start( .ctx .ws_tx .send(crate::ws::WsEvent::SessionsChanged); + // A nil item id makes `start` a no-op (no session is created), so there is + // no playback to report. + if !data + .item_id + .is_nil() + && state + .ctx + .webhooks + .wants(NotificationType::PlaybackStart) + { + state + .ctx + .webhooks + .emit(WebhookEvent::PlaybackStart { + playback: playback_event( + &session, + &data, + data.item_id, + data.position_ticks + .unwrap_or(0), + ), + }); + } Ok(StatusCode::NO_CONTENT.into_response()) } @@ -154,6 +205,54 @@ pub async fn report_playback_progress( .ctx .ws_tx .send(crate::ws::WsEvent::SessionsChanged); + + let wants_progress = state + .ctx + .webhooks + .wants(NotificationType::PlaybackProgress); + let wants_user_data = state + .ctx + .webhooks + .wants(NotificationType::UserDataSaved); + // Read back the session `progress` just updated. It is gone when the + // report belonged to no session, or to a ghost one that was evicted — + // in both cases nothing was recorded, so nothing is reported. + if let Some(ps) = (wants_progress || wants_user_data) + .then(|| { + state + .ctx + .sessions + .get(psid) + }) + .flatten() + { + let item_id = ps.item_id; + if !item_id.is_nil() { + if wants_progress { + state + .ctx + .webhooks + .emit(WebhookEvent::PlaybackProgress { + playback: playback_event( + &session, + &data, + item_id, + ps.position_ticks, + ), + }); + } + if wants_user_data { + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: (&session.user).into(), + item_id, + save_reason: UserDataSaveReason::PlaybackProgress, + }); + } + } + } } Ok(StatusCode::NO_CONTENT.into_response()) } @@ -179,6 +278,25 @@ pub async fn report_playback_stopped( .map(|s| s.play_session_id) }); if let Some(ref psid) = effective_psid { + let wants_stop = state + .ctx + .webhooks + .wants(NotificationType::PlaybackStop); + let wants_user_data = state + .ctx + .webhooks + .wants(NotificationType::UserDataSaved); + // `stopped` removes the session, so its final item/position have to be + // read before the call, exactly as `stopped` itself reads them. + let ps = (wants_stop || wants_user_data) + .then(|| { + state + .ctx + .sessions + .get(psid) + }) + .flatten(); + state .ctx .sessions @@ -196,6 +314,46 @@ pub async fn report_playback_stopped( .ctx .ws_tx .send(crate::ws::WsEvent::SessionsChanged); + + if let Some(item_id) = Some(data.item_id) + .filter(|id| !id.is_nil()) + .or_else(|| { + ps.as_ref() + .map(|s| s.item_id) + }) + .filter(|id| !id.is_nil()) + { + if wants_stop { + let position_ticks = data + .position_ticks + .or_else(|| { + ps.as_ref() + .map(|s| s.position_ticks) + }) + .unwrap_or(0); + state + .ctx + .webhooks + .emit(WebhookEvent::PlaybackStop { + playback: playback_event( + &session, + &data, + item_id, + position_ticks, + ), + }); + } + if wants_user_data { + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: (&session.user).into(), + item_id, + save_reason: UserDataSaveReason::PlaybackFinished, + }); + } + } } Ok(StatusCode::NO_CONTENT.into_response()) } @@ -783,6 +941,14 @@ pub async fn user_mark_played( server_config.release_date_threshold(), ) .await?; + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: (&user).into(), + item_id: media.id, + save_reason: UserDataSaveReason::TogglePlayed, + }); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } @@ -805,6 +971,14 @@ pub async fn user_unmark_played( true, ) .await?; + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: (&user).into(), + item_id: media.id, + save_reason: UserDataSaveReason::TogglePlayed, + }); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } diff --git a/crates/remux-server/src/api/startup.rs b/crates/remux-server/src/api/startup.rs index 2edba083b..3f7f585df 100644 --- a/crates/remux-server/src/api/startup.rs +++ b/crates/remux-server/src/api/startup.rs @@ -77,6 +77,12 @@ pub async fn post_startup_configuration( &config, ) .await?; + // The server name is baked into every webhook payload from the dispatcher's + // cached snapshot; this is what makes it re-read the new one. + state + .ctx + .webhooks + .invalidate(); Ok(StatusCode::NO_CONTENT) } diff --git a/crates/remux-server/src/api/system.rs b/crates/remux-server/src/api/system.rs index 05e9af25c..e2196bed2 100644 --- a/crates/remux-server/src/api/system.rs +++ b/crates/remux-server/src/api/system.rs @@ -239,6 +239,13 @@ pub async fn update_system_configuration( &config, ) .await?; + // The server name reaches every webhook payload through the dispatcher's + // cached snapshot, which only reloads when this flag is set. Without this, + // renaming the server keeps shipping the old name until a restart. + state + .ctx + .webhooks + .invalidate(); Ok(StatusCode::NO_CONTENT) } diff --git a/crates/remux-server/src/api/users.rs b/crates/remux-server/src/api/users.rs index 3629906b4..6d5da986a 100644 --- a/crates/remux-server/src/api/users.rs +++ b/crates/remux-server/src/api/users.rs @@ -21,11 +21,14 @@ use crate::{ common::{get_uuid, server_id}, db, db::{auth, user::User}, - services::MediaResolveService, + services::{ + MediaResolveService, + webhooks::{DeviceEventData, UserDataSaveReason, UserEventData, WebhookEvent}, + }, ws::WsEvent, }; use axum_anyhow::ApiResult as Result; -use remux_sdks::remux::Username; +use remux_sdks::remux::{NotificationType, Username}; use super::{ items::{ItemsQueryResultBuilder, item, items, items_flat}, @@ -203,6 +206,63 @@ fn require_self_or_admin(target_id: Uuid, session: &auth::AuthSession) -> Result Ok(()) } +/// Report that a user's data for one item changed. +/// +/// Instrumented in the handlers rather than in `db::UserMediaState` on purpose: +/// the reason is a property of the request (a favourite toggle, a played +/// toggle, a position save), not of the row being written, and the db layer +/// stays free of the event bus. +fn emit_user_data_saved( + state: &AppState, + user: &db::User, + item_id: Uuid, + save_reason: UserDataSaveReason, +) { + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: user.into(), + item_id, + save_reason, + }); +} + +/// Report a successful login. +/// +/// Jellyfin raises both events for one login: `AuthenticationSuccess` is the +/// credential check, `SessionStart` is the session it opened. Webhooks +/// subscribe to them separately, so both are emitted. +/// +/// `remote_ip` is passed in rather than read off `device`: the row is being +/// created by this very request and has no IP recorded yet. +fn emit_authenticated( + state: &AppState, + user: &db::User, + device: &auth::Device, + remote_ip: Option, +) { + let user_data: UserEventData = user.into(); + let device_data = DeviceEventData { + remote_ip, + ..device.into() + }; + state + .ctx + .webhooks + .emit(WebhookEvent::AuthenticationSuccess { + user: user_data.clone(), + device: device_data.clone(), + }); + state + .ctx + .webhooks + .emit(WebhookEvent::SessionStart { + user: user_data, + device: device_data, + }); +} + fn build_auth_response( data_dir: &std::path::Path, device: auth::Device, @@ -268,21 +328,37 @@ fn build_auth_response( pub async fn users_authenticatebyname( State(state): State, auth_header: auth::JellyfinAuthHeader, + headers: header::HeaderMap, Json(data): Json, ) -> Result { - let user = User::authenticate( + let username = data + .username + .as_deref() + .unwrap_or(""); + let authenticated = User::authenticate( &state .ctx .db, - data.username - .as_deref() - .unwrap_or(""), + username, data.pw .as_deref() .unwrap_or(""), ) - .await? - .context_unauthorized("not found")?; + .await?; + // `authenticate` answers `Ok(None)` for both an unknown user and a wrong + // password, and only for those — a DB failure is an error, not a failed + // login, and must not be reported as one. The `?` below is untouched, so + // the refusal is the same 401 with the same body and the same timing. + if authenticated.is_none() { + state + .ctx + .webhooks + .emit(WebhookEvent::AuthenticationFailure { + username: username.to_string(), + remote_ip: auth::remote_ip_from_headers(&headers), + }); + } + let user = authenticated.context_unauthorized("not found")?; let device = auth::Device::new_from_header(auth_header, &user)?; device .save( @@ -291,6 +367,12 @@ pub async fn users_authenticatebyname( .db, ) .await?; + emit_authenticated( + &state, + &user, + &device, + auth::remote_ip_from_headers(&headers), + ); Ok(build_auth_response( &state @@ -306,6 +388,7 @@ pub async fn users_authenticatebyname( pub async fn authenticate_with_quickconnect( State(state): State, auth_header: auth::JellyfinAuthHeader, + headers: header::HeaderMap, Json(body): Json, ) -> Result { let entry = state @@ -359,6 +442,13 @@ pub async fn authenticate_with_quickconnect( ) .await?; + emit_authenticated( + &state, + &user, + &device, + auth::remote_ip_from_headers(&headers), + ); + // clean up store entries state .ctx @@ -444,6 +534,12 @@ pub async fn mark_favorite( &session.user, ) .await?; + emit_user_data_saved( + &state, + &session.user, + media.id, + UserDataSaveReason::ToggleFavorite, + ); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } @@ -464,6 +560,12 @@ pub async fn unmark_favorite( &session.user, ) .await?; + emit_user_data_saved( + &state, + &session.user, + media.id, + UserDataSaveReason::ToggleFavorite, + ); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } @@ -484,6 +586,12 @@ pub async fn mark_favorite_modern( &session.user, ) .await?; + emit_user_data_saved( + &state, + &session.user, + media.id, + UserDataSaveReason::ToggleFavorite, + ); Ok(Json(api::db_state_to_dto(s, &media)).into_response()) } @@ -504,6 +612,12 @@ pub async fn unmark_favorite_modern( &session.user, ) .await?; + emit_user_data_saved( + &state, + &session.user, + media.id, + UserDataSaveReason::ToggleFavorite, + ); Ok(Json(api::db_state_to_dto(s, &media)).into_response()) } @@ -533,6 +647,7 @@ pub async fn mark_played( server_config.release_date_threshold(), ) .await?; + emit_user_data_saved(&state, &user, media.id, UserDataSaveReason::TogglePlayed); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } @@ -555,6 +670,7 @@ pub async fn unmark_played( true, ) .await?; + emit_user_data_saved(&state, &user, media.id, UserDataSaveReason::TogglePlayed); Ok(Json(api::db_state_to_dto(ms, &media)).into_response()) } @@ -594,6 +710,12 @@ pub async fn create_user( .ctx .ws_tx .send(WsEvent::UserUpdated(user.id)); + state + .ctx + .webhooks + .emit(WebhookEvent::UserCreated { + user: (&user).into(), + }); Ok(( StatusCode::OK, Json(api::db_user_to_dto( @@ -621,6 +743,27 @@ pub async fn delete_user( return Err(anyhow::anyhow!("Cannot delete yourself") .context_bad_request("cannot delete own account")); } + // The row is about to disappear, so the name has to be read first — and + // only when something is listening, since that is one extra query per + // deletion. + let username = if state + .ctx + .webhooks + .wants(NotificationType::UserDeleted) + { + db::User::get_by_id( + &state + .ctx + .db, + &user_id, + ) + .await + .ok() + .flatten() + .map(|u| u.username) + } else { + None + }; db::User::delete( &state .ctx @@ -632,6 +775,12 @@ pub async fn delete_user( .ctx .ws_tx .send(WsEvent::UserDeleted(user_id)); + if let Some(username) = username { + state + .ctx + .webhooks + .emit(WebhookEvent::UserDeleted { user_id, username }); + } Ok(StatusCode::NO_CONTENT.into_response()) } @@ -686,6 +835,12 @@ pub async fn change_password( .ctx .ws_tx .send(WsEvent::UserUpdated(user_id)); + state + .ctx + .webhooks + .emit(WebhookEvent::UserPasswordChanged { + user: (&user).into(), + }); Ok(StatusCode::NO_CONTENT.into_response()) } @@ -716,6 +871,12 @@ pub async fn update_user_policy( .ctx .ws_tx .send(WsEvent::UserUpdated(user_id)); + state + .ctx + .webhooks + .emit(WebhookEvent::UserUpdated { + user: (&user).into(), + }); Ok(StatusCode::NO_CONTENT.into_response()) } @@ -752,6 +913,12 @@ pub async fn update_user( .ctx .ws_tx .send(WsEvent::UserUpdated(user_id)); + state + .ctx + .webhooks + .emit(WebhookEvent::UserUpdated { + user: (&user).into(), + }); Ok(StatusCode::NO_CONTENT.into_response()) } diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index c67bba1ef..9eff0899c 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -942,4 +942,209 @@ mod tests { "a deleted webhook must stop receiving events" ); } + + // --- the emission sites ----------------------------------------------- + // + // These drive real HTTP endpoints and watch a real socket. Each mock + // matches the *exact* body it expects, so a hit proves both that the site + // emits and that the event carried the right data — a wrong payload leaves + // the mock at zero hits and fails the wait. + + /// The one variable every template below echoes, plus the event kind, so a + /// site wired to the wrong variant cannot pass. + fn echo_template(variable: &str) -> String { + format!( + r#"{{"content":"{{{{{variable}}}}}","type":"{{{{NotificationType}}}}"}}"# + ) + } + + fn echoed(content: &str, notification_type: NotificationType) -> String { + format!(r#"{{"content":"{content}","type":"{notification_type}"}}"#) + } + + /// `POST /sessions/playing` reaches a hook subscribed to `PlaybackStart`, + /// carrying the item that is being played. + #[tokio::test] + async fn a_playback_start_reaches_a_configured_webhook() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let media = crate::integration_test::insert_test_source(&guard.0).await; + + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/hook") + .body(echoed(&media.title, NotificationType::PlaybackStart)); + then.status(200); + }) + .await; + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::PlaybackStart], + template: echo_template("Name"), + ..hook_dto("playback", &endpoint_server.url("/hook")) + }, + ) + .await; + + server + .post("/sessions/playing") + .add_header(h.clone(), v.clone()) + .json(&json!({ + "ItemId": media.id, + "PlaySessionId": "emission-test", + "PositionTicks": 1_500_000_000i64, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "PlayMethod": "DirectPlay", + })) + .await + .assert_status(StatusCode::NO_CONTENT); + + eventually("the playback start to reach the webhook", async || { + hits(&endpoint).await == 1 + }) + .await; + } + + /// `DELETE /items/{id}` reaches a hook subscribed to `ItemDeleted` with the + /// deleted item's own data — which only works because the row is captured + /// before the DELETE. The row is gone by the time the payload is built, so + /// anything that re-read it would render an empty name. + #[tokio::test] + async fn an_item_deletion_carries_the_deleted_items_data() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let media = crate::integration_test::insert_test_source(&guard.0).await; + + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/hook") + .body(echoed(&media.title, NotificationType::ItemDeleted)); + then.status(200); + }) + .await; + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::ItemDeleted], + template: echo_template("Name"), + ..hook_dto("deletions", &endpoint_server.url("/hook")) + }, + ) + .await; + + server + .delete(&format!("/items/{}", media.id)) + .add_header(h.clone(), v.clone()) + .await + .assert_status(StatusCode::NO_CONTENT); + + assert!( + db::Media::get_by_id( + &guard + .0 + .db, + &media.id + ) + .await + .expect("the lookup must succeed") + .is_none(), + "the row must really be gone, or this test proves nothing" + ); + + eventually("the deletion to reach the webhook", async || { + hits(&endpoint).await == 1 + }) + .await; + } + + /// A failed login emits `AuthenticationFailure` — and answers with exactly + /// the same 401 it answered before any webhook existed. A successful login + /// must not emit it. + #[tokio::test] + async fn an_authentication_failure_emits_without_changing_the_401() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + let bad_login = async || { + server + .post("/users/authenticatebyname") + .add_header( + http::header::AUTHORIZATION, + HeaderValue::from_static(AUTH_HEADER), + ) + .json(&json!({ "Username": "test", "Pw": "wrong" })) + .expect_failure() + .await + }; + + // Baseline: the refusal as it is with nothing listening. + let before = bad_login().await; + before.assert_status(StatusCode::UNAUTHORIZED); + let before_body = before.text(); + + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/hook") + .body(echoed("test", NotificationType::AuthenticationFailure)); + then.status(200); + }) + .await; + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::AuthenticationFailure], + template: echo_template("NotificationUsername"), + ..hook_dto("failures", &endpoint_server.url("/hook")) + }, + ) + .await; + + let after = bad_login().await; + after.assert_status(StatusCode::UNAUTHORIZED); + assert_eq!( + after.text(), + before_body, + "emitting must not change the refusal a client sees" + ); + + eventually("the failed login to reach the webhook", async || { + hits(&endpoint).await == 1 + }) + .await; + + // The credential check is the trigger, not the endpoint: a login that + // succeeds must not report a failure. + server + .post("/users/authenticatebyname") + .add_header( + http::header::AUTHORIZATION, + HeaderValue::from_static(AUTH_HEADER), + ) + .json(&json!({ "Username": "test", "Pw": "test" })) + .await + .assert_status_ok(); + settle().await; + assert_eq!( + hits(&endpoint).await, + 1, + "a successful login must not emit AuthenticationFailure" + ); + } } diff --git a/crates/remux-server/src/db/auth.rs b/crates/remux-server/src/db/auth.rs index e59736e49..14e8ea301 100644 --- a/crates/remux-server/src/db/auth.rs +++ b/crates/remux-server/src/db/auth.rs @@ -299,6 +299,37 @@ impl Device { } } +/// The client IP as reported by a reverse proxy, if it reported one. +/// +/// `X-Forwarded-For` is a list; the first entry is the original client. Falls +/// back to `X-Real-IP`. Both are untrusted input — this is display/audit data +/// for sessions and webhooks, never an authorization input. +pub fn remote_ip_from_headers(headers: &http::HeaderMap) -> Option { + headers + .get("X-Forwarded-For") + .and_then(|v| { + v.to_str() + .ok() + }) + .and_then(|v| { + v.split(',') + .next() + }) + .map(|s| { + s.trim() + .to_string() + }) + .or_else(|| { + headers + .get("X-Real-IP") + .and_then(|v| { + v.to_str() + .ok() + }) + .map(|s| s.to_string()) + }) +} + #[derive(Clone)] pub struct AuthSession { pub device: Device, @@ -326,31 +357,7 @@ impl FromRequestParts for AuthSession { .as_deref(); // Capture client IP from proxy headers or peer address. - let remote_ip = parts - .headers - .get("X-Forwarded-For") - .and_then(|v| { - v.to_str() - .ok() - }) - .and_then(|v| { - v.split(',') - .next() - }) - .map(|s| { - s.trim() - .to_string() - }) - .or_else(|| { - parts - .headers - .get("X-Real-IP") - .and_then(|v| { - v.to_str() - .ok() - }) - .map(|s| s.to_string()) - }); + let remote_ip = remote_ip_from_headers(&parts.headers); // First try the devices table (normal session token). if let Some(mut device) = Device::get_by_access_token( diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index 42c17bc04..e5cae95aa 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -277,6 +277,9 @@ pub async fn init_app( .spawn_cleanup_task( std::time::Duration::from_secs(60), std::time::Duration::from_secs(60 * 15), + conn.clone(), + ctx.webhooks + .clone(), ); // Fans emitted webhook events out to the enabled webhooks. diff --git a/crates/remux-server/src/playback_session.rs b/crates/remux-server/src/playback_session.rs index d59b1fa97..dc1687b45 100644 --- a/crates/remux-server/src/playback_session.rs +++ b/crates/remux-server/src/playback_session.rs @@ -5,8 +5,15 @@ use tokio::task::JoinHandle; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::{common, db, db::auth, playback::session::TranscodeSession}; -use remux_sdks::remux::{PlayMethod, PlaybackInfo, QueueItem}; +use crate::{ + common, db, + db::auth, + playback::session::TranscodeSession, + services::webhooks::{ + DeviceEventData, PlaybackEventData, UserEventData, WebhookEvent, WebhookService, + }, +}; +use remux_sdks::remux::{NotificationType, PlayMethod, PlaybackInfo, QueueItem}; #[derive(Clone)] pub struct PlaybackSession { @@ -754,10 +761,15 @@ impl PlaybackSessionManager { } /// Spawn a background task that reaps sessions idle longer than `max_age`. + /// + /// A reaped session emits `PlaybackStop` just like an explicit stop does: + /// a client that dies mid-playback would otherwise never produce one. pub fn spawn_cleanup_task( self, interval: Duration, max_age: Duration, + db: sqlx::SqlitePool, + webhooks: WebhookService, ) -> JoinHandle<()> { tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -782,14 +794,81 @@ impl PlaybackSessionManager { .collect(); for id in stale { info!("Cleaning up idle session: {}", id); - self.stop(&id) + let stopped = self + .stop(&id) .await; + // Only past this point is the session really gone, and only + // here are the two identity lookups below worth running. + if let Some(ps) = stopped + && !ps + .item_id + .is_nil() + && webhooks.wants(NotificationType::PlaybackStop) + { + webhooks.emit(WebhookEvent::PlaybackStop { + playback: reaped_playback_event(&db, &ps).await, + }); + } } } }) } } +/// Rebuild the identity a reaped session no longer carries. +/// +/// The in-memory session only holds ids, so the username and the device's +/// display name come from the database. Both are best-effort: a webhook with a +/// blank username is better than no stop event at all. +async fn reaped_playback_event( + db: &sqlx::SqlitePool, + ps: &PlaybackSession, +) -> PlaybackEventData { + let username = db::User::get_by_id(db, &ps.user_id) + .await + .ok() + .flatten() + .map(|u| u.username) + .unwrap_or_default(); + let device = auth::Device::get_by_id(db, &ps.device_id) + .await + .ok() + .flatten(); + PlaybackEventData { + user: UserEventData { + id: ps.user_id, + username, + }, + item_id: ps.item_id, + device: DeviceEventData { + id: ps + .device_id + .clone(), + name: device + .as_ref() + .map(|d| { + d.name + .clone() + }) + .unwrap_or_default(), + client_name: ps + .client_name + .clone(), + remote_ip: device.and_then(|d| d.remote_ip), + }, + position_ticks: ps.position_ticks, + // A reaped session is one that stopped reporting, not one that paused. + is_paused: ps.is_paused, + play_method: ps + .play_method + .as_deref() + .and_then(|m| { + m.parse() + .ok() + }), + } +} + /// Kill an ffmpeg process and wait for it to exit before returning. async fn kill_transcode(ts: Arc>) { let (kill_tx, wait_done, output_dir) = { diff --git a/crates/remux-server/src/services/webhooks/events.rs b/crates/remux-server/src/services/webhooks/events.rs index b81cbbf7a..a46b26b0c 100644 --- a/crates/remux-server/src/services/webhooks/events.rs +++ b/crates/remux-server/src/services/webhooks/events.rs @@ -15,6 +15,17 @@ pub struct UserEventData { pub username: String, } +impl From<&db::User> for UserEventData { + fn from(user: &db::User) -> Self { + Self { + id: user.id, + username: user + .username + .clone(), + } + } +} + /// The client/device a webhook event originated from. #[derive(Debug, Clone)] pub struct DeviceEventData { @@ -24,6 +35,25 @@ pub struct DeviceEventData { pub remote_ip: Option, } +impl From<&db::auth::Device> for DeviceEventData { + fn from(device: &db::auth::Device) -> Self { + Self { + id: device + .id + .clone(), + name: device + .name + .clone(), + client_name: device + .app_name + .clone(), + remote_ip: device + .remote_ip + .clone(), + } + } +} + /// The playback state shared by the three playback events. #[derive(Debug, Clone)] pub struct PlaybackEventData { diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index 5fd3f7b8e..dfac6b640 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -21,7 +21,7 @@ use std::{ collections::HashSet, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU32, Ordering}, }, }; use tokio::{ @@ -45,6 +45,9 @@ pub(crate) struct LoadedWebhooks { pub registry: handlebars::Handlebars<'static>, /// Union of every enabled hook's subscriptions — the dispatcher fast-path. pub wanted: HashSet, + /// Server name/url/version as of this snapshot. Reloaded with the hooks so + /// that renaming the server does not leave stale values in every payload. + pub server: payload::ServerInfo, } /// The empty snapshot the dispatcher starts from. It must carry the helpers @@ -56,6 +59,7 @@ impl Default for LoadedWebhooks { hooks: Vec::new(), registry: template::fresh_registry(), wanted: HashSet::new(), + server: payload::ServerInfo::default(), } } } @@ -64,6 +68,17 @@ struct Inner { /// Set by the webhook CRUD endpoints; consumed by the dispatcher. dirty: AtomicBool, cache: RwLock, + /// `cache.wanted` as a bitmask, readable without awaiting a lock. This is + /// what [`WebhookService::wants`] probes; see the note there. + wanted_mask: AtomicU32, +} + +/// One bit per [`NotificationType`], indexed by its discriminant (the enum is +/// fieldless). `None` for a type that would not fit in the mask — see +/// [`WebhookService::wants`], which answers those optimistically, so growing the +/// enum past 32 variants costs a little wasted work but never a lost event. +fn wanted_bit(notification_type: NotificationType) -> Option { + 1u32.checked_shl(notification_type as u32) } #[derive(Clone)] @@ -82,6 +97,11 @@ impl WebhookService { // stale until the CRUD endpoints say so. dirty: AtomicBool::new(false), cache: RwLock::new(LoadedWebhooks::default()), + // Everything is "wanted" until the first reload has run: the + // dispatcher buffers the events emitted during startup and + // filters them properly once its snapshot is loaded, so the + // probe must not tell callers to skip building them. + wanted_mask: AtomicU32::new(u32::MAX), }), } } @@ -94,32 +114,74 @@ impl WebhookService { .send(Arc::new(event)); } + /// Whether any enabled webhook subscribes to `notification_type`. + /// + /// `emit` is cheap, but the *caller* is not: building an event means + /// cloning usernames and device names, and for `ItemDeleted` re-reading and + /// boxing a whole [`db::Media`]. On a `PlaybackProgress` stream with no + /// webhooks configured that cost is paid per progress tick for nothing. + /// Guard those sites with this. + /// + /// Lock-free (one atomic load) and deliberately conservative: a pending + /// reload, or a subscription set too wide for the mask, answers `true`. It + /// is an optimisation, never the authority — the dispatcher re-checks every + /// event against the real snapshot. + pub fn wants(&self, notification_type: NotificationType) -> bool { + let Some(bit) = wanted_bit(notification_type) else { + return true; + }; + self.inner + .wanted_mask + .load(Ordering::Relaxed) + & bit + != 0 + } + /// Mark the cached webhook set stale. The dispatcher reloads before it /// handles the next event. pub fn invalidate(&self) { + // Widened first, and only ever narrowed again by `reload` once the new + // snapshot is in place: a hook that just gained a subscription must not + // have its events skipped by `wants` during the window in between. + self.inner + .wanted_mask + .store(u32::MAX, Ordering::Relaxed); self.inner .dirty .store(true, Ordering::Release); } /// Replace the cached snapshot from the database. On error the previous - /// snapshot is kept — a transient DB failure must not silently disable + /// hook set is kept — a transient DB failure must not silently disable /// every webhook. /// + /// The server identity is reloaded here too, which is why settings writers + /// call [`Self::invalidate`]: it is built once and then read by every + /// payload, so a rename would otherwise ship the old name until restart. + /// /// Invariant: this is the only writer of `cache`, and it is only ever /// called from the dispatcher task itself, at a point where that task /// holds no read guard. That is what makes it safe for the dispatcher to /// hold the read guard across `enrich_item().await` — no other task can be /// waiting for the write lock. - async fn reload(&self, db: &sqlx::SqlitePool) { - let hooks = match db::Webhook::get_enabled(db).await { + async fn reload(&self, ctx: &AppContext) { + // Never fails (falls back to defaults), so it is applied even when the + // hook query below does not. + let server = payload::ServerInfo::load(ctx).await; + + let hooks = match db::Webhook::get_enabled(&ctx.db).await { Ok(hooks) => hooks, Err(e) => { warn!(error = %e, "failed to load webhooks, keeping previous set"); + self.inner + .cache + .write() + .await + .server = server; return; } }; - let wanted = hooks + let wanted: HashSet = hooks .iter() .flat_map(|hook| { hook.notification_types @@ -127,6 +189,10 @@ impl WebhookService { .copied() }) .collect(); + let mask = wanted + .iter() + .filter_map(|t| wanted_bit(*t)) + .fold(0u32, |mask, bit| mask | bit); let mut cache = self .inner .cache @@ -136,7 +202,14 @@ impl WebhookService { registry: template::build_registry(&hooks), hooks, wanted, + server, }; + // Published after the snapshot: this is the only narrowing writer, and + // `invalidate` has already widened the mask for the whole window that + // ends here. + self.inner + .wanted_mask + .store(mask, Ordering::Relaxed); } /// Whether `hook` wants `event`. Pure: `item_kind` is the kind of the item @@ -199,10 +272,8 @@ impl WebhookService { .tx .subscribe(); tokio::spawn(async move { - self.reload(&ctx.db) + self.reload(&ctx) .await; - // Read once: every event of this process reports the same server. - let server = payload::ServerInfo::load(&ctx).await; loop { let event = match rx @@ -222,7 +293,7 @@ impl WebhookService { .dirty .swap(false, Ordering::AcqRel) { - self.reload(&ctx.db) + self.reload(&ctx) .await; } @@ -256,7 +327,7 @@ impl WebhookService { // Built once per event; `render` applies the per-hook overlay // (a Generic destination's operator-defined fields). - let data = payload::build_data(&server, &event, item.as_ref()); + let data = payload::build_data(&cache.server, &event, item.as_ref()); for hook in targets { match template::render(hook, &cache.registry, &data) { // Delivery is spawned so one slow endpoint cannot stall @@ -498,6 +569,69 @@ mod tests { assert_eq!(body, "ok"); } + // --- the `wants` probe ------------------------------------------------ + + /// Every notification type must own a bit. Two types sharing one would make + /// `wants` answer for the wrong subscription — and the bit index is the + /// enum's discriminant, which nothing else in the code pins down. + #[test] + fn every_notification_type_has_its_own_bit() { + let types = [ + NotificationType::ItemAdded, + NotificationType::ItemDeleted, + NotificationType::Generic, + NotificationType::PlaybackStart, + NotificationType::PlaybackProgress, + NotificationType::PlaybackStop, + NotificationType::AuthenticationSuccess, + NotificationType::AuthenticationFailure, + NotificationType::SessionStart, + NotificationType::TaskCompleted, + NotificationType::UserCreated, + NotificationType::UserDeleted, + NotificationType::UserUpdated, + NotificationType::UserPasswordChanged, + NotificationType::UserDataSaved, + ]; + let bits: HashSet = types + .iter() + .map(|t| { + wanted_bit(*t).unwrap_or_else(|| panic!("{t} must fit in the mask")) + }) + .collect(); + assert_eq!( + bits.len(), + types.len(), + "each notification type must map to its own bit" + ); + } + + /// Before the dispatcher's first load — and for the whole window a pending + /// reload is open — the probe must not tell callers to skip building + /// events. Skipping is only ever correct against a snapshot that is known + /// to be current. + #[tokio::test] + async fn the_probe_is_open_until_a_snapshot_says_otherwise() { + let service = WebhookService::new(); + assert!( + service.wants(NotificationType::PlaybackProgress), + "a service whose snapshot has never loaded must want everything" + ); + + // Narrowed exactly as `reload` narrows it: nothing subscribes. + service + .inner + .wanted_mask + .store(0, Ordering::Relaxed); + assert!(!service.wants(NotificationType::PlaybackProgress)); + + service.invalidate(); + assert!( + service.wants(NotificationType::PlaybackProgress), + "invalidate must re-open the probe until the reload it asked for lands" + ); + } + // --- rule 1: notification types ------------------------------------- /// Deliberate parity with the Jellyfin webhook plugin: a webhook that diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index 7819e3d29..745459dc9 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -71,8 +71,13 @@ pub(crate) struct ItemContext { pub genres: Vec, } -/// The identity of this server, resolved once when the dispatcher starts. -#[derive(Debug, Clone)] +/// The identity of this server, resolved whenever the dispatcher's snapshot is +/// (re)loaded — a rename must not keep shipping the old name until restart. +/// +/// `Default` is the pre-first-load placeholder only. The dispatcher loads the +/// snapshot before it reads its first event, so no delivery is ever built from +/// it. +#[derive(Debug, Clone, Default)] pub(crate) struct ServerInfo { pub id: String, pub name: String, diff --git a/crates/remux-server/src/tasks/catalog_import_shared.rs b/crates/remux-server/src/tasks/catalog_import_shared.rs index c30dc88bc..c445c8e53 100644 --- a/crates/remux-server/src/tasks/catalog_import_shared.rs +++ b/crates/remux-server/src/tasks/catalog_import_shared.rs @@ -6,7 +6,10 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use super::ProgressReporter; -use crate::{AppContext, addons::ResolvedCatalog, db}; +use crate::{ + AppContext, addons::ResolvedCatalog, db, services::webhooks::WebhookEvent, +}; +use remux_sdks::remux::NotificationType; /// Consume `stream`, fetching metadata + full tree for new items and upserting everything. /// @@ -131,6 +134,20 @@ where continue; } + // The partition above is the only place that knows which of these rows + // are new. A scan can produce tens of thousands of them, so the whole + // loop is skipped — at the cost of one atomic load per chunk — when no + // webhook subscribes. + if ctx + .webhooks + .wants(NotificationType::ItemAdded) + { + for item in new_items.iter() { + ctx.webhooks + .emit(WebhookEvent::ItemAdded { item_id: item.id }); + } + } + for id in new_series_ids { db::reconcile_series_played_state(&ctx.db, id).await; } diff --git a/crates/remux-server/src/tasks/mod.rs b/crates/remux-server/src/tasks/mod.rs index 0f9260627..95b824b8e 100644 --- a/crates/remux-server/src/tasks/mod.rs +++ b/crates/remux-server/src/tasks/mod.rs @@ -13,7 +13,7 @@ use tokio::{sync::Mutex as AsyncMutex, task::JoinHandle}; use tokio_cron_scheduler::{Job, JobScheduler, job::JobId}; use tracing::{error, info}; -use crate::{AppContext, db, ws}; +use crate::{AppContext, db, services::webhooks::WebhookEvent, ws}; use remux_sdks::remux::TaskTriggerInfoType; use strum_macros::{Display, EnumString}; @@ -242,6 +242,16 @@ impl TaskHandler { .lock() .unwrap_or_else(|e| e.into_inner()) = new_status; + ctx.webhooks + .emit(WebhookEvent::TaskCompleted { + key: task_key.clone(), + name: task + .name() + .to_string(), + succeeded: result.is_ok(), + elapsed_ms: elapsed.as_millis() as u64, + }); + let task_result = db::TaskResult { task_id: task_key.clone(), start_at, From cd01a88c39191b1bb52c1617cd67e36d91d69f3f Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 17:29:01 +0200 Subject: [PATCH 17/29] fix(server): make the webhook wants probe self-healing and stop-events truthful --- crates/remux-sdks/src/remux/mod.rs | 1 + crates/remux-server/src/api/session.rs | 61 +++------ crates/remux-server/src/api/users.rs | 13 +- crates/remux-server/src/api/webhooks.rs | 124 ++++++++++++++++++ crates/remux-server/src/playback_session.rs | 36 ++++- .../remux-server/src/services/webhooks/mod.rs | 108 ++++++++++++--- 6 files changed, 280 insertions(+), 63 deletions(-) diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 7c780b4d2..fee32b877 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6210,6 +6210,7 @@ pub struct RefreshItemQuery { Deserialize, strum_macros::EnumString, strum_macros::Display, + strum_macros::EnumCount, )] pub enum NotificationType { ItemAdded, diff --git a/crates/remux-server/src/api/session.rs b/crates/remux-server/src/api/session.rs index d86d790c6..a42a65cc2 100644 --- a/crates/remux-server/src/api/session.rs +++ b/crates/remux-server/src/api/session.rs @@ -278,26 +278,7 @@ pub async fn report_playback_stopped( .map(|s| s.play_session_id) }); if let Some(ref psid) = effective_psid { - let wants_stop = state - .ctx - .webhooks - .wants(NotificationType::PlaybackStop); - let wants_user_data = state - .ctx - .webhooks - .wants(NotificationType::UserDataSaved); - // `stopped` removes the session, so its final item/position have to be - // read before the call, exactly as `stopped` itself reads them. - let ps = (wants_stop || wants_user_data) - .then(|| { - state - .ctx - .sessions - .get(psid) - }) - .flatten(); - - state + let recorded = state .ctx .sessions .stopped( @@ -315,22 +296,18 @@ pub async fn report_playback_stopped( .ws_tx .send(crate::ws::WsEvent::SessionsChanged); - if let Some(item_id) = Some(data.item_id) - .filter(|id| !id.is_nil()) - .or_else(|| { - ps.as_ref() - .map(|s| s.item_id) - }) - .filter(|id| !id.is_nil()) - { - if wants_stop { - let position_ticks = data - .position_ticks - .or_else(|| { - ps.as_ref() - .map(|s| s.position_ticks) - }) - .unwrap_or(0); + // Reported only for a stop that recorded something. The endpoint + // answers 204 to any authenticated client that posts any item id, with + // or without a session behind it, and deriving the event from the + // *request* rather than from what was written would let that client + // forge playback against the operator's endpoint — and make + // `UserDataSaved` assert a save that never happened. + if let Some(recorded) = recorded { + if state + .ctx + .webhooks + .wants(NotificationType::PlaybackStop) + { state .ctx .webhooks @@ -338,18 +315,22 @@ pub async fn report_playback_stopped( playback: playback_event( &session, &data, - item_id, - position_ticks, + recorded.item_id, + recorded.position_ticks, ), }); } - if wants_user_data { + if state + .ctx + .webhooks + .wants(NotificationType::UserDataSaved) + { state .ctx .webhooks .emit(WebhookEvent::UserDataSaved { user: (&session.user).into(), - item_id, + item_id: recorded.item_id, save_reason: UserDataSaveReason::PlaybackFinished, }); } diff --git a/crates/remux-server/src/api/users.rs b/crates/remux-server/src/api/users.rs index 6d5da986a..2e1487397 100644 --- a/crates/remux-server/src/api/users.rs +++ b/crates/remux-server/src/api/users.rs @@ -349,7 +349,18 @@ pub async fn users_authenticatebyname( // password, and only for those — a DB failure is an error, not a failed // login, and must not be reported as one. The `?` below is untouched, so // the refusal is the same 401 with the same body and the same timing. - if authenticated.is_none() { + // + // Guarded, unlike the other auth events: this is the only emission site + // reachable without credentials, so a credential-stuffing run drives it at + // whatever rate the attacker can manage. With nothing subscribed that is an + // allocation and a broadcast send per attempt, and enough of them push the + // dispatcher into `Lagged` warn-spam. + if authenticated.is_none() + && state + .ctx + .webhooks + .wants(NotificationType::AuthenticationFailure) + { state .ctx .webhooks diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index 9eff0899c..a0167fd84 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -1013,6 +1013,130 @@ mod tests { .await; } + /// A stop report that records nothing must report nothing. + /// + /// The endpoint answers 204 to any authenticated client for any item id, + /// with or without a session behind it. Deriving the event from the request + /// rather than from what was written would let that client forge playback + /// against the operator's endpoint — and make the `UserDataSaved` that + /// rides along assert a save that provably did not happen. + #[tokio::test] + async fn a_stop_for_an_unknown_item_emits_nothing_and_still_answers_204() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + // Deliberately unconstrained: any delivery at all is a failure here. + let forged = endpoint_server.mock(|when, then| { + when.method(POST) + .path("/forged"); + then.status(200); + }); + let canary_ep = endpoint_server.mock(|when, then| { + when.method(POST) + .path("/canary"); + then.status(200); + }); + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![ + NotificationType::PlaybackStop, + NotificationType::UserDataSaved, + ], + ..hook_dto("forgeable", &endpoint_server.url("/forged")) + }, + ) + .await; + create( + &server, + &h, + &v, + &hook_dto("canary", &endpoint_server.url("/canary")), + ) + .await; + + server + .post("/sessions/playing/stopped") + .add_header(h.clone(), v.clone()) + .json(&json!({ + "ItemId": Uuid::from_u128(0xf0f0), + "PlaySessionId": "never-started", + "PositionTicks": 9_000_000_000i64, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + })) + .await + .assert_status(StatusCode::NO_CONTENT); + + // The canary rides the same dispatcher: once it has seen an event + // emitted *after* the stop, the stop has been fully dispatched. + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the dispatcher to drain past the stop", async || { + hits(&canary_ep).await == 1 + }) + .await; + settle().await; + assert_eq!( + hits(&forged).await, + 0, + "a stop that recorded nothing must not manufacture playback events" + ); + } + + /// `reload` is what narrows the probe, and every other test here runs with + /// a freshly widened mask (`create` invalidates, and nothing forces a + /// reload before the request under test). So without this, a `reload` that + /// computed an empty mask — or dropped a bit — would pass the whole suite + /// while permanently suppressing every guarded event on a real server. + #[tokio::test] + async fn a_reload_narrows_the_probe_to_the_subscribed_types() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::PlaybackStart], + ..hook_dto("starts only", &endpoint_server.url("/hook")) + }, + ) + .await; + + // Drive one event through so the dispatcher performs a real reload. + guard + .0 + .webhooks + .emit(generic_event()); + eventually( + "the dispatcher to reload and narrow the probe", + async || { + !guard + .0 + .webhooks + .wants(NotificationType::ItemAdded) + }, + ) + .await; + + assert!( + guard + .0 + .webhooks + .wants(NotificationType::PlaybackStart), + "the one subscribed type must survive the narrowing" + ); + } + /// `DELETE /items/{id}` reaches a hook subscribed to `ItemDeleted` with the /// deleted item's own data — which only works because the row is captured /// before the DELETE. The row is gone by the time the payload is built, so diff --git a/crates/remux-server/src/playback_session.rs b/crates/remux-server/src/playback_session.rs index dc1687b45..85f2a2b14 100644 --- a/crates/remux-server/src/playback_session.rs +++ b/crates/remux-server/src/playback_session.rs @@ -44,6 +44,19 @@ pub struct PlaybackSession { pub item_kind: Option, } +/// What a stop report actually persisted. +/// +/// [`PlaybackSessionManager::stopped`] answers `None` when it wrote nothing: +/// no session to close and no usable item id, or an id that resolves to no row +/// in the library. The endpoint answers 204 either way, so this is the only +/// thing that distinguishes a real stop from a report that named an item the +/// server knows nothing about. +#[derive(Debug, Clone, Copy)] +pub struct StoppedPlayback { + pub item_id: Uuid, + pub position_ticks: i64, +} + #[derive(Clone)] pub struct PlaybackSessionManager { sessions: Arc>, @@ -427,13 +440,16 @@ impl PlaybackSessionManager { /// Removes the playback session (stopping any active transcode), persists /// the final position to the DB (with the 90 % watched-mark check), and /// emits a debug log line. + /// + /// Returns what was actually written, so callers can tell a real stop from + /// a report that resolved to nothing — see [`StoppedPlayback`]. pub async fn stopped( &self, db: &sqlx::SqlitePool, user: &db::User, psid: &str, data: &PlaybackInfo, - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { let ps = self .stop(psid) .await; @@ -444,30 +460,36 @@ impl PlaybackSessionManager { ps.as_ref() .map(|s| s.item_id) }); - let final_ticks = data + let position_ticks = data .position_ticks .or_else(|| { ps.as_ref() .map(|s| s.position_ticks) - }); + }) + .unwrap_or(0); + let mut recorded = None; if let Some(item_id) = item_id { if let Ok(Some(media)) = db::Media::get_by_id(db, &item_id).await { db::UserMediaState::update_playback( db, user, &media, - final_ticks.unwrap_or(0), + position_ticks, None, // don't overwrite stream selections on stop None, media.runtime, // Some(runtime) triggers watched-threshold check ) .await?; + recorded = Some(StoppedPlayback { + item_id, + position_ticks, + }); } } debug!(play_session_id = psid, "Playback stopped"); - Ok(()) + Ok(recorded) } /// Insert (or replace) a playback session, preserving any transcode that was @@ -857,7 +879,9 @@ async fn reaped_playback_event( remote_ip: device.and_then(|d| d.remote_ip), }, position_ticks: ps.position_ticks, - // A reaped session is one that stopped reporting, not one that paused. + // The last state the client reported. A reap says the client stopped + // reporting, which tells us nothing about whether it was paused when it + // did — so the last known value is the honest answer. is_paused: ps.is_paused, play_method: ps .play_method diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index dfac6b640..e6881cd2c 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -75,12 +75,21 @@ struct Inner { /// One bit per [`NotificationType`], indexed by its discriminant (the enum is /// fieldless). `None` for a type that would not fit in the mask — see -/// [`WebhookService::wants`], which answers those optimistically, so growing the -/// enum past 32 variants costs a little wasted work but never a lost event. +/// [`WebhookService::wants`], which answers those optimistically, so an enum +/// too wide for the mask costs wasted work but never a lost event. fn wanted_bit(notification_type: NotificationType) -> Option { 1u32.checked_shl(notification_type as u32) } +/// The degradation above is correct but *silent*: a 33rd variant would quietly +/// turn the probe into "always true" for everything past the 32nd, and no test +/// would notice. Make outgrowing the mask a build error instead, so the choice +/// (widen the mask, or accept the loss) is made deliberately. +const _: () = assert!( + ::COUNT <= u32::BITS as usize, + "NotificationType has outgrown the u32 `wants` mask — widen it to u64" +); + #[derive(Clone)] pub struct WebhookService { tx: broadcast::Sender>, @@ -122,27 +131,44 @@ impl WebhookService { /// webhooks configured that cost is paid per progress tick for nothing. /// Guard those sites with this. /// - /// Lock-free (one atomic load) and deliberately conservative: a pending + /// Lock-free (two atomic loads) and deliberately conservative: a pending /// reload, or a subscription set too wide for the mask, answers `true`. It /// is an optimisation, never the authority — the dispatcher re-checks every /// event against the real snapshot. + /// + /// The `dirty` half is not redundant with the widening in [`Self::invalidate`], + /// and leaving it out is a *sticky* bug rather than a transient one. The + /// mask is narrowed by `reload` from a snapshot it read some time earlier, + /// so an `invalidate` that lands mid-reload has its widen clobbered by a + /// mask that predates it. Were `wants` to answer from the mask alone, it + /// would then suppress exactly the guarded events that would otherwise have + /// woken the dispatcher and made it consume the still-set `dirty` flag — so + /// nothing would heal it until some *unguarded* event happened to fire, + /// which on a quiet server can be hours. Consulting `dirty` keeps the + /// staleness self-healing, which is what it was before this probe existed. pub fn wants(&self, notification_type: NotificationType) -> bool { let Some(bit) = wanted_bit(notification_type) else { return true; }; self.inner - .wanted_mask - .load(Ordering::Relaxed) - & bit - != 0 + .dirty + .load(Ordering::Acquire) + || self + .inner + .wanted_mask + .load(Ordering::Relaxed) + & bit + != 0 } /// Mark the cached webhook set stale. The dispatcher reloads before it /// handles the next event. pub fn invalidate(&self) { - // Widened first, and only ever narrowed again by `reload` once the new - // snapshot is in place: a hook that just gained a subscription must not - // have its events skipped by `wants` during the window in between. + // Widened before the flag is raised: a hook that just gained a + // subscription must not have its events skipped in the window before + // the dispatcher reloads. `reload` declines to narrow again while the + // flag is still up, and `wants` consults the flag too, so this is the + // fast path rather than the correctness argument. self.inner .wanted_mask .store(u32::MAX, Ordering::Relaxed); @@ -204,12 +230,21 @@ impl WebhookService { wanted, server, }; - // Published after the snapshot: this is the only narrowing writer, and - // `invalidate` has already widened the mask for the whole window that - // ends here. - self.inner - .wanted_mask - .store(mask, Ordering::Relaxed); + // Published after the snapshot, and only when nothing invalidated while + // the rows above were being read. The dispatcher clears `dirty` before + // calling this, so finding it set again means `hooks` predates an + // `invalidate` whose widening this store would otherwise silently + // clobber — leaving the mask narrow, and stale, for as long as the flag + // stays unconsumed. + if !self + .inner + .dirty + .load(Ordering::Acquire) + { + self.inner + .wanted_mask + .store(mask, Ordering::Relaxed); + } } /// Whether `hook` wants `event`. Pure: `item_kind` is the kind of the item @@ -632,6 +667,47 @@ mod tests { ); } + /// The narrowing store at the end of `reload` publishes a mask derived from + /// rows read some time earlier. An `invalidate` that lands in between must + /// not have its widening clobbered by it. + /// + /// This is the interleaving, in order: the dispatcher consumes the flag and + /// starts reloading, the operator saves a hook mid-reload, the reload + /// finishes from its now-outdated snapshot. Left unhandled the result is + /// *sticky*, not transient — the closed probe suppresses exactly the + /// guarded events that would have woken the dispatcher into consuming the + /// flag, so nothing reopens it until some unguarded event happens to fire. + #[tokio::test] + async fn a_reload_that_races_an_invalidate_leaves_the_probe_open() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .expect("test server"); + let service = WebhookService::new(); + + // The dispatcher takes the flag and begins reading the database, which + // at this point holds no webhooks at all — so this reload can only + // compute an empty mask. + service.invalidate(); + service + .inner + .dirty + .swap(false, Ordering::AcqRel); + + // Mid-read: the operator saves a hook that subscribes to a guarded event. + service.invalidate(); + + // The reload lands, carrying the snapshot from before that save. + service + .reload(&guard.0) + .await; + + assert!( + service.wants(NotificationType::PlaybackProgress), + "a reload that raced an invalidate must not leave the probe closed \ + — the flag is still set, so its own snapshot is known to be stale" + ); + } + // --- rule 1: notification types ------------------------------------- /// Deliberate parity with the Jellyfin webhook plugin: a webhook that From 1eeb02dd3988973d7e2599dbc446835dcc7d34a4 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 17:52:30 +0200 Subject: [PATCH 18/29] feat(dashboard): webhooks management page --- crates/remux-dashboard/src/layout.rs | 7 + crates/remux-dashboard/src/pages/mod.rs | 2 + crates/remux-dashboard/src/pages/webhooks.rs | 1545 ++++++++++++++++++ crates/remux-dashboard/src/router.rs | 8 + 4 files changed, 1562 insertions(+) create mode 100644 crates/remux-dashboard/src/pages/webhooks.rs diff --git a/crates/remux-dashboard/src/layout.rs b/crates/remux-dashboard/src/layout.rs index 00dc6cc4d..47b2937b7 100644 --- a/crates/remux-dashboard/src/layout.rs +++ b/crates/remux-dashboard/src/layout.rs @@ -111,6 +111,7 @@ pub fn DashboardLayout() -> Element { Route::SettingsBrandingRoute => "Branding", Route::SettingsIntroRoute => "Intro", Route::SettingsRemuxdbRoute => "Remuxdb", + Route::SettingsWebhooksRoute => "Webhooks", Route::AccessUsersRoute => "Users", Route::AccessApiKeysRoute => "API Keys", Route::TasksRoute => "Tasks", @@ -198,6 +199,7 @@ pub fn DashboardLayout() -> Element { | Route::SettingsBrandingRoute | Route::SettingsIntroRoute | Route::SettingsRemuxdbRoute + | Route::SettingsWebhooksRoute ), NavSubItem { label: "General", @@ -234,6 +236,11 @@ pub fn DashboardLayout() -> Element { active: route == Route::SettingsBrandingRoute, on_click: move |_| { navigator().push(Route::SettingsBrandingRoute); sidebar_open.set(false); }, } + NavSubItem { + label: "Webhooks", + active: route == Route::SettingsWebhooksRoute, + on_click: move |_| { navigator().push(Route::SettingsWebhooksRoute); sidebar_open.set(false); }, + } } SidebarGroup { diff --git a/crates/remux-dashboard/src/pages/mod.rs b/crates/remux-dashboard/src/pages/mod.rs index ddc3f3f84..c6576e5ab 100644 --- a/crates/remux-dashboard/src/pages/mod.rs +++ b/crates/remux-dashboard/src/pages/mod.rs @@ -7,6 +7,7 @@ pub mod iptv; pub mod settings; pub mod streams; pub mod users; +pub mod webhooks; pub use addons::AddonsPage; pub use api_keys::ApiKeysPage; @@ -20,3 +21,4 @@ pub use settings::{ }; pub use streams::StreamGroupsCard; pub use users::UsersPage; +pub use webhooks::WebhooksPage; diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs new file mode 100644 index 000000000..1497ce96a --- /dev/null +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -0,0 +1,1545 @@ +//! Admin page for outgoing webhooks. +//! +//! Two rules govern everything in this module. +//! +//! **A webhook URL is a credential.** Discord's is +//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the whole +//! authentication, so the URL is never written to the browser console (nothing +//! here logs at all) and the list renders only a truncated prefix that stops +//! short of any Discord token. +//! +//! **Every mutation sends a complete [`WebhookDto`].** `WebhookDto` carries no +//! `#[serde(default)]`, so a payload missing one field is a 422 rather than a +//! partial update — even the one-click enable toggle rebuilds the full row. + +use crate::{ + components::{Card, EmptyState, ErrorAlert, FormGroup, LoadingText, ToggleRow}, + state::AppState, +}; +use dioxus::prelude::*; +use remux_sdks::remux::{ + CreateWebhook, DeleteWebhook, DiscordMentionType, GetUsers, GetWebhooks, + NotificationType, TestWebhook, UpdateWebhook, UserDto, WebhookDestination, + WebhookDto, WebhookItemTypes, WebhookKeyValue, WebhookTestResult, +}; +use std::{collections::HashMap, str::FromStr}; +use uuid::Uuid; + +/// The Jellyfin webhook plugin's stock `Templates/Discord.handlebars`, verbatim +/// (its UTF-8 BOM stripped). +/// +/// This is not decoration. remux follows the plugin exactly: for a Discord +/// destination the operator's template renders the **entire** Discord JSON +/// payload, with the destination's options injected as the variables +/// `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` and `BotUsername`. A +/// Discord webhook with an empty template therefore POSTs an empty body, which +/// is why picking Discord pre-fills this — see [`apply_destination_change`]. +const DISCORD_TEMPLATE: &str = r##"{ + "content": "{{MentionType}}", + "avatar_url": "{{AvatarUrl}}", + "username": "{{BotUsername}}", + "embeds": [ + { + "color": "{{EmbedColor}}", + "footer": { + "text": "From {{{ServerName}}}", + "icon_url": "{{AvatarUrl}}" + }, + {{#if_equals ItemType 'Season'}} + "title": "{{{SeriesName}}} {{{Name}}} has been added to {{{ServerName}}}", + {{else}} + {{#if_equals ItemType 'Episode'}} + "title": "{{{SeriesName}}} S{{SeasonNumber00}}E{{EpisodeNumber00}} {{{Name}}} has been added to {{{ServerName}}}", + {{else}} + "title": "{{{Name}}} ({{Year}}) has been added to {{{ServerName}}}", + {{/if_equals}} + {{/if_equals}} + "thumbnail":{ + "url": "{{ServerUrl}}/Items/{{ItemId}}/Images/Primary" + }, + "description": "External Links:\n + {{~#if_exist Provider_imdb~}} + [IMDb](https://www.imdb.com/title/{{Provider_imdb}}/)\n + {{~/if_exist~}} + {{~#if_exist Provider_tmdb~}} + {{~#if_equals ItemType 'Movie'~}} + [TMDb](https://www.themoviedb.org/movie/{{Provider_tmdb}})\n + {{~else~}} + [TMDb](https://www.themoviedb.org/tv/{{Provider_tmdb}})\n + {{~/if_equals~}} + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainzartist~}} + [MusicBrainz](https://musicbrainz.org/artist/{{Provider_musicbrainzartist}})\n + {{~/if_exist~}} + {{~#if_exist Provider_audiodbartist~}} + [AudioDb](https://theaudiodb.com/artist/{{Provider_audiodbartist}})\n + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainztrack~}} + [MusicBrainz Track](https://musicbrainz.org/track/{{Provider_musicbrainztrack}})\n + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainzalbum~}} + [MusicBrainz Album](https://musicbrainz.org/release/{{Provider_musicbrainzalbum}})\n + {{~/if_exist~}} + {{~#if_exist Provider_theaudiodbalbum~}} + [TADb Album](https://theaudiodb.com/album/{{Provider_theaudiodbalbum}})\n + {{~/if_exist~}} + {{~#if_exist Provider_tvmaze~}} + {{~#if_equals ItemType 'Episode'~}} + [TVMaze](https://www.tvmaze.com/episodes/{{Provider_tvmaze}})\n + {{~/if_equals~}} + {{~#if_equals ItemType 'Series'~}} + [TVMaze](https://www.tvmaze.com/shows/{{Provider_tvmaze}})\n + {{~/if_equals~}} + {{~/if_exist~}} + [Jellyfin]({{ServerUrl}}/web/index.html#!/details?id={{ItemId}}&serverId={{ServerId}})" + } + ] +} +"##; + +/// The colour the server injects when a Discord hook names none, mirrored here +/// so the field is never blank: the stock template interpolates `EmbedColor` +/// unguarded, and an empty swatch reads as a bug. +const DEFAULT_EMBED_COLOR: &str = "#3399FF"; + +/// Every [`NotificationType`], in the order the SDK declares them. +/// +/// The list is hand-written, so a variant added to the SDK must be added here +/// too. `every_notification_type_round_trips_through_its_label` keeps the +/// entries themselves honest (each label parses back to the variant, and no +/// entry is duplicated); the array's declared length is what pins the count. +const NOTIFICATION_TYPES: [NotificationType; 15] = [ + NotificationType::ItemAdded, + NotificationType::ItemDeleted, + NotificationType::Generic, + NotificationType::PlaybackStart, + NotificationType::PlaybackProgress, + NotificationType::PlaybackStop, + NotificationType::AuthenticationSuccess, + NotificationType::AuthenticationFailure, + NotificationType::SessionStart, + NotificationType::TaskCompleted, + NotificationType::UserCreated, + NotificationType::UserDeleted, + NotificationType::UserUpdated, + NotificationType::UserPasswordChanged, + NotificationType::UserDataSaved, +]; + +/// Labels for the seven [`WebhookItemTypes`] flags, indexed the same way as +/// [`item_type_flag`] / [`set_item_type_flag`]. +const ITEM_TYPE_LABELS: [&str; 7] = [ + "Movies", "Episodes", "Series", "Seasons", "Albums", "Songs", "Videos", +]; + +const MENTION_TYPES: [DiscordMentionType; 3] = [ + DiscordMentionType::None, + DiscordMentionType::Here, + DiscordMentionType::Everyone, +]; + +/// How much of a webhook URL the list shows. 48 characters stop inside the id +/// segment of a Discord webhook URL — well before the token. +const URL_PREVIEW_LEN: usize = 48; + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/// A display-only prefix of `url`, char-boundary safe. +fn truncate_url(url: &str, max: usize) -> String { + if url + .chars() + .count() + <= max + { + return url.to_string(); + } + let head: String = url + .chars() + .take(max) + .collect(); + format!("{head}…") +} + +/// `raw` as a `#rrggbb` string an `` accepts, or `None` when +/// it is not a six-digit hex colour. Accepts a leading `#` or not, any case. +fn normalize_hex_color(raw: &str) -> Option { + let trimmed = raw.trim(); + let digits = trimmed + .strip_prefix('#') + .unwrap_or(trimmed); + if digits.len() != 6 + || !digits + .chars() + .all(|c| c.is_ascii_hexdigit()) + { + return None; + } + Some(format!("#{}", digits.to_ascii_lowercase())) +} + +/// What to feed the colour swatch: the operator's colour when it parses, the +/// server's default otherwise, so the widget is never blank while they type. +fn color_input_value(raw: &str) -> String { + normalize_hex_color(raw).unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_ascii_lowercase()) +} + +fn item_type_flag(types: &WebhookItemTypes, idx: usize) -> bool { + match idx { + 0 => types.movies, + 1 => types.episodes, + 2 => types.series, + 3 => types.seasons, + 4 => types.albums, + 5 => types.songs, + 6 => types.videos, + _ => false, + } +} + +fn set_item_type_flag(types: &mut WebhookItemTypes, idx: usize, value: bool) { + match idx { + 0 => types.movies = value, + 1 => types.episodes = value, + 2 => types.series = value, + 3 => types.seasons = value, + 4 => types.albums = value, + 5 => types.songs = value, + 6 => types.videos = value, + _ => {} + } +} + +fn destination_label(destination: &WebhookDestination) -> &'static str { + match destination { + WebhookDestination::Generic { .. } => "Generic", + WebhookDestination::Discord { .. } => "Discord", + } +} + +/// Badge styling for the list, reusing the existing user-badge variants rather +/// than adding CSS: Discord gets the accented one, Generic the muted one. +fn destination_badge_class(destination: &WebhookDestination) -> &'static str { + match destination { + WebhookDestination::Generic { .. } => "user-badge user-badge-self", + WebhookDestination::Discord { .. } => "user-badge user-badge-admin", + } +} + +/// `Some(trimmed)` unless the field is blank — the server treats an empty +/// Discord option and an absent one differently (`if_exist` blocks hinge on it), +/// so a blank input must serialize as `null`, not `""`. +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// `selected` in the canonical order, de-duplicated, so the payload does not +/// depend on the order the operator ticked the boxes. +fn sorted_notification_types(selected: &[NotificationType]) -> Vec { + NOTIFICATION_TYPES + .iter() + .filter(|t| selected.contains(t)) + .copied() + .collect() +} + +/// One line describing a completed test. A refused delivery is a *result*, not +/// an error: the API call succeeded and returned `success: false`. +fn test_message(result: &WebhookTestResult) -> String { + if result.success { + match result.status_code { + Some(code) => format!("Test delivered — HTTP {code}"), + None => "Test delivered".to_string(), + } + } else { + let detail = result + .error + .clone() + .unwrap_or_else(|| "delivery failed".to_string()); + match result.status_code { + Some(code) => format!("Test failed (HTTP {code}) — {detail}"), + None => format!("Test failed — {detail}"), + } + } +} + +// --------------------------------------------------------------------------- +// Form state +// --------------------------------------------------------------------------- + +/// The editable shape of a webhook. +/// +/// Both destinations' options live here at once, so flipping the selector back +/// and forth never discards the headers the operator typed. Which half reaches +/// the wire is decided by `discord` in [`WebhookForm::to_dto`]. +/// +/// `created_at` / `updated_at` are deliberately absent: they are server-owned +/// (create stamps both, update preserves `created_at` and bumps `updated_at`), +/// so the form sends `null` for them and loses nothing. +#[derive(Clone, PartialEq)] +pub struct WebhookForm { + /// `None` for a webhook that does not exist yet. + id: Option, + name: String, + enabled: bool, + url: String, + template: String, + discord: bool, + headers: Vec, + fields: Vec, + avatar_url: String, + bot_username: String, + embed_color: String, + mention_type: DiscordMentionType, + notification_types: Vec, + user_filter: Vec, + item_types: WebhookItemTypes, + send_all_properties: bool, + trim_whitespace: bool, + skip_empty_message_body: bool, +} + +impl Default for WebhookForm { + fn default() -> Self { + Self { + id: None, + name: String::new(), + enabled: true, + url: String::new(), + template: String::new(), + discord: false, + headers: Vec::new(), + fields: Vec::new(), + avatar_url: String::new(), + bot_username: String::new(), + embed_color: DEFAULT_EMBED_COLOR.to_string(), + mention_type: DiscordMentionType::None, + notification_types: Vec::new(), + user_filter: Vec::new(), + item_types: WebhookItemTypes::default(), + send_all_properties: false, + trim_whitespace: false, + skip_empty_message_body: false, + } + } +} + +impl WebhookForm { + fn from_dto(dto: &WebhookDto) -> Self { + let mut form = Self { + id: Some(dto.id), + name: dto + .name + .clone(), + enabled: dto.enabled, + url: dto + .url + .clone(), + template: dto + .template + .clone(), + notification_types: dto + .notification_types + .clone(), + user_filter: dto + .user_filter + .clone(), + item_types: dto + .item_types + .clone(), + send_all_properties: dto.send_all_properties, + trim_whitespace: dto.trim_whitespace, + skip_empty_message_body: dto.skip_empty_message_body, + ..Self::default() + }; + match &dto.destination { + WebhookDestination::Generic { headers, fields } => { + form.discord = false; + form.headers = headers.clone(); + form.fields = fields.clone(); + } + WebhookDestination::Discord { + avatar_url, + bot_username, + embed_color, + mention_type, + } => { + form.discord = true; + form.avatar_url = avatar_url + .clone() + .unwrap_or_default(); + form.bot_username = bot_username + .clone() + .unwrap_or_default(); + // A hook stored without a colour gets the default the server + // injects anyway, rather than an empty swatch. + form.embed_color = embed_color + .clone() + .filter(|c| { + !c.trim() + .is_empty() + }) + .unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_string()); + form.mention_type = *mention_type; + } + } + form + } + + /// A **complete** DTO. Never build a partial one: the server's + /// `WebhookDto` has no field defaults, so an omitted field is a 422. + fn to_dto(&self) -> WebhookDto { + let destination = if self.discord { + WebhookDestination::Discord { + avatar_url: non_empty(&self.avatar_url), + bot_username: non_empty(&self.bot_username), + embed_color: non_empty(&self.embed_color), + mention_type: self.mention_type, + } + } else { + WebhookDestination::Generic { + headers: self + .headers + .clone(), + fields: self + .fields + .clone(), + } + }; + WebhookDto { + // Ignored by the server on create; it assigns a fresh id. + id: self + .id + .unwrap_or_else(Uuid::nil), + name: self + .name + .trim() + .to_string(), + enabled: self.enabled, + url: self + .url + .trim() + .to_string(), + template: self + .template + .clone(), + destination, + notification_types: sorted_notification_types(&self.notification_types), + user_filter: self + .user_filter + .clone(), + item_types: self + .item_types + .clone(), + send_all_properties: self.send_all_properties, + trim_whitespace: self.trim_whitespace, + skip_empty_message_body: self.skip_empty_message_body, + created_at: None, + updated_at: None, + } + } + + fn is_valid(&self) -> bool { + !self + .name + .trim() + .is_empty() + && !self + .url + .trim() + .is_empty() + } +} + +/// Switch the destination, pre-filling the stock Discord template when — and +/// only when — the operator has not written one. Overwriting an edited template +/// would silently destroy their work. +fn apply_destination_change(form: &mut WebhookForm, discord: bool) { + form.discord = discord; + if discord + && form + .template + .trim() + .is_empty() + { + form.template = DISCORD_TEMPLATE.to_string(); + } +} + +/// `hook` with `enabled` flipped — a whole DTO, not a patch. +fn dto_with_enabled(hook: &WebhookDto, enabled: bool) -> WebhookDto { + WebhookDto { + enabled, + ..hook.clone() + } +} + +/// Outcome of the per-row "Test" button. +#[derive(Clone)] +enum TestState { + Running, + /// The API call succeeded. `WebhookTestResult::success` says whether the + /// *delivery* did. + Done(WebhookTestResult), + /// The API call itself failed — transport, auth, or a 4xx/5xx from remux. + Failed(String), +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +#[component] +pub fn WebhooksPage(app_state: AppState) -> Element { + let mut hooks: Signal> = use_signal(Vec::new); + let mut users: Signal> = use_signal(Vec::new); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + let mut refresh = use_signal(|| 0_u32); + + let mut editing: Signal> = use_signal(|| None); + let mut to_delete: Signal> = use_signal(|| None); + let mut deleting = use_signal(|| false); + let mut tests: Signal> = use_signal(HashMap::new); + + let app_state_effect = app_state.clone(); + use_effect(move || { + let _r = *refresh.read(); + loading.set(true); + let client = app_state_effect + .client + .clone(); + spawn(async move { + match client + .execute(GetWebhooks) + .await + { + Ok(list) => { + hooks.set(list); + error.set(None); + } + Err(e) => error.set(Some(format!("Failed to load webhooks: {e}"))), + } + // A user-filter list we cannot populate is a degraded form, not a + // page-level failure. + if let Ok(list) = client + .execute(GetUsers) + .await + { + users.set(list); + } + loading.set(false); + }); + }); + + rsx! { + Card { + title: "Webhooks", + tight: true, + action: rsx! { + button { + class: "btn btn-primary", + style: "height:32px;font-size:.68rem", + onclick: move |_| editing.set(Some(WebhookForm::default())), + "+ New Webhook" + } + }, + p { style: "color:var(--text-muted);font-size:.75rem;padding:0 12px 8px", + "Webhooks POST a rendered template to an external endpoint whenever a subscribed server event fires." + } + if *loading.read() { + LoadingText {} + } else if let Some(err) = error.read().as_ref() { + span { class: "loading-text", style: "color:var(--error)", "{err}" } + } else if hooks.read().is_empty() { + EmptyState { message: "No webhooks — create one to get started." } + } else { + div { class: "data-table-container", + div { class: "row-list", + for hook in hooks.read().clone() { + { + let hook_id = hook.id; + let name = hook.name.clone(); + let kind = destination_label(&hook.destination); + let kind_class = destination_badge_class(&hook.destination); + let url_preview = truncate_url(&hook.url, URL_PREVIEW_LEN); + let events = hook.notification_types.len(); + let enabled = hook.enabled; + let hook_toggle = hook.clone(); + let hook_edit = hook.clone(); + let client_toggle = app_state.client.clone(); + let client_test = app_state.client.clone(); + let delete_name = name.clone(); + let test_line = tests.read().get(&hook_id).map(|state| match state { + TestState::Running => ("Testing…".to_string(), "var(--text-muted)"), + TestState::Done(result) => ( + test_message(result), + if result.success { "var(--success)" } else { "var(--error)" }, + ), + TestState::Failed(message) => (message.clone(), "var(--error)"), + }); + rsx! { + div { + class: "flex items-center border-b border-[var(--border)] hover:bg-[rgba(0,0,0,0.03)] even:bg-[rgba(0,0,0,0.02)] even:hover:bg-[rgba(0,0,0,0.03)]", + key: "{hook_id}", + div { class: "flex-1 min-w-0 px-3 py-[10px]", + div { style: "display:flex;align-items:center;gap:8px", + span { style: "font-weight:500;font-size:.85rem", "{name}" } + span { class: "{kind_class}", "{kind}" } + } + div { style: "font-size:.72rem;color:var(--text-muted);font-family:var(--font-mono);margin-top:2px;word-break:break-all", + "{url_preview}" + } + div { style: "font-size:.72rem;color:var(--text-muted);margin-top:2px", + if events == 0 { + "No event types selected — this webhook never fires" + } else { + "{events} event types" + } + } + if let Some((message, color)) = test_line { + div { style: "font-size:.72rem;margin-top:4px;color:{color}", "{message}" } + } + } + div { class: "shrink-0 px-3 py-[10px] flex items-center gap-2", + label { class: "toggle", title: if enabled { "Enabled" } else { "Disabled" }, + input { + r#type: "checkbox", + checked: enabled, + oninput: move |e| { + let dto = dto_with_enabled(&hook_toggle, e.checked()); + let c = client_toggle.clone(); + spawn(async move { + if let Err(err) = c.execute(UpdateWebhook { id: hook_id, webhook: dto }).await { + error.set(Some(format!("Failed to update webhook: {err}"))); + } + let v = *refresh.peek() + 1; + refresh.set(v); + }); + }, + } + span { class: "toggle-track" } + } + button { + class: "btn btn-ghost", + style: "height:30px;font-size:.68rem;padding:0 10px", + onclick: move |_| editing.set(Some(WebhookForm::from_dto(&hook_edit))), + "Edit" + } + button { + class: "btn btn-ghost", + style: "height:30px;font-size:.68rem;padding:0 10px", + onclick: move |_| { + tests.write().insert(hook_id, TestState::Running); + let c = client_test.clone(); + spawn(async move { + // A refused delivery comes back as Ok(result) with + // success: false — a result, not an error. + let outcome = match c.execute(TestWebhook { id: hook_id }).await { + Ok(result) => TestState::Done(result), + Err(e) => TestState::Failed(format!("Could not run the test: {e}")), + }; + tests.write().insert(hook_id, outcome); + }); + }, + "Test" + } + button { + class: "btn btn-ghost", + style: "height:30px;font-size:.68rem;padding:0 10px;color:var(--error);border-color:var(--error)", + onclick: move |_| to_delete.set(Some((hook_id, delete_name.clone()))), + "Delete" + } + } + } + } + } + } + } + } + } + } + + if let Some(form) = editing.read().clone() { + WebhookFormModal { + app_state: app_state.clone(), + form, + users: users.read().clone(), + on_close: move |_| editing.set(None), + on_saved: move |_| { + editing.set(None); + let v = *refresh.peek() + 1; + refresh.set(v); + }, + } + } + + if let Some((id, name)) = to_delete.read().clone() { + { + let client = app_state.client.clone(); + rsx! { + div { class: "modal-backdrop", + div { class: "modal", + div { class: "modal-header", + span { class: "modal-title", "Delete Webhook" } + } + div { class: "modal-body", + p { style: "font-size:.85rem", + "Are you sure you want to delete “{name}”? Events will stop being delivered to this endpoint immediately." + } + } + div { class: "modal-footer", + button { + class: "btn btn-ghost", + onclick: move |_| to_delete.set(None), + "Cancel" + } + button { + class: "btn btn-ghost", + style: "color:var(--error);border-color:var(--error)", + disabled: *deleting.read(), + onclick: { + let c = client.clone(); + move |_| { + deleting.set(true); + let cc = c.clone(); + spawn(async move { + if let Err(e) = cc.execute(DeleteWebhook { id }).await { + error.set(Some(format!("Failed to delete webhook: {e}"))); + } + tests.write().remove(&id); + to_delete.set(None); + deleting.set(false); + let v = *refresh.peek() + 1; + refresh.set(v); + }); + } + }, + if *deleting.read() { "Deleting…" } else { "Delete" } + } + } + } + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Create / edit modal +// --------------------------------------------------------------------------- + +/// One modal for both create and edit — `form.id` decides which endpoint the +/// save hits. +#[component] +fn WebhookFormModal( + app_state: AppState, + form: WebhookForm, + users: Vec, + on_close: EventHandler<()>, + on_saved: EventHandler<()>, +) -> Element { + let mut state = use_signal(|| form.clone()); + let mut saving = use_signal(|| false); + let mut save_error = use_signal(|| Option::::None); + + // One snapshot per render: reading fields off a clone keeps every handler + // free to `state.write()` without overlapping the render's borrow. + let f = state + .read() + .clone(); + let is_new = + f.id.is_none(); + let color_swatch = color_input_value(&f.embed_color); + let mention_value = f + .mention_type + .to_string(); + let destination_value = if f.discord { "Discord" } else { "Generic" }; + let client = app_state + .client + .clone(); + + rsx! { + div { class: "modal-backdrop", + div { class: "modal modal--wide", + div { class: "modal-header", + span { class: "modal-title", if is_new { "New Webhook" } else { "Edit Webhook" } } + } + div { class: "modal-body", + FormGroup { label: "Name", + input { + class: "form-input", + r#type: "text", + placeholder: "e.g. Discord — new releases", + value: "{f.name}", + oninput: move |e| state.write().name = e.value(), + } + } + + FormGroup { label: "Destination", + select { + class: "select-input", + value: "{destination_value}", + onchange: move |e| { + let discord = e.value() == "Discord"; + apply_destination_change(&mut state.write(), discord); + }, + option { value: "Generic", selected: !f.discord, "Generic" } + option { value: "Discord", selected: f.discord, "Discord" } + } + } + + FormGroup { label: "URL", + input { + class: "form-input", + r#type: "text", + placeholder: "https://example.com/hook", + value: "{f.url}", + oninput: move |e| state.write().url = e.value(), + } + } + + ToggleRow { + label: "Enabled", + checked: f.enabled, + on_change: move |v| state.write().enabled = v, + } + + if f.discord { + div { class: "form-group", + label { class: "form-label", "Discord options" } + p { class: "field-hint", + "These are injected into the template as MentionType, EmbedColor, AvatarUrl, Username and BotUsername." + } + FormGroup { label: "Avatar URL", + input { + class: "form-input", + r#type: "text", + placeholder: "https://example.com/avatar.png", + value: "{f.avatar_url}", + oninput: move |e| state.write().avatar_url = e.value(), + } + } + FormGroup { label: "Bot username", + input { + class: "form-input", + r#type: "text", + placeholder: "Remux", + value: "{f.bot_username}", + oninput: move |e| state.write().bot_username = e.value(), + } + } + FormGroup { label: "Embed color", + div { style: "display:flex;gap:8px;align-items:center", + input { + r#type: "color", + style: "width:44px;height:34px;padding:2px;border:1px solid var(--border);border-radius:var(--radius-sm);background:transparent", + value: "{color_swatch}", + oninput: move |e| state.write().embed_color = e.value(), + } + input { + class: "form-input", + r#type: "text", + placeholder: "{DEFAULT_EMBED_COLOR}", + value: "{f.embed_color}", + oninput: move |e| state.write().embed_color = e.value(), + } + } + } + FormGroup { label: "Mention type", + select { + class: "select-input", + value: "{mention_value}", + onchange: move |e| { + state.write().mention_type = + DiscordMentionType::from_str(&e.value()).unwrap_or_default(); + }, + for mention in MENTION_TYPES { + { + let label = mention.to_string(); + rsx! { + option { + value: "{label}", + selected: f.mention_type == mention, + "{label}" + } + } + } + } + } + } + } + } else { + KeyValueEditor { + label: "Headers", + hint: "Sent with the request. A pair with an empty key or value is skipped.", + key_placeholder: "X-Api-Key", + value_placeholder: "secret", + items: f.headers.clone(), + on_change: move |items| state.write().headers = items, + } + KeyValueEditor { + label: "Template fields", + hint: "Extra variables merged into the template data.", + key_placeholder: "Environment", + value_placeholder: "production", + items: f.fields.clone(), + on_change: move |items| state.write().fields = items, + } + } + + div { class: "form-group", + label { class: "form-label", "Template" } + p { class: "field-hint", + if f.discord { + "Handlebars. For Discord the template renders the entire JSON payload — an empty template sends an empty body." + } else { + "Handlebars. The rendered output is the request body, verbatim." + } + } + textarea { + class: "form-input", + style: "min-height:200px;resize:vertical;font-family:var(--font-mono);font-size:.76rem;line-height:1.45", + spellcheck: false, + value: "{f.template}", + oninput: move |e| state.write().template = e.value(), + } + } + + div { class: "form-group", + label { class: "form-label", "Notification types" } + if f.notification_types.is_empty() { + p { class: "field-hint", style: "color:var(--warning)", + "Nothing is selected — this webhook will never fire." + } + } + div { class: "check-row-group", + for notification in NOTIFICATION_TYPES { + { + let label = notification.to_string(); + let checked = f.notification_types.contains(¬ification); + rsx! { + label { class: "check-row", key: "{label}", + input { + r#type: "checkbox", + checked, + onchange: move |e| { + let mut s = state.write(); + if e.checked() { + if !s.notification_types.contains(¬ification) { + s.notification_types.push(notification); + } + } else { + s.notification_types.retain(|t| *t != notification); + } + }, + } + "{label}" + } + } + } + } + } + } + + div { class: "form-group", + label { class: "form-label", "User filter" } + p { class: "field-hint", + "Leave everything unchecked to notify for every user." + } + if users.is_empty() { + p { class: "field-hint", "No users available." } + } else { + div { class: "check-row-group", + for user in users.iter().cloned() { + { + let user_id = user.id; + let checked = f.user_filter.contains(&user_id); + rsx! { + label { class: "check-row", key: "{user_id}", + input { + r#type: "checkbox", + checked, + onchange: move |e| { + let mut s = state.write(); + if e.checked() { + if !s.user_filter.contains(&user_id) { + s.user_filter.push(user_id); + } + } else { + s.user_filter.retain(|id| *id != user_id); + } + }, + } + "{user.name}" + } + } + } + } + } + } + } + + div { class: "form-group", + label { class: "form-label", "Item types" } + div { class: "check-row-group", + for (idx, label) in ITEM_TYPE_LABELS.iter().enumerate() { + { + let checked = item_type_flag(&f.item_types, idx); + rsx! { + label { class: "check-row", key: "{label}", + input { + r#type: "checkbox", + checked, + onchange: move |e| { + let mut s = state.write(); + set_item_type_flag(&mut s.item_types, idx, e.checked()); + }, + } + "{label}" + } + } + } + } + } + } + + div { class: "form-group", + label { class: "form-label", "Options" } + ToggleRow { + label: "Send all properties", + checked: f.send_all_properties, + on_change: move |v| state.write().send_all_properties = v, + } + ToggleRow { + label: "Trim whitespace", + checked: f.trim_whitespace, + on_change: move |v| state.write().trim_whitespace = v, + } + ToggleRow { + label: "Skip empty message body", + checked: f.skip_empty_message_body, + on_change: move |v| state.write().skip_empty_message_body = v, + } + } + + if let Some(err) = save_error.read().as_ref() { + ErrorAlert { message: err.clone() } + } + } + div { class: "modal-footer", + button { + class: "btn btn-ghost", + onclick: move |_| on_close.call(()), + "Cancel" + } + button { + class: "btn btn-primary", + disabled: *saving.read() || !f.is_valid(), + onclick: move |_| { + let snapshot = state.peek().clone(); + if !snapshot.is_valid() { + return; + } + let dto = snapshot.to_dto(); + let id = snapshot.id; + saving.set(true); + save_error.set(None); + let c = client.clone(); + spawn(async move { + let outcome = match id { + Some(id) => c.execute(UpdateWebhook { id, webhook: dto }).await.map(|_| ()), + None => c.execute(CreateWebhook { webhook: dto }).await.map(|_| ()), + }; + match outcome { + Ok(()) => on_saved.call(()), + Err(e) => save_error.set(Some(format!("Failed to save webhook: {e}"))), + } + saving.set(false); + }); + }, + if *saving.read() { "Saving…" } else { "Save" } + } + } + } + } + } +} + +/// Dynamic list of key/value pairs. Stateless — the parent owns the vector and +/// receives a whole new one on every edit. +#[component] +fn KeyValueEditor( + label: String, + hint: String, + key_placeholder: String, + value_placeholder: String, + items: Vec, + on_change: EventHandler>, +) -> Element { + rsx! { + div { class: "form-group", + label { class: "form-label", "{label}" } + p { class: "field-hint", "{hint}" } + for (idx, pair) in items.iter().enumerate() { + { + let on_key = items.clone(); + let on_value = items.clone(); + let on_remove = items.clone(); + rsx! { + div { + key: "{idx}", + style: "display:flex;gap:6px;align-items:center;margin-bottom:6px", + input { + class: "form-input", + r#type: "text", + placeholder: "{key_placeholder}", + value: "{pair.key}", + oninput: move |e| { + let mut next = on_key.clone(); + next[idx].key = e.value(); + on_change.call(next); + }, + } + input { + class: "form-input", + r#type: "text", + placeholder: "{value_placeholder}", + value: "{pair.value}", + oninput: move |e| { + let mut next = on_value.clone(); + next[idx].value = e.value(); + on_change.call(next); + }, + } + button { + class: "btn btn-ghost", + style: "height:36px;flex-shrink:0;color:var(--error);border-color:var(--error)", + onclick: move |_| { + let mut next = on_remove.clone(); + next.remove(idx); + on_change.call(next); + }, + "×" + } + } + } + } + } + { + let on_add = items.clone(); + rsx! { + button { + class: "btn btn-ghost", + style: "height:30px;font-size:.68rem;padding:0 10px", + onclick: move |_| { + let mut next = on_add.clone(); + next.push(WebhookKeyValue::default()); + on_change.call(next); + }, + "+ Add" + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kv(key: &str, value: &str) -> WebhookKeyValue { + WebhookKeyValue { + key: key.to_string(), + value: value.to_string(), + } + } + + fn discord_dto() -> WebhookDto { + WebhookDto { + id: Uuid::from_u128(0xdead_beef), + name: "Releases".to_string(), + enabled: true, + url: "https://discord.com/api/webhooks/1234/token".to_string(), + template: "{\"content\":\"{{Name}}\"}".to_string(), + destination: WebhookDestination::Discord { + avatar_url: Some("https://example.com/a.png".to_string()), + bot_username: Some("Remux".to_string()), + embed_color: Some("#aa5cc3".to_string()), + mention_type: DiscordMentionType::Everyone, + }, + notification_types: vec![ + NotificationType::ItemAdded, + NotificationType::PlaybackStop, + ], + user_filter: vec![Uuid::from_u128(7), Uuid::from_u128(9)], + item_types: WebhookItemTypes { + movies: true, + episodes: false, + series: true, + seasons: false, + albums: true, + songs: false, + videos: true, + }, + send_all_properties: true, + trim_whitespace: true, + skip_empty_message_body: true, + created_at: None, + updated_at: None, + } + } + + fn generic_dto() -> WebhookDto { + WebhookDto { + destination: WebhookDestination::Generic { + headers: vec![kv("X-Api-Key", "s3cret"), kv("X-Other", "v")], + fields: vec![kv("Environment", "production")], + }, + ..discord_dto() + } + } + + // -- url preview -------------------------------------------------------- + + #[test] + fn a_short_url_is_shown_whole() { + assert_eq!( + truncate_url("https://example.com/hook", 48), + "https://example.com/hook" + ); + } + + #[test] + fn a_long_url_is_cut_before_a_discord_token() { + let url = "https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz"; + let shown = truncate_url(url, URL_PREVIEW_LEN); + assert!( + shown.ends_with('…'), + "a long url must be visibly truncated: {shown}" + ); + assert!( + !shown.contains("AbCdEf"), + "the token must never reach the DOM: {shown}" + ); + } + + #[test] + fn truncation_does_not_split_a_char() { + // Every char is 3 bytes; a byte-wise cut would panic. + let url = "https://example.com/日本語日本語日本語"; + assert_eq!( + truncate_url(url, 22) + .chars() + .count(), + 23 + ); + } + + // -- colour ------------------------------------------------------------- + + #[test] + fn a_six_digit_hex_is_normalised_for_the_swatch() { + assert_eq!(normalize_hex_color("#AA5CC3"), Some("#aa5cc3".to_string())); + assert_eq!(normalize_hex_color("aa5cc3"), Some("#aa5cc3".to_string())); + assert_eq!( + normalize_hex_color(" #000000 "), + Some("#000000".to_string()) + ); + } + + #[test] + fn anything_that_is_not_a_six_digit_hex_is_rejected() { + for raw in ["", "#", "#12345", "#1234567", "#GGGGGG", "red"] { + assert_eq!(normalize_hex_color(raw), None, "raw = {raw:?}"); + } + } + + #[test] + fn the_swatch_falls_back_to_the_server_default() { + assert_eq!(color_input_value(""), "#3399ff"); + assert_eq!(color_input_value("#1a2b3c"), "#1a2b3c"); + } + + // -- item types --------------------------------------------------------- + + #[test] + fn every_item_type_index_round_trips() { + for idx in 0..ITEM_TYPE_LABELS.len() { + let mut types = WebhookItemTypes::default(); + set_item_type_flag(&mut types, idx, false); + assert!(!item_type_flag(&types, idx), "idx {idx} did not clear"); + set_item_type_flag(&mut types, idx, true); + assert!(item_type_flag(&types, idx), "idx {idx} did not set"); + // Clearing one flag must not disturb the others. + let mut only = WebhookItemTypes::default(); + set_item_type_flag(&mut only, idx, false); + let cleared = (0..ITEM_TYPE_LABELS.len()) + .filter(|i| !item_type_flag(&only, *i)) + .count(); + assert_eq!(cleared, 1, "idx {idx} cleared more than itself"); + } + } + + // -- notification types ------------------------------------------------- + + #[test] + fn every_notification_type_round_trips_through_its_label() { + let mut labels: Vec = NOTIFICATION_TYPES + .iter() + .map(|t| t.to_string()) + .collect(); + for (label, expected) in labels + .iter() + .zip(NOTIFICATION_TYPES.iter()) + { + assert_eq!( + NotificationType::from_str(label).ok(), + Some(*expected), + "label {label} did not parse back" + ); + } + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), 15, "the list must have no duplicates"); + } + + #[test] + fn selection_is_sorted_into_the_canonical_order_and_deduped() { + let selected = vec![ + NotificationType::UserDeleted, + NotificationType::ItemAdded, + NotificationType::UserDeleted, + ]; + assert_eq!( + sorted_notification_types(&selected), + vec![NotificationType::ItemAdded, NotificationType::UserDeleted] + ); + assert!(sorted_notification_types(&[]).is_empty()); + } + + // -- form round-trip ---------------------------------------------------- + + /// The form must not silently drop a field: a fully populated hook that + /// goes through the form and back is the same JSON the server sent. + #[test] + fn a_discord_webhook_round_trips_without_losing_a_field() { + let original = discord_dto(); + let round = WebhookForm::from_dto(&original).to_dto(); + assert_eq!( + serde_json::to_value(&original).unwrap(), + serde_json::to_value(&round).unwrap() + ); + } + + #[test] + fn a_generic_webhook_round_trips_without_losing_a_field() { + let original = generic_dto(); + let round = WebhookForm::from_dto(&original).to_dto(); + assert_eq!( + serde_json::to_value(&original).unwrap(), + serde_json::to_value(&round).unwrap() + ); + } + + #[test] + fn the_other_destinations_options_survive_a_round_trip_through_the_selector() { + let mut form = WebhookForm::from_dto(&generic_dto()); + apply_destination_change(&mut form, true); + apply_destination_change(&mut form, false); + let dto = form.to_dto(); + match dto.destination { + WebhookDestination::Generic { headers, fields } => { + assert_eq!(headers.len(), 2, "headers were dropped"); + assert_eq!(fields.len(), 1, "fields were dropped"); + } + other => panic!("expected Generic, got {other:?}"), + } + } + + #[test] + fn a_discord_hook_stored_without_a_colour_gets_the_default() { + let dto = WebhookDto { + destination: WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }, + ..discord_dto() + }; + let round = WebhookForm::from_dto(&dto).to_dto(); + match round.destination { + WebhookDestination::Discord { + embed_color, + avatar_url, + bot_username, + .. + } => { + assert_eq!(embed_color.as_deref(), Some(DEFAULT_EMBED_COLOR)); + // Blank identity options must stay absent — the stock template + // guards them with `if_exist`, which an empty string flips. + assert_eq!(avatar_url, None); + assert_eq!(bot_username, None); + } + other => panic!("expected Discord, got {other:?}"), + } + } + + #[test] + fn a_new_webhook_carries_a_nil_id_and_no_timestamps() { + let dto = WebhookForm::default().to_dto(); + assert_eq!(dto.id, Uuid::nil()); + assert_eq!(dto.created_at, None); + assert_eq!(dto.updated_at, None); + assert!(dto.enabled); + } + + #[test] + fn the_enable_toggle_sends_a_complete_dto() { + let hook = discord_dto(); + let flipped = dto_with_enabled(&hook, false); + assert!(!flipped.enabled); + let expected = serde_json::to_value(WebhookDto { + enabled: false, + ..hook + }) + .unwrap(); + assert_eq!(serde_json::to_value(&flipped).unwrap(), expected); + } + + #[test] + fn a_webhook_needs_a_name_and_a_url_before_it_can_be_saved() { + let mut form = WebhookForm::default(); + assert!(!form.is_valid()); + form.name = " ".to_string(); + form.url = "https://example.com".to_string(); + assert!(!form.is_valid(), "whitespace is not a name"); + form.name = "Hook".to_string(); + assert!(form.is_valid()); + } + + // -- destination switch ------------------------------------------------- + + #[test] + fn picking_discord_prefills_the_stock_template() { + let mut form = WebhookForm::default(); + apply_destination_change(&mut form, true); + assert!(form.discord); + assert_eq!(form.template, DISCORD_TEMPLATE); + assert!( + form.template + .contains("{{MentionType}}"), + "the stock template must expose the injected variables" + ); + } + + #[test] + fn picking_discord_never_overwrites_an_edited_template() { + let mut form = WebhookForm { + template: "mine".to_string(), + ..WebhookForm::default() + }; + apply_destination_change(&mut form, true); + assert_eq!(form.template, "mine"); + } + + #[test] + fn going_back_to_generic_leaves_the_template_alone() { + let mut form = WebhookForm::default(); + apply_destination_change(&mut form, true); + apply_destination_change(&mut form, false); + assert!(!form.discord); + assert_eq!(form.template, DISCORD_TEMPLATE); + } + + // -- test result rendering ---------------------------------------------- + + #[test] + fn a_delivered_test_reads_as_a_success() { + let message = test_message(&WebhookTestResult { + success: true, + status_code: Some(204), + error: None, + }); + assert_eq!(message, "Test delivered — HTTP 204"); + } + + /// The endpoint answers `200 OK` with `success: false` when the *target* + /// refuses. That is a result, not an API error, and must render as one. + #[test] + fn a_refused_delivery_reads_as_a_failed_result() { + let message = test_message(&WebhookTestResult { + success: false, + status_code: Some(401), + error: Some("endpoint returned 401 Unauthorized".to_string()), + }); + assert!(message.starts_with("Test failed (HTTP 401)"), "{message}"); + assert!(message.contains("401 Unauthorized"), "{message}"); + } + + #[test] + fn a_transport_failure_reads_without_a_status() { + let message = test_message(&WebhookTestResult { + success: false, + status_code: None, + error: Some("connection refused".to_string()), + }); + assert_eq!(message, "Test failed — connection refused"); + } + + // -- misc --------------------------------------------------------------- + + #[test] + fn a_blank_option_serialises_as_absent_not_empty() { + assert_eq!(non_empty(" "), None); + assert_eq!(non_empty(" x "), Some("x".to_string())); + } + + #[test] + fn destinations_are_labelled_for_the_list_badge() { + assert_eq!( + destination_label(&WebhookDestination::Generic { + headers: vec![], + fields: vec![] + }), + "Generic" + ); + assert_eq!( + destination_label(&WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }), + "Discord" + ); + } + + #[test] + fn destination_badges_reuse_an_existing_style() { + let generic = WebhookDestination::Generic { + headers: vec![], + fields: vec![], + }; + let discord = WebhookDestination::Discord { + avatar_url: None, + bot_username: None, + embed_color: None, + mention_type: DiscordMentionType::None, + }; + assert!( + destination_badge_class(&generic).starts_with("user-badge "), + "the badge must carry the base class" + ); + assert_ne!( + destination_badge_class(&generic), + destination_badge_class(&discord), + "the two destinations must be visually distinguishable" + ); + } +} diff --git a/crates/remux-dashboard/src/router.rs b/crates/remux-dashboard/src/router.rs index 61f6581cb..68cc5ce34 100644 --- a/crates/remux-dashboard/src/router.rs +++ b/crates/remux-dashboard/src/router.rs @@ -37,6 +37,8 @@ pub enum Route { SettingsRemuxdbRoute, #[route("/settings/branding")] SettingsBrandingRoute, + #[route("/settings/webhooks")] + SettingsWebhooksRoute, #[route("/access/users")] AccessUsersRoute, #[route("/access/apikeys")] @@ -134,6 +136,12 @@ pub(crate) fn SettingsBrandingRoute() -> Element { rsx! { BrandingPage { app_state } } } +#[component] +pub(crate) fn SettingsWebhooksRoute() -> Element { + let app_state = use_context::(); + rsx! { WebhooksPage { app_state } } +} + #[component] pub(crate) fn AccessUsersRoute() -> Element { let app_state = use_context::(); From 85238532f57866695a9bceef4e172c75c95242bd Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 18:08:55 +0200 Subject: [PATCH 19/29] fix(dashboard): surface webhook mutation failures and normalise embed colour --- crates/remux-dashboard/src/pages/webhooks.rs | 162 +++++++++++++++++-- crates/remux-sdks/src/lib.rs | 5 + 2 files changed, 154 insertions(+), 13 deletions(-) diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs index 1497ce96a..65c18bc08 100644 --- a/crates/remux-dashboard/src/pages/webhooks.rs +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -22,6 +22,7 @@ use remux_sdks::remux::{ NotificationType, TestWebhook, UpdateWebhook, UserDto, WebhookDestination, WebhookDto, WebhookItemTypes, WebhookKeyValue, WebhookTestResult, }; +use remux_sdks::ClientError; use std::{collections::HashMap, str::FromStr}; use uuid::Uuid; @@ -97,10 +98,15 @@ const DISCORD_TEMPLATE: &str = r##"{ } "##; -/// The colour the server injects when a Discord hook names none, mirrored here -/// so the field is never blank: the stock template interpolates `EmbedColor` -/// unguarded, and an empty swatch reads as a bug. -const DEFAULT_EMBED_COLOR: &str = "#3399FF"; +/// The colour the server injects when a Discord hook names none (`0x3399FF`), +/// mirrored here so the field is never blank: the stock template interpolates +/// `EmbedColor` unguarded, and an empty swatch reads as a bug. +/// +/// Lower-case on purpose. `` round-trips its value in lower +/// case, and [`WebhookForm::to_dto`] stores the normalized form, so keeping the +/// constant lower-case means the swatch, the text field, the stored value and +/// the placeholder are all one string. +const DEFAULT_EMBED_COLOR: &str = "#3399ff"; /// Every [`NotificationType`], in the order the SDK declares them. /// @@ -182,7 +188,7 @@ fn normalize_hex_color(raw: &str) -> Option { /// What to feed the colour swatch: the operator's colour when it parses, the /// server's default otherwise, so the widget is never blank while they type. fn color_input_value(raw: &str) -> String { - normalize_hex_color(raw).unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_ascii_lowercase()) + normalize_hex_color(raw).unwrap_or_else(|| DEFAULT_EMBED_COLOR.to_string()) } fn item_type_flag(types: &WebhookItemTypes, idx: usize) -> bool { @@ -245,6 +251,15 @@ fn sorted_notification_types(selected: &[NotificationType]) -> Vec String { + format!("Failed to {action}: {}", error.user_message()) +} + /// One line describing a completed test. A refused delivery is a *result*, not /// an error: the API call succeeded and returned `success: false`. fn test_message(result: &WebhookTestResult) -> String { @@ -395,7 +410,11 @@ impl WebhookForm { WebhookDestination::Discord { avatar_url: non_empty(&self.avatar_url), bot_username: non_empty(&self.bot_username), - embed_color: non_empty(&self.embed_color), + // Normalized, not passed through: what the swatch shows, what + // is stored and what reaches Discord must be one value. An + // unparseable colour is saved as `null`, which makes the server + // inject the same default the swatch is already displaying. + embed_color: normalize_hex_color(&self.embed_color), mention_type: self.mention_type, } } else { @@ -499,6 +518,13 @@ pub fn WebhooksPage(app_state: AppState) -> Element { let mut error = use_signal(|| Option::::None); let mut refresh = use_signal(|| 0_u32); + // Kept apart from `error` on purpose. `error` belongs to the list effect, + // which clears it on every successful reload and is only rendered when the + // page is not loading — so a failed toggle or delete written there would be + // hidden by the reload it triggers and then wiped. This one is owned by the + // mutation handlers, rendered unconditionally, and never touched by the + // effect. + let mut action_error: Signal> = use_signal(|| None); let mut editing: Signal> = use_signal(|| None); let mut to_delete: Signal> = use_signal(|| None); let mut deleting = use_signal(|| false); @@ -520,7 +546,7 @@ pub fn WebhooksPage(app_state: AppState) -> Element { hooks.set(list); error.set(None); } - Err(e) => error.set(Some(format!("Failed to load webhooks: {e}"))), + Err(e) => error.set(Some(action_failure("load webhooks", &e))), } // A user-filter list we cannot populate is a degraded form, not a // page-level failure. @@ -549,6 +575,13 @@ pub fn WebhooksPage(app_state: AppState) -> Element { p { style: "color:var(--text-muted);font-size:.75rem;padding:0 12px 8px", "Webhooks POST a rendered template to an external endpoint whenever a subscribed server event fires." } + // Outside the loading/error chain below: a mutation failure must + // survive the reload it kicks off. + if let Some(message) = action_error.read().as_ref() { + div { style: "padding:0 12px 8px", + ErrorAlert { message: message.clone() } + } + } if *loading.read() { LoadingText {} } else if let Some(err) = error.read().as_ref() { @@ -611,9 +644,15 @@ pub fn WebhooksPage(app_state: AppState) -> Element { oninput: move |e| { let dto = dto_with_enabled(&hook_toggle, e.checked()); let c = client_toggle.clone(); + action_error.set(None); spawn(async move { - if let Err(err) = c.execute(UpdateWebhook { id: hook_id, webhook: dto }).await { - error.set(Some(format!("Failed to update webhook: {err}"))); + match c.execute(UpdateWebhook { id: hook_id, webhook: dto }).await { + Ok(_) => action_error.set(None), + // The reload below snaps the switch back to + // the stored value; without this the operator + // sees a toggle that "won't stick" and no + // reason why. + Err(err) => action_error.set(Some(action_failure("update webhook", &err))), } let v = *refresh.peek() + 1; refresh.set(v); @@ -639,7 +678,10 @@ pub fn WebhooksPage(app_state: AppState) -> Element { // success: false — a result, not an error. let outcome = match c.execute(TestWebhook { id: hook_id }).await { Ok(result) => TestState::Done(result), - Err(e) => TestState::Failed(format!("Could not run the test: {e}")), + Err(e) => TestState::Failed(format!( + "Could not run the test: {}", + e.user_message() + )), }; tests.write().insert(hook_id, outcome); }); @@ -704,10 +746,14 @@ pub fn WebhooksPage(app_state: AppState) -> Element { let c = client.clone(); move |_| { deleting.set(true); + action_error.set(None); let cc = c.clone(); spawn(async move { - if let Err(e) = cc.execute(DeleteWebhook { id }).await { - error.set(Some(format!("Failed to delete webhook: {e}"))); + match cc.execute(DeleteWebhook { id }).await { + Ok(_) => action_error.set(None), + // Without this the row simply stays and the + // refusal is invisible. + Err(e) => action_error.set(Some(action_failure("delete webhook", &e))), } tests.write().remove(&id); to_delete.set(None); @@ -754,6 +800,13 @@ fn WebhookFormModal( let is_new = f.id.is_none(); let color_swatch = color_input_value(&f.embed_color); + // Blank is fine (the server injects its default); anything else that is not + // a hex colour is silently discarded on save, so say so. + let color_is_valid = f + .embed_color + .trim() + .is_empty() + || normalize_hex_color(&f.embed_color).is_some(); let mention_value = f .mention_type .to_string(); @@ -848,6 +901,11 @@ fn WebhookFormModal( oninput: move |e| state.write().embed_color = e.value(), } } + if !color_is_valid { + p { class: "field-hint", style: "color:var(--warning)", + "Not a #rrggbb colour — {DEFAULT_EMBED_COLOR} will be used." + } + } } FormGroup { label: "Mention type", select { @@ -1056,7 +1114,9 @@ fn WebhookFormModal( }; match outcome { Ok(()) => on_saved.call(()), - Err(e) => save_error.set(Some(format!("Failed to save webhook: {e}"))), + Err(e) => { + save_error.set(Some(action_failure("save webhook", &e))) + } } saving.set(false); }); @@ -1151,6 +1211,7 @@ fn KeyValueEditor( #[cfg(test)] mod tests { use super::*; + use remux_sdks::EnumCount; fn kv(key: &str, value: &str) -> WebhookKeyValue { WebhookKeyValue { @@ -1308,6 +1369,17 @@ mod tests { assert_eq!(labels.len(), 15, "the list must have no duplicates"); } + /// The form's list is hand-written; this is what stops a variant added to + /// the SDK from silently going missing from the checkbox grid. + #[test] + fn the_list_covers_every_variant_the_sdk_declares() { + assert_eq!( + NOTIFICATION_TYPES.len(), + NotificationType::COUNT, + "a NotificationType variant is missing from NOTIFICATION_TYPES" + ); + } + #[test] fn selection_is_sorted_into_the_canonical_order_and_deduped() { let selected = vec![ @@ -1390,6 +1462,47 @@ mod tests { } } + /// What the swatch shows, what is saved and what Discord receives must be + /// one value: an unparseable colour is dropped on save so the server's + /// default — the colour the swatch is already displaying — applies. + #[test] + fn an_unparseable_colour_is_not_sent_verbatim() { + let form = WebhookForm { + discord: true, + embed_color: "purple".to_string(), + ..WebhookForm::default() + }; + assert_eq!(color_input_value(&form.embed_color), DEFAULT_EMBED_COLOR); + match form + .to_dto() + .destination + { + WebhookDestination::Discord { embed_color, .. } => assert_eq!( + embed_color, None, + "an invalid colour must not reach the server" + ), + other => panic!("expected Discord, got {other:?}"), + } + } + + #[test] + fn a_colour_is_stored_in_the_form_the_swatch_uses() { + let form = WebhookForm { + discord: true, + embed_color: "#AA5CC3".to_string(), + ..WebhookForm::default() + }; + match form + .to_dto() + .destination + { + WebhookDestination::Discord { embed_color, .. } => { + assert_eq!(embed_color.as_deref(), Some("#aa5cc3")); + } + other => panic!("expected Discord, got {other:?}"), + } + } + #[test] fn a_new_webhook_carries_a_nil_id_and_no_timestamps() { let dto = WebhookForm::default().to_dto(); @@ -1482,6 +1595,29 @@ mod tests { assert!(message.contains("401 Unauthorized"), "{message}"); } + // -- mutation failures -------------------------------------------------- + + /// A failed toggle, save or delete must reach the operator as the server's + /// sentence, not as the SDK's `Display` with its status and endpoint noise. + #[test] + fn a_failed_mutation_shows_the_servers_message_only() { + let error = ClientError::Http { + status: 400, + message: "webhook url must use http or https".to_string(), + endpoint: Some("/remux/webhooks".to_string()), + body: Some( + "{\"title\":\"webhook url must use http or https\"}".to_string(), + ), + }; + let shown = action_failure("save webhook", &error); + assert_eq!( + shown, + "Failed to save webhook: webhook url must use http or https" + ); + assert!(!shown.contains("status="), "{shown}"); + assert!(!shown.contains("endpoint="), "{shown}"); + } + #[test] fn a_transport_failure_reads_without_a_status() { let message = test_message(&WebhookTestResult { diff --git a/crates/remux-sdks/src/lib.rs b/crates/remux-sdks/src/lib.rs index c86b59f4b..b95447c33 100644 --- a/crates/remux-sdks/src/lib.rs +++ b/crates/remux-sdks/src/lib.rs @@ -9,6 +9,11 @@ pub mod stremio; pub mod tmdb; pub mod trakt; +/// Re-exported so a consumer can assert against a `strum`-derived `COUNT` +/// (e.g. that a hand-written list covers every variant of an SDK enum) without +/// taking a direct dependency on `strum`. +pub use strum::EnumCount; + use http::{HeaderMap, HeaderValue, Method, header}; use itertools::Itertools; use md5; From 067c32ff3c0eee2cb302481a0139fd890f3565a5 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 18:15:17 +0200 Subject: [PATCH 20/29] fix(dashboard): clear the webhook error banner on save and keep test results on a failed delete --- crates/remux-dashboard/src/pages/webhooks.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs index 65c18bc08..d41716052 100644 --- a/crates/remux-dashboard/src/pages/webhooks.rs +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -712,6 +712,12 @@ pub fn WebhooksPage(app_state: AppState) -> Element { on_close: move |_| editing.set(None), on_saved: move |_| { editing.set(None); + // A successful save supersedes whatever a previous toggle or + // delete failed at — leaving the banner up would make it lie. + // A successful *test* deliberately does not clear it: a + // reachable endpoint says nothing about whether the failed + // write went through, and the test has its own per-row line. + action_error.set(None); let v = *refresh.peek() + 1; refresh.set(v); }, @@ -750,12 +756,17 @@ pub fn WebhooksPage(app_state: AppState) -> Element { let cc = c.clone(); spawn(async move { match cc.execute(DeleteWebhook { id }).await { - Ok(_) => action_error.set(None), + Ok(_) => { + action_error.set(None); + // Only once the row is really gone: a + // refused delete leaves the row on screen + // and must leave its test result with it. + tests.write().remove(&id); + } // Without this the row simply stays and the // refusal is invisible. Err(e) => action_error.set(Some(action_failure("delete webhook", &e))), } - tests.write().remove(&id); to_delete.set(None); deleting.set(false); let v = *refresh.peek() + 1; From cbb14cc97dc9f4bb4c208329a26f5d3b351a0fdc Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 18:32:19 +0200 Subject: [PATCH 21/29] test(server): webhook end-to-end integration tests --- crates/remux-server/src/api/webhooks.rs | 293 ++++++++++++++++++++++-- 1 file changed, 278 insertions(+), 15 deletions(-) diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index a0167fd84..89aa64dd4 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -244,7 +244,8 @@ mod tests { use http::header::{HeaderName, HeaderValue}; use httpmock::{Method::POST, Mock, MockServer}; use remux_sdks::remux::{ - NotificationType, WebhookDestination, WebhookItemTypes, WebhookKeyValue, + DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, + WebhookKeyValue, }; use serde_json::json; use std::time::{Duration, Instant}; @@ -962,6 +963,44 @@ mod tests { format!(r#"{{"content":"{content}","type":"{notification_type}"}}"#) } + /// Report `item_id` as started playing, exactly as a client would. + async fn report_playback_start( + server: &TestServer, + h: &HeaderName, + v: &HeaderValue, + item_id: Uuid, + ) { + server + .post("/sessions/playing") + .add_header(h.clone(), v.clone()) + .json(&json!({ + "ItemId": item_id, + "PlaySessionId": "emission-test", + "PositionTicks": 1_500_000_000i64, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "PlayMethod": "DirectPlay", + })) + .await + .assert_status(StatusCode::NO_CONTENT); + } + + /// The id of the authenticated user, as the API itself reports it. + async fn my_user_id(server: &TestServer, h: &HeaderName, v: &HeaderValue) -> Uuid { + let me: serde_json::Value = server + .get("/users/me") + .add_header(h.clone(), v.clone()) + .await + .json(); + Uuid::parse_str( + me["Id"] + .as_str() + .expect("/users/me must carry an Id"), + ) + .expect("the reported id must be a uuid") + } + /// `POST /sessions/playing` reaches a hook subscribed to `PlaybackStart`, /// carrying the item that is being played. #[tokio::test] @@ -992,20 +1031,7 @@ mod tests { ) .await; - server - .post("/sessions/playing") - .add_header(h.clone(), v.clone()) - .json(&json!({ - "ItemId": media.id, - "PlaySessionId": "emission-test", - "PositionTicks": 1_500_000_000i64, - "CanSeek": true, - "IsPaused": false, - "IsMuted": false, - "PlayMethod": "DirectPlay", - })) - .await - .assert_status(StatusCode::NO_CONTENT); + report_playback_start(&server, &h, &v, media.id).await; eventually("the playback start to reach the webhook", async || { hits(&endpoint).await == 1 @@ -1271,4 +1297,241 @@ mod tests { "a successful login must not emit AuthenticationFailure" ); } + + // --- the filters, against the event the server actually builds ---------- + + /// One `PlaybackStart`, three hooks, one delivery. + /// + /// `WebhookService::matches` is unit-tested against hand-built events, which + /// cannot see what the *emission site* puts in one. The subscribed hook here + /// filters on the id `/users/me` reports, so a `PlaybackStart` emitted with + /// the device id, the session id, or any other uuid in `user.id` — all of + /// which pass every unit test — leaves it at zero hits and fails. + /// + /// That same hook is the canary for the two zero assertions: the dispatcher + /// picks all three targets in a single pass over the cached hook set, so its + /// delivery proves the event was processed and filtered rather than merely + /// still in flight. + #[tokio::test] + async fn a_playback_start_reaches_only_the_hooks_whose_filters_accept_it() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let media = crate::integration_test::insert_test_source(&guard.0).await; + let me = my_user_id(&server, &h, &v).await; + + let endpoint_server = MockServer::start_async().await; + let subscribed = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/subscribed") + .body(echoed(&media.title, NotificationType::PlaybackStart)); + then.status(200); + }) + .await; + // Deliberately unconstrained: any request at all is a failure. + let mut reject = |path: &'static str| { + endpoint_server.mock(|when, then| { + when.method(POST) + .path(path); + then.status(200); + }) + }; + let wrong_type = reject("/wrong-type"); + let wrong_user = reject("/wrong-user"); + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::PlaybackStart], + user_filter: vec![me], + template: echo_template("Name"), + ..hook_dto("mine", &endpoint_server.url("/subscribed")) + }, + ) + .await; + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::ItemDeleted], + ..hook_dto("deletions only", &endpoint_server.url("/wrong-type")) + }, + ) + .await; + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::PlaybackStart], + user_filter: vec![Uuid::from_u128(0xf0f0)], + ..hook_dto("someone else", &endpoint_server.url("/wrong-user")) + }, + ) + .await; + + report_playback_start(&server, &h, &v, media.id).await; + + eventually("the subscribed hook to be delivered to", async || { + hits(&subscribed).await == 1 + }) + .await; + settle().await; + assert_eq!( + hits(&wrong_type).await, + 0, + "a hook subscribed to another type must not receive this event" + ); + assert_eq!( + hits(&wrong_user).await, + 0, + "a hook filtered on another user must not receive this event" + ); + } + + // --- the enabled switch, end to end ------------------------------------- + + /// Disabling a hook over HTTP must stop the *running* dispatcher from + /// delivering to it. + /// + /// Neither half of that is proven by the parts: the repository test shows + /// `get_enabled` filters the query, and the invalidation test shows an + /// update reaches the dispatcher, but nothing pins the two together. A + /// `reload` that read `get_all` instead would keep every existing test green + /// while making the operator's kill switch do nothing until a restart. + /// + /// The canary is created once and never touched again; its second hit is + /// what proves the post-disable event was dispatched. + #[tokio::test] + async fn disabling_a_hook_over_http_stops_its_deliveries() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + + let mut endpoint = |path: &'static str| { + endpoint_server.mock(|when, then| { + when.method(POST) + .path(path); + then.status(200); + }) + }; + let canary_ep = endpoint("/canary"); + let target_ep = endpoint("/target"); + + create( + &server, + &h, + &v, + &hook_dto("canary", &endpoint_server.url("/canary")), + ) + .await; + let target = create( + &server, + &h, + &v, + &hook_dto("target", &endpoint_server.url("/target")), + ) + .await; + + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the enabled hook to be delivered to", async || { + hits(&canary_ep).await == 1 && hits(&target_ep).await == 1 + }) + .await; + + let disabled: WebhookDto = server + .post(&format!("/remux/webhooks/{}", target.id)) + .add_header(h.clone(), v.clone()) + .json(&WebhookDto { + enabled: false, + ..hook_dto("target", &endpoint_server.url("/target")) + }) + .await + .json(); + assert!(!disabled.enabled, "the write must have taken effect"); + + guard + .0 + .webhooks + .emit(generic_event()); + eventually("the canary to see the second event", async || { + hits(&canary_ep).await == 2 + }) + .await; + settle().await; + assert_eq!( + hits(&target_ep).await, + 1, + "a disabled webhook must stop receiving events" + ); + } + + // --- the Discord destination, end to end -------------------------------- + + /// A Discord hook, created over HTTP and driven by a real event, must reach + /// the endpoint as the JSON envelope its template describes. + /// + /// The destination's settings are template *variables*, so they only work if + /// the whole chain holds: the `Discord` variant survives the DB's JSON + /// column, the dispatcher's reload hands it to `with_hook_fields`, the + /// overlay lands under the plugin's key spellings, and the sender posts the + /// result as JSON. Every link is unit-tested; nothing composed them. + #[tokio::test] + async fn a_discord_hook_posts_the_rendered_discord_envelope() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let media = crate::integration_test::insert_test_source(&guard.0).await; + + // `#AA5CC3` as the integer Discord wants. Spelled out rather than + // computed so the plugin's off-by-one hex truncation cannot creep back + // in unnoticed. + let expected = format!( + r#"{{"username":"remux","avatar_url":"https://example.test/a.png","content":"@everyone","embeds":[{{"color":11164867,"description":"{}"}}]}}"#, + media.title + ); + let endpoint_server = MockServer::start_async().await; + let endpoint = endpoint_server + .mock_async(|when, then| { + when.method(POST) + .path("/api/webhooks/1/token") + .header("content-type", "application/json; charset=utf-8") + .body(&expected); + then.status(204); + }) + .await; + + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::PlaybackStart], + destination: WebhookDestination::Discord { + avatar_url: Some("https://example.test/a.png".into()), + bot_username: Some("remux".into()), + embed_color: Some("#AA5CC3".into()), + mention_type: DiscordMentionType::Everyone, + }, + template: r#"{"username":"{{BotUsername}}","avatar_url":"{{AvatarUrl}}","content":"{{MentionType}}","embeds":[{"color":{{EmbedColor}},"description":"{{Name}}"}]}"#.into(), + ..hook_dto( + "discord", + &endpoint_server.url("/api/webhooks/1/token"), + ) + }, + ) + .await; + + report_playback_start(&server, &h, &v, media.id).await; + + eventually("the discord envelope to reach the endpoint", async || { + hits(&endpoint).await == 1 + }) + .await; + } } From 0314424620e2490a12f0e45c038e5ba1a20eeb38 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 18:47:18 +0200 Subject: [PATCH 22/29] test(server): unpin the test suite from the torrent peer port range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::torrent_peer_port defaults to Some(6881), which becomes the ten-port listen range 6881..6891 — a process-wide cap of about ten concurrent test servers, and the whole reason the crate's tests only passed under --test-threads=1. Leave it unset in the test config so no fixed peer port is claimed; the suite is now green under default parallelism. Also swap the webhook filter test's positive hook from {{Name}}, which a_playback_start_reaches_a_configured_webhook already pins byte for byte, to {{NotificationUsername}} — the variable a playback event routes through From<&db::User> for UserEventData, pinned end to end nowhere else. --- crates/remux-server/src/api/webhooks.rs | 12 ++++++++++-- crates/remux-server/src/integration_test.rs | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index 89aa64dd4..29b025885 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -1308,6 +1308,13 @@ mod tests { /// the device id, the session id, or any other uuid in `user.id` — all of /// which pass every unit test — leaves it at zero hits and fails. /// + /// Its body echoes `{{NotificationUsername}}` rather than `{{Name}}`: on a + /// playback event that variable comes from `put_user`, fed by the + /// `&db::User → UserEventData` conversion, and no other test pins it end to + /// end (`AuthenticationFailure` takes an inline branch that never touches + /// that conversion). A username half that shipped the device name, the + /// client name or an empty string would otherwise pass the whole suite. + /// /// That same hook is the canary for the two zero assertions: the dispatcher /// picks all three targets in a single pass over the cached hook set, so its /// delivery proves the event was processed and filtered rather than merely @@ -1324,7 +1331,8 @@ mod tests { .mock_async(|when, then| { when.method(POST) .path("/subscribed") - .body(echoed(&media.title, NotificationType::PlaybackStart)); + // "test" is the user `authenticated_server` seeds and logs in. + .body(echoed("test", NotificationType::PlaybackStart)); then.status(200); }) .await; @@ -1346,7 +1354,7 @@ mod tests { &WebhookDto { notification_types: vec![NotificationType::PlaybackStart], user_filter: vec![me], - template: echo_template("Name"), + template: echo_template("NotificationUsername"), ..hook_dto("mine", &endpoint_server.url("/subscribed")) }, ) diff --git a/crates/remux-server/src/integration_test.rs b/crates/remux-server/src/integration_test.rs index 8cc150c2f..df9e6e521 100644 --- a/crates/remux-server/src/integration_test.rs +++ b/crates/remux-server/src/integration_test.rs @@ -68,7 +68,12 @@ pub async fn new_test_server() -> Result<(TestServer, TestGuard)> { new_test_server_with_config(Config { database_url: Some("sqlite::memory:".into()), torrent_http_port: None, // OS picks a free ephemeral port - disable_dht: true, // no DHT needed in tests; avoids socket conflicts + // Defaults to `Some(6881)`, which becomes the ten-port peer listen range + // `6881..6891` — a process-wide cap of ~10 concurrent test servers, and + // the reason the suite used to need `--test-threads=1`. `None` leaves the + // range unset so no fixed peer port is claimed. + torrent_peer_port: None, + disable_dht: true, // no DHT needed in tests; avoids socket conflicts ..Default::default() }) .await From 6d8cb428fdf60824049257bfff0fce2c1490b8d1 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Wed, 5 Aug 2026 19:32:36 +0200 Subject: [PATCH 23/29] fix(webhooks): apply the final whole-branch review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five cross-task issues found by the final review, plus four cheap minors. - The stock Discord template used the plugin's `{{{triple}}}` interpolations, which the plugin needs because its Handlebars escapes for HTML. remux escapes for JSON instead, so a title containing `"` or `\` rendered a body Discord answered 400 to — Fatal, so no retry, one warn, and nothing for the operator. All seven are now double braces. The constant moved to remux-sdks so the server can render the very template the dashboard ships, which is the test gap that let this through: nothing anywhere exercised it. - A DB failure during the startup reload left the cache empty with `dirty` down, disabling every webhook until an admin touched one or the process restarted. The error branch re-raises the flag, as its doc comment already promised. - A template syntax error reached the operator as "Template not found: ", and was never rejected at write time. `test_body` now compiles the single template directly and propagates the parse error, and create/update refuse an unparseable template with a 400 carrying handlebars' own message — derived from the operator's template, so nothing leaks. - The saturation warning was reachable from an unauthenticated caller through the AuthenticationFailure emission, one warn line per dropped delivery. It and the per-event render-failure warning are now throttled to one line per hook per minute, carrying a suppressed count. - A failed webhook test logged nothing at all — `deliver_logged` is not on that path, contrary to its doc comment — so the operator saw a bare status and the server saw nothing. It now writes its own warn under the same redaction, and the Discord section documents that `{{ServerUrl}}` needs `public_url`. Minors: replace the mock-swap retry test with a call-count endpoint (the branch's likeliest CI flake), skip item-scoped events whose item could not be resolved instead of firing past the item-type filter with an empty body, correct the reload comment about `dirty`, and note that `public_url`'s env var is the bare `PUBLIC_URL`. --- crates/remux-dashboard/src/pages/webhooks.rs | 84 +--- crates/remux-sdks/src/remux/mod.rs | 90 +++++ crates/remux-server/src/api/webhooks.rs | 189 ++++++++- crates/remux-server/src/lib.rs | 8 + .../remux-server/src/services/webhooks/mod.rs | 205 +++++++++- .../src/services/webhooks/sender.rs | 363 ++++++++++++++---- .../src/services/webhooks/template.rs | 96 +++++ .../src/services/webhooks/throttle.rs | 147 +++++++ 8 files changed, 1026 insertions(+), 156 deletions(-) create mode 100644 crates/remux-server/src/services/webhooks/throttle.rs diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs index d41716052..4d9985862 100644 --- a/crates/remux-dashboard/src/pages/webhooks.rs +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -20,84 +20,12 @@ use dioxus::prelude::*; use remux_sdks::remux::{ CreateWebhook, DeleteWebhook, DiscordMentionType, GetUsers, GetWebhooks, NotificationType, TestWebhook, UpdateWebhook, UserDto, WebhookDestination, - WebhookDto, WebhookItemTypes, WebhookKeyValue, WebhookTestResult, + WebhookDto, WebhookItemTypes, WebhookKeyValue, WebhookTestResult, DISCORD_TEMPLATE, }; use remux_sdks::ClientError; use std::{collections::HashMap, str::FromStr}; use uuid::Uuid; -/// The Jellyfin webhook plugin's stock `Templates/Discord.handlebars`, verbatim -/// (its UTF-8 BOM stripped). -/// -/// This is not decoration. remux follows the plugin exactly: for a Discord -/// destination the operator's template renders the **entire** Discord JSON -/// payload, with the destination's options injected as the variables -/// `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` and `BotUsername`. A -/// Discord webhook with an empty template therefore POSTs an empty body, which -/// is why picking Discord pre-fills this — see [`apply_destination_change`]. -const DISCORD_TEMPLATE: &str = r##"{ - "content": "{{MentionType}}", - "avatar_url": "{{AvatarUrl}}", - "username": "{{BotUsername}}", - "embeds": [ - { - "color": "{{EmbedColor}}", - "footer": { - "text": "From {{{ServerName}}}", - "icon_url": "{{AvatarUrl}}" - }, - {{#if_equals ItemType 'Season'}} - "title": "{{{SeriesName}}} {{{Name}}} has been added to {{{ServerName}}}", - {{else}} - {{#if_equals ItemType 'Episode'}} - "title": "{{{SeriesName}}} S{{SeasonNumber00}}E{{EpisodeNumber00}} {{{Name}}} has been added to {{{ServerName}}}", - {{else}} - "title": "{{{Name}}} ({{Year}}) has been added to {{{ServerName}}}", - {{/if_equals}} - {{/if_equals}} - "thumbnail":{ - "url": "{{ServerUrl}}/Items/{{ItemId}}/Images/Primary" - }, - "description": "External Links:\n - {{~#if_exist Provider_imdb~}} - [IMDb](https://www.imdb.com/title/{{Provider_imdb}}/)\n - {{~/if_exist~}} - {{~#if_exist Provider_tmdb~}} - {{~#if_equals ItemType 'Movie'~}} - [TMDb](https://www.themoviedb.org/movie/{{Provider_tmdb}})\n - {{~else~}} - [TMDb](https://www.themoviedb.org/tv/{{Provider_tmdb}})\n - {{~/if_equals~}} - {{~/if_exist~}} - {{~#if_exist Provider_musicbrainzartist~}} - [MusicBrainz](https://musicbrainz.org/artist/{{Provider_musicbrainzartist}})\n - {{~/if_exist~}} - {{~#if_exist Provider_audiodbartist~}} - [AudioDb](https://theaudiodb.com/artist/{{Provider_audiodbartist}})\n - {{~/if_exist~}} - {{~#if_exist Provider_musicbrainztrack~}} - [MusicBrainz Track](https://musicbrainz.org/track/{{Provider_musicbrainztrack}})\n - {{~/if_exist~}} - {{~#if_exist Provider_musicbrainzalbum~}} - [MusicBrainz Album](https://musicbrainz.org/release/{{Provider_musicbrainzalbum}})\n - {{~/if_exist~}} - {{~#if_exist Provider_theaudiodbalbum~}} - [TADb Album](https://theaudiodb.com/album/{{Provider_theaudiodbalbum}})\n - {{~/if_exist~}} - {{~#if_exist Provider_tvmaze~}} - {{~#if_equals ItemType 'Episode'~}} - [TVMaze](https://www.tvmaze.com/episodes/{{Provider_tvmaze}})\n - {{~/if_equals~}} - {{~#if_equals ItemType 'Series'~}} - [TVMaze](https://www.tvmaze.com/shows/{{Provider_tvmaze}})\n - {{~/if_equals~}} - {{~/if_exist~}} - [Jellyfin]({{ServerUrl}}/web/index.html#!/details?id={{ItemId}}&serverId={{ServerId}})" - } - ] -} -"##; - /// The colour the server injects when a Discord hook names none (`0x3399FF`), /// mirrored here so the field is never blank: the stock template interpolates /// `EmbedColor` unguarded, and an empty swatch reads as a bug. @@ -878,6 +806,16 @@ fn WebhookFormModal( p { class: "field-hint", "These are injected into the template as MentionType, EmbedColor, AvatarUrl, Username and BotUsername." } + // The stock template's thumbnail and deep link are + // built from {{ServerUrl}}, which is the server's + // public URL setting. Unset it renders empty, the + // thumbnail URL comes out relative, and Discord + // answers 400 with nothing in the dashboard to say + // why — so the setting has to be discoverable from + // the one page that depends on it. + p { class: "field-hint", + "The stock template also uses ServerUrl for the thumbnail and the \"open in remux\" link. It comes from the server's public URL (the PUBLIC_URL environment variable or public_url in the config file) and renders empty when that is unset — Discord rejects the embed in that case." + } FormGroup { label: "Avatar URL", input { class: "form-input", diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index fee32b877..5ca334a04 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6249,6 +6249,96 @@ pub enum DiscordMentionType { Everyone, } +/// The Jellyfin webhook plugin's stock `Templates/Discord.handlebars` (its +/// UTF-8 BOM stripped), verbatim **except** for the plugin's triple-brace +/// interpolations, which are double braces here. +/// +/// The plugin needs `{{{X}}}` because its Handlebars escapes for *HTML*, which +/// would mangle a title into `Ocean's 11`. remux replaces that escape +/// function with a JSON-string escape, so the triple brace is no longer merely +/// unnecessary — it is unsafe: it defeats the escaping and a title containing +/// `"` or `\` renders a body Discord rejects as malformed JSON, fatally and +/// without a retry. For a title with none of those characters the output is +/// byte-identical either way. +/// +/// This is not decoration. remux follows the plugin exactly: for a Discord +/// destination the operator's template renders the **entire** Discord JSON +/// payload, with the destination's options injected as the variables +/// `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` and `BotUsername`. A +/// Discord webhook with an empty template therefore POSTs an empty body, which +/// is why the dashboard pre-fills this when Discord is picked. +/// +/// It lives in the SDK rather than in the dashboard because the dashboard is a +/// WASM crate and the Handlebars registry that renders this lives in the +/// server: both crates already depend on the SDK, so this is the only place +/// from which the server can compile the very template it ships to operators. +/// +/// `{{ServerUrl}}` comes from the server's `public_url` setting. Unset, it +/// renders empty and the `thumbnail` URL below is relative, which Discord +/// refuses. +pub const DISCORD_TEMPLATE: &str = r##"{ + "content": "{{MentionType}}", + "avatar_url": "{{AvatarUrl}}", + "username": "{{BotUsername}}", + "embeds": [ + { + "color": "{{EmbedColor}}", + "footer": { + "text": "From {{ServerName}}", + "icon_url": "{{AvatarUrl}}" + }, + {{#if_equals ItemType 'Season'}} + "title": "{{SeriesName}} {{Name}} has been added to {{ServerName}}", + {{else}} + {{#if_equals ItemType 'Episode'}} + "title": "{{SeriesName}} S{{SeasonNumber00}}E{{EpisodeNumber00}} {{Name}} has been added to {{ServerName}}", + {{else}} + "title": "{{Name}} ({{Year}}) has been added to {{ServerName}}", + {{/if_equals}} + {{/if_equals}} + "thumbnail":{ + "url": "{{ServerUrl}}/Items/{{ItemId}}/Images/Primary" + }, + "description": "External Links:\n + {{~#if_exist Provider_imdb~}} + [IMDb](https://www.imdb.com/title/{{Provider_imdb}}/)\n + {{~/if_exist~}} + {{~#if_exist Provider_tmdb~}} + {{~#if_equals ItemType 'Movie'~}} + [TMDb](https://www.themoviedb.org/movie/{{Provider_tmdb}})\n + {{~else~}} + [TMDb](https://www.themoviedb.org/tv/{{Provider_tmdb}})\n + {{~/if_equals~}} + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainzartist~}} + [MusicBrainz](https://musicbrainz.org/artist/{{Provider_musicbrainzartist}})\n + {{~/if_exist~}} + {{~#if_exist Provider_audiodbartist~}} + [AudioDb](https://theaudiodb.com/artist/{{Provider_audiodbartist}})\n + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainztrack~}} + [MusicBrainz Track](https://musicbrainz.org/track/{{Provider_musicbrainztrack}})\n + {{~/if_exist~}} + {{~#if_exist Provider_musicbrainzalbum~}} + [MusicBrainz Album](https://musicbrainz.org/release/{{Provider_musicbrainzalbum}})\n + {{~/if_exist~}} + {{~#if_exist Provider_theaudiodbalbum~}} + [TADb Album](https://theaudiodb.com/album/{{Provider_theaudiodbalbum}})\n + {{~/if_exist~}} + {{~#if_exist Provider_tvmaze~}} + {{~#if_equals ItemType 'Episode'~}} + [TVMaze](https://www.tvmaze.com/episodes/{{Provider_tvmaze}})\n + {{~/if_equals~}} + {{~#if_equals ItemType 'Series'~}} + [TVMaze](https://www.tvmaze.com/shows/{{Provider_tvmaze}})\n + {{~/if_equals~}} + {{~/if_exist~}} + [Jellyfin]({{ServerUrl}}/web/index.html#!/details?id={{ItemId}}&serverId={{ServerId}})" + } + ] +} +"##; + /// A user-defined header or template field attached to a generic webhook. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct WebhookKeyValue { diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index 29b025885..c93cf7137 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -100,6 +100,28 @@ fn with_parsed_url(payload: WebhookDto) -> Result { } } +/// `payload` with its template proved to parse — or a 400 carrying handlebars' +/// own message. +/// +/// Nothing compiled the template before it was stored, so a typo saved with a +/// clean 200 and the operator's entire feedback loop was: the save succeeds, +/// the Test button answers "Template not found: ", and production is +/// silent. The parse error is derived from the operator's own template — never +/// from a remote response, never from the URL — so returning it leaks nothing. +/// +/// Checked even when `send_all_properties` bypasses the template at render +/// time: the flag is one checkbox away from being turned off, and a template +/// that cannot parse is a latent break either way. +fn with_checked_template(payload: WebhookDto) -> Result { + match webhooks::validate_template(&payload.template) { + Ok(()) => Ok(payload), + Err(e) => { + let detail = format!("webhook template does not parse: {e}"); + Err(e.context_bad_request(&detail)) + } + } +} + /// The stored webhook, or a 404. Every by-id route starts here so a missing row /// is a 404 rather than a 500 out of the repository's re-read. async fn load(state: &AppState, id: &Uuid) -> Result { @@ -150,7 +172,7 @@ pub async fn create_webhook( _session: auth::AdminSession, Json(payload): Json, ) -> Result { - let payload = with_parsed_url(payload)?; + let payload = with_checked_template(with_parsed_url(payload)?)?; let created = db::Webhook::create( &state .ctx @@ -175,7 +197,7 @@ pub async fn update_webhook( Json(payload): Json, ) -> Result { load(&state, &id).await?; - let payload = with_parsed_url(payload)?; + let payload = with_checked_template(with_parsed_url(payload)?)?; let updated = db::Webhook::update( &state .ctx @@ -586,6 +608,83 @@ mod tests { ); } + // --- template validation ---------------------------------------------- + + /// A template that does not parse used to save with a clean 200, then fail + /// at render time with handlebars' "Template not found: " — a + /// diagnosis naming an id the operator never typed, while the real parse + /// error went only to the server log. Refuse the write instead, and say + /// why: the message comes from the operator's own template, not from any + /// remote response. + #[tokio::test] + async fn a_template_that_does_not_parse_is_rejected_on_create_and_on_update() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + let broken = WebhookDto { + template: "{{#if_equals ItemType 'Movie'}}unclosed".into(), + ..hook_dto("broken", "https://example.test/hook") + }; + server + .post("/remux/webhooks") + .add_header(h.clone(), v.clone()) + .expect_failure() + .json(&broken) + .await + .assert_status(StatusCode::BAD_REQUEST); + assert!( + list(&server, &h, &v) + .await + .is_empty(), + "a rejected create must not store anything" + ); + + let created = create( + &server, + &h, + &v, + &hook_dto("good", "https://example.test/hook"), + ) + .await; + server + .post(&format!("/remux/webhooks/{}", created.id)) + .add_header(h.clone(), v.clone()) + .expect_failure() + .json(&broken) + .await + .assert_status(StatusCode::BAD_REQUEST); + + let unchanged: WebhookDto = server + .get(&format!("/remux/webhooks/{}", created.id)) + .add_header(h, v) + .await + .json(); + assert_eq!( + unchanged.template, TEMPLATE, + "a rejected update must not touch the stored row" + ); + } + + /// The template the dashboard pre-fills for a Discord destination has to be + /// acceptable to the endpoint that stores it, or picking Discord and + /// pressing Save is an instant 400. + #[tokio::test] + async fn the_stock_discord_template_is_accepted() { + let (server, _guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + + let dto = WebhookDto { + template: remux_sdks::remux::DISCORD_TEMPLATE.into(), + ..hook_dto("discord", "https://example.test/hook") + }; + server + .post("/remux/webhooks") + .add_header(h, v) + .json(&dto) + .await + .assert_status_ok(); + } + // --- authorization ---------------------------------------------------- #[tokio::test] @@ -846,6 +945,92 @@ mod tests { ); } + // --- enrichment failures ---------------------------------------------- + + /// An item-scoped event whose item cannot be resolved must not be + /// delivered at all. + /// + /// Two separate breakages, one cause. `matches` only applies the item-type + /// rule when it is handed a kind, and enrichment is where the kind comes + /// from — so a hook with *every* item type unticked used to fire on an + /// unresolvable item. And the body it fired with had no `Name`, `ItemId` or + /// `ItemType`, which the stock Discord template renders as + /// `"title": " () has been added to remux"`. + /// + /// The canary is subscribed to a different, itemless event and is the + /// synchronisation point: once it has been hit, the dispatcher is past the + /// `ItemAdded` that preceded it, so the negative assertion is not a race. + #[tokio::test] + async fn an_item_event_whose_item_cannot_be_resolved_is_not_delivered() { + let (server, guard, token) = authenticated_server().await; + let (h, v) = auth(&token); + let endpoint_server = MockServer::start_async().await; + + let canary_ep = endpoint_server.mock(|when, then| { + when.method(POST) + .path("/canary"); + then.status(200); + }); + let unticked_ep = endpoint_server.mock(|when, then| { + when.method(POST) + .path("/unticked"); + then.status(200); + }); + + create( + &server, + &h, + &v, + &hook_dto("canary", &endpoint_server.url("/canary")), + ) + .await; + create( + &server, + &h, + &v, + &WebhookDto { + notification_types: vec![NotificationType::ItemAdded], + // Nothing is allowed through — this hook wants no item type at + // all, which is exactly what the missing kind used to bypass. + item_types: WebhookItemTypes { + movies: false, + episodes: false, + series: false, + seasons: false, + albums: false, + songs: false, + videos: false, + }, + ..hook_dto("unticked", &endpoint_server.url("/unticked")) + }, + ) + .await; + + // No such row exists, so `enrich_item` answers `None`. + guard + .0 + .webhooks + .emit(WebhookEvent::ItemAdded { + item_id: Uuid::from_u128(0xf00d), + }); + guard + .0 + .webhooks + .emit(generic_event()); + + eventually( + "the dispatcher to get past the unresolvable item", + async || hits(&canary_ep).await == 1, + ) + .await; + settle().await; + assert_eq!( + hits(&unticked_ep).await, + 0, + "an event with no resolvable item must not slip past the item-type filter" + ); + } + // --- dispatcher cache invalidation ------------------------------------ /// `invalidate()` is how a saved webhook reaches the *running* dispatcher: diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index e5cae95aa..d7fbc0a88 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -452,6 +452,14 @@ pub struct Config { /// server (webhook `ServerUrl`, deep links, image URLs); unset means "no /// absolute URL is known", and such links are rendered empty rather than /// guessed. + /// + /// The config layer uses no env prefix, so this field's environment + /// variable is the bare `PUBLIC_URL` — a name Create-React-App builds + /// export and some PaaS runtimes already set, often to `/`. A value that is + /// not an absolute URL produces relative links that the consumer rejects + /// (Discord refuses a non-absolute embed URL), so an unexpectedly populated + /// `PUBLIC_URL` in the environment is worth ruling out first when those + /// links misbehave. #[serde(default)] pub public_url: Option, } diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index e6881cd2c..a8b029246 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -10,6 +10,7 @@ pub mod events; mod payload; mod sender; mod template; +mod throttle; pub use events::{ DeviceEventData, PlaybackEventData, UserDataSaveReason, UserEventData, WebhookEvent, @@ -28,7 +29,7 @@ use tokio::{ sync::{RwLock, broadcast, broadcast::error::RecvError}, task::JoinHandle, }; -use tracing::warn; +use tracing::{debug, warn}; /// Buffered events per subscriber. Large enough that a slow dispatcher pass /// (one enrichment round-trip) never drops events under normal playback load. @@ -37,6 +38,17 @@ const EVENT_CHANNEL_CAPACITY: usize = 4096; /// `Name` seen by the template of the synthetic event [`deliver_test`] sends. pub const TEST_EVENT_TITLE: &str = "Test notification"; +/// How often a hook may repeat its "template render failed" line. +/// +/// The failure is per *event*, so a hook subscribed to `PlaybackProgress` with +/// a template that does not render logs once per progress tick, forever. The +/// first line is what an operator needs; the rest is the same line again. +const RENDER_FAILURE_WARN_WINDOW: std::time::Duration = + std::time::Duration::from_secs(60); + +static RENDER_FAILURE_WARNINGS: std::sync::LazyLock = + std::sync::LazyLock::new(|| throttle::LogThrottle::new(RENDER_FAILURE_WARN_WINDOW)); + /// The enabled webhooks as last read from the database, plus everything derived /// from them that would otherwise be recomputed per event. pub(crate) struct LoadedWebhooks { @@ -178,8 +190,18 @@ impl WebhookService { } /// Replace the cached snapshot from the database. On error the previous - /// hook set is kept — a transient DB failure must not silently disable - /// every webhook. + /// hook set is kept and the cache is marked stale again — a transient DB + /// failure must not silently disable every webhook. + /// + /// Re-raising `dirty` is what makes that promise true for the *first* + /// reload, and only the first reload can break it: [`Self::spawn_dispatcher`] + /// calls this when the previous set is [`LoadedWebhooks::default`], i.e. + /// empty, so "keep the previous set" keeps nothing. A `SQLITE_BUSY` at boot + /// would otherwise leave the cache empty with the flag down — nothing to + /// retry the load, and `wanted_mask` still `u32::MAX` from [`Self::new`], so + /// every guarded call site keeps paying full price to build events the + /// dispatcher then discards. Recovery would need an admin to touch a + /// webhook, or a restart. /// /// The server identity is reloaded here too, which is why settings writers /// call [`Self::invalidate`]: it is built once and then read by every @@ -204,6 +226,12 @@ impl WebhookService { .write() .await .server = server; + // Ask for another attempt. Without this the failure is + // permanent: the flag was consumed before the call, so nothing + // else will ever set it. + self.inner + .dirty + .store(true, Ordering::Release); return; } }; @@ -230,12 +258,13 @@ impl WebhookService { wanted, server, }; - // Published after the snapshot, and only when nothing invalidated while - // the rows above were being read. The dispatcher clears `dirty` before - // calling this, so finding it set again means `hooks` predates an - // `invalidate` whose widening this store would otherwise silently - // clobber — leaving the mask narrow, and stale, for as long as the flag - // stays unconsumed. + // Published after the snapshot, and only when the flag is down. On the + // dispatcher's steady-state path the flag was consumed just before this + // call, so finding it set again means `hooks` predates an `invalidate` + // whose widening this store would otherwise silently clobber — leaving + // the mask narrow, and stale, for as long as the flag stays unconsumed. + // The startup reload has no preceding swap, so there the check simply + // holds the mask open until a snapshot nobody has invalidated lands. if !self .inner .dirty @@ -345,6 +374,28 @@ impl WebhookService { } let item = payload::enrich_item(&ctx, &event).await; + // An item-scoped event whose item could not be resolved has + // nothing left to deliver, and delivering it anyway is worse + // than dropping it twice over: `item_kind` is `None`, so + // `matches` skips the item-type rule entirely and a hook with + // every type unticked fires; and the dictionary has no `Name`, + // `ItemId` or `ItemType`, so the stock template renders + // `"title": " () has been added to remux"`. `ItemDeleted` + // carries its row inline and never lands here. + if event + .item_id() + .is_some() + && item.is_none() + { + // `debug`, not `warn`: `enrich_item` already logged the + // real cause at warn, and a scan that deletes rows behind + // an in-flight event makes this expected rather than wrong. + debug!( + notification_type = %event.notification_type(), + "webhook event dropped, its item could not be resolved" + ); + continue; + } let item_kind = item .as_ref() .map(|i| { @@ -373,8 +424,21 @@ impl WebhookService { } // `skip_empty_message_body` suppressed the delivery. Ok(None) => {} + // Throttled: the failure is a property of the template, + // not of the event, so an unthrottled line repeats for + // every tick of a `PlaybackProgress` subscription. Err(e) => { - warn!(webhook = %hook.name, error = %e, "webhook template render failed") + if let Some(suppressed) = + RENDER_FAILURE_WARNINGS.allow(hook.id) + { + warn!( + webhook = %hook.name, + webhook_id = %hook.id, + error = %e, + suppressed, + "webhook template render failed" + ); + } } } } @@ -385,12 +449,30 @@ impl WebhookService { // --- the admin "test this webhook" path -------------------------------------- +/// Whether an operator-supplied template parses, for write-time validation. +/// +/// The error is handlebars' own parse error, derived from the operator's text +/// and nothing else — no remote response, no URL — so it is safe to return over +/// the API. Rejecting at write time is the difference between "your template +/// has an unclosed block on line 4" and a hook that saves clean, says +/// "Template not found" when tested, and stays silent in production. +pub fn validate_template(template: &str) -> Result<(), handlebars::TemplateError> { + template::validate(template) +} + /// Render `hook`'s body for the synthetic test event. /// /// The template is compiled here rather than taken from the dispatcher's cached /// registry: the hook being tested was very likely saved a moment ago, and that /// cache only reloads when the dispatcher next sees an event. Testing a hook /// against a stale template would be worse than not testing it. +/// +/// Compiled through [`template::single_registry`], not `build_registry`: the +/// latter warns-and-skips an unparseable template, which is right for the +/// dispatcher — one hook's typo must not stop the others — and wrong here, +/// because `render` would then fail with handlebars' "Template not found: +/// " while the operator's actual syntax error went only to the server +/// log. fn test_body( server: &payload::ServerInfo, hook: &db::Webhook, @@ -400,7 +482,7 @@ fn test_body( extra: Vec::new(), }; let data = payload::build_data(server, &event, None); - let registry = template::build_registry(std::slice::from_ref(hook)); + let registry = template::single_registry(hook)?; template::render(hook, ®istry, &data) } @@ -559,15 +641,42 @@ mod tests { /// The hook's template is compiled for this call, so a hook the dispatcher /// has never seen is still testable. + /// + /// And what comes back must be the *parse* error. Routed through + /// `build_registry` this failed with "Template not found: ", naming + /// an id the operator never typed while the real error went to the log — + /// so asserting `is_err()` alone was not enough to keep it honest. #[test] - fn a_template_that_does_not_compile_is_reported_not_panicked() { + fn a_template_that_does_not_compile_reports_its_parse_error() { let hook = db::Webhook { template: "{{#if_equals A}}unclosed".into(), ..permissive(vec![NotificationType::Generic]) }; + let error = test_body(&test_server_info(), &hook) + .expect_err("an uncompilable template must surface as an error") + .to_string(); + assert!( + !error.contains("Template not found"), + "the operator must not be told their template is missing: {error}" + ); assert!( - test_body(&test_server_info(), &hook).is_err(), - "an uncompilable template must surface as an error" + error.contains("{{#if_equals A}}unclosed"), + "the parse error must quote the operator's own template: {error}" + ); + } + + /// The same error is what the CRUD endpoints refuse a write with, so it has + /// to name something the operator can act on. + #[test] + fn validate_template_rejects_a_template_that_does_not_parse() { + assert!(validate_template(r#"{"content":"{{Name}}"}"#).is_ok()); + assert!(validate_template("").is_ok()); + let error = validate_template("{{#if_equals A}}unclosed") + .expect_err("an unclosed block must be refused") + .to_string(); + assert!( + !error.is_empty() && !error.contains("Template not found"), + "the refusal must carry the parse error: {error}" ); } @@ -604,6 +713,74 @@ mod tests { assert_eq!(body, "ok"); } + /// The startup reload is the one call with nothing to "keep": the previous + /// set is `LoadedWebhooks::default()`, i.e. empty. A transient + /// `SQLITE_BUSY` there used to be permanent — the flag had already been + /// consumed, so nothing would ever ask for another attempt, and every + /// webhook stayed disabled until an admin touched one or the process + /// restarted. + #[tokio::test] + async fn a_failed_reload_asks_for_another_attempt() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .expect("test server"); + let service = WebhookService::new(); + + // The dispatcher's startup path: consume the flag, then load — except + // the database is gone. + service + .inner + .dirty + .swap(false, Ordering::AcqRel); + guard + .0 + .db + .close() + .await; + + service + .reload(&guard.0) + .await; + + assert!( + service + .inner + .dirty + .load(Ordering::Acquire), + "a failed load must leave the cache stale so the next event retries it" + ); + assert!( + service.wants(NotificationType::PlaybackProgress), + "and the probe must stay open until a snapshot actually lands" + ); + } + + /// The mirror image: a load that worked must not ask to be redone, or the + /// dispatcher reloads on every single event. + #[tokio::test] + async fn a_successful_reload_leaves_the_cache_clean() { + let (_server, guard) = crate::integration_test::new_test_server() + .await + .expect("test server"); + let service = WebhookService::new(); + + service + .inner + .dirty + .swap(false, Ordering::AcqRel); + service + .reload(&guard.0) + .await; + + assert!( + !service + .inner + .dirty + .load(Ordering::Acquire), + "a load that succeeded must not mark the cache stale" + ); + } + // --- the `wants` probe ------------------------------------------------ /// Every notification type must own a bit. Two types sharing one would make diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index a82565f9f..ef76f7362 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -21,6 +21,7 @@ //! authentication — anyone holding it can post as the webhook indefinitely. No //! log line in this module may contain a URL path or query; see [`redact_url`]. +use super::throttle::LogThrottle; use crate::db; use remux_sdks::remux::{WebhookDestination, WebhookTestResult}; use reqwest::{ @@ -83,6 +84,21 @@ static WEBHOOK_CLIENT: LazyLock = LazyLock::new(|| { static DELIVERY_SLOTS: LazyLock = LazyLock::new(|| DeliverySlots::new(MAX_CONCURRENT_DELIVERIES_PER_HOOK)); +/// How often a hook may repeat its "dropping event" line. +/// +/// Without a ceiling this is an amplification primitive rather than a log line. +/// The `AuthenticationFailure` emission in `api::users` is the only one +/// reachable **without credentials**, and `User::authenticate` short-circuits +/// an unknown username in one indexed SELECT — so a credential-stuffing run is +/// cheap for the attacker and every delivery past the per-hook ceiling of +/// [`MAX_CONCURRENT_DELIVERIES_PER_HOOK`] used to write one `warn!`. At 1000 +/// requests a second that is 1000 lines a second onto the operator's disk, +/// paced entirely by the attacker. +const SATURATION_WARN_WINDOW: Duration = Duration::from_secs(60); + +static SATURATION_WARNINGS: LazyLock = + LazyLock::new(|| LogThrottle::new(SATURATION_WARN_WINDOW)); + // --- concurrency ------------------------------------------------------------ /// Delivery slots, counted **per hook**. @@ -157,12 +173,18 @@ pub(crate) fn spawn_delivery_with( policy: DeliveryPolicy, ) -> bool { let Some(permit) = slots.try_acquire(hook.id) else { - warn!( - webhook = %hook.name, - webhook_id = %hook.id, - limit = slots.limit, - "webhook already has its share of deliveries in flight, dropping event" - ); + // Throttled — see [`SATURATION_WARN_WINDOW`]. The drop itself is not + // rate-limited, only the line about it, and the line carries how many + // it now stands for. + if let Some(dropped_since) = SATURATION_WARNINGS.allow(hook.id) { + warn!( + webhook = %hook.name, + webhook_id = %hook.id, + limit = slots.limit, + dropped_since, + "webhook already has its share of deliveries in flight, dropping event" + ); + } return false; }; tokio::spawn(async move { @@ -282,8 +304,17 @@ pub(crate) async fn send_once( /// echoing the response would turn this endpoint into a read primitive: point a /// hook at an internal service, press Test, and read its reply out of the admin /// API — from where it reaches browser devtools and support tickets. Only the -/// status line comes back; the body stays in the server-side log line that -/// [`deliver_logged`] writes. +/// status line comes back. +/// +/// The body is not thrown away, though: this path writes its own server-side +/// `warn!` carrying the truncated response, under exactly the redaction +/// [`deliver_logged`] applies. It has to write its own, because +/// `deliver_logged` is never on this path — and without it a failed test was +/// *undiagnosable*, reporting `endpoint returned 400 Bad Request` to the +/// operator and nothing at all to the log. The commonest cause is a stock +/// Discord template rendering `{{ServerUrl}}` empty because `public_url` is +/// unset, which Discord rejects as a non-absolute embed URL and which the +/// status line alone cannot distinguish from anything else. /// /// The transport-error text is [`SendError`]'s, which is already redacted — a /// webhook URL is a credential and must not travel back either. @@ -298,29 +329,47 @@ async fn send_test_with( body: &str, timeout: Duration, ) -> WebhookTestResult { - match attempt_once_within(hook, body, Some(timeout)).await { - Ok(response) => WebhookTestResult { - success: true, - status_code: Some( - response - .status() - .as_u16(), - ), - error: None, - }, + let error = match attempt_once_within(hook, body, Some(timeout)).await { + Ok(response) => { + return WebhookTestResult { + success: true, + status_code: Some( + response + .status() + .as_u16(), + ), + error: None, + }; + } + Err(e) => e, + }; + + // Server-side only, and the same redaction guarantee as `deliver_logged`: + // no URL path or query, ever. `error` is `SendError`'s message, which + // already carries at most MAX_LOGGED_RESPONSE bytes of the remote body and + // has the URL stripped out of any transport error. + warn!( + webhook = %hook.name, + webhook_id = %hook.id, + endpoint = %redact_url(&hook.url), + error = %error, + "webhook test delivery failed" + ); + + match error { // Status only. `e.message` carries up to MAX_LOGGED_RESPONSE bytes of // the remote body and must not leave the server. - Err(SendError { + SendError { status: Some(status), .. - }) => WebhookTestResult { + } => WebhookTestResult { success: false, status_code: Some(status.as_u16()), error: Some(format!("endpoint returned {status}")), }, // Nothing reached the endpoint: DNS, connect, TLS or timeout. The // message is ours, not the remote's. - Err(e) => WebhookTestResult { + e => WebhookTestResult { success: false, status_code: None, error: Some(e.to_string()), @@ -633,7 +682,10 @@ mod tests { DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, WebhookKeyValue, }; - use std::time::Instant; + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Instant, + }; /// Short enough that the suite does not crawl, long enough that the two /// backoff sleeps are observable. @@ -698,6 +750,45 @@ mod tests { .to_string() } + /// A local endpoint that answers `statuses` in order — by call count, not + /// by wall clock — repeating the last one once the list runs out. + /// + /// `httpmock` cannot vary a response by call count, and swapping mocks + /// mid-retry means racing the backoff. Returns the URL and the call + /// counter. + async fn sequenced_endpoint( + statuses: &'static [u16], + ) -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&calls); + let app = axum::Router::new().route( + "/hook", + axum::routing::post(move || { + let counter = Arc::clone(&counter); + async move { + let nth = counter.fetch_add(1, Ordering::SeqCst); + let status = statuses[nth.min( + statuses + .len() + .saturating_sub(1), + )]; + axum::http::StatusCode::from_u16(status) + .expect("the test statuses must be valid") + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("a free local port"); + let addr = listener + .local_addr() + .expect("the listener must have an address"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}/hook"), calls) + } + /// Poll `condition` until it holds, failing the test rather than hanging. async fn eventually(what: &str, mut condition: impl AsyncFnMut() -> bool) { let deadline = Instant::now() + Duration::from_secs(10); @@ -1129,6 +1220,99 @@ mod tests { .await; } + /// A `tracing` subscriber that keeps what was written, scoped to the + /// current thread so parallel tests do not see each other's lines. + #[derive(Clone, Default)] + struct CapturedLogs(Arc>>); + + impl CapturedLogs { + fn text(&self) -> String { + String::from_utf8_lossy( + &self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + .into_owned() + } + } + + impl std::io::Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs { + type Writer = Self; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + /// A failed test used to be *undiagnosable*: the operator was told + /// `endpoint returned 400 Bad Request` — deliberately, the response body is + /// an SSRF read primitive — and the server log was told nothing at all, + /// because `deliver_logged` is not on this path. The commonest real cause, + /// a stock Discord template whose `{{ServerUrl}}` rendered empty because + /// `public_url` is unset, is invisible from the status line alone. + /// + /// So the body is logged, and the credential still is not: the same + /// redaction `deliver_logged` applies. + #[tokio::test] + async fn a_failed_test_is_logged_server_side_without_the_url_path() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/api/webhooks/1/s3cret-token"); + then.status(400) + .body("Invalid Form Body: embeds.0.thumbnail.url: Not a well formed URL."); + }) + .await; + + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .finish(); + + let hook = generic(&server.url("/api/webhooks/1/s3cret-token"), &[]); + let result = { + let _guard = tracing::subscriber::set_default(subscriber); + send_test(&hook, "ping").await + }; + + assert!(!result.success); + let logged = logs.text(); + assert!( + logged.contains("webhook test delivery failed"), + "the failed test must reach the server log: {logged:?}" + ); + assert!( + logged.contains("Not a well formed URL"), + "the operator needs the endpoint's reason, in the log: {logged:?}" + ); + assert!( + !logged.contains("s3cret-token"), + "the URL path is a credential and must not be logged: {logged:?}" + ); + assert!( + !result + .error + .unwrap_or_default() + .contains("Not a well formed URL"), + "…and the remote body still must not travel back to the caller" + ); + } + #[tokio::test] async fn send_test_reports_a_2xx_as_a_success() { let server = MockServer::start_async().await; @@ -1375,56 +1559,49 @@ mod tests { ); } + /// The endpoint answers by call count rather than by wall clock. The + /// previous version of this test swapped a failing mock for a healthy one + /// from the outside and needed two mock-server round-trips to land inside a + /// ~200-250 ms backoff window — which is a race, and the likeliest flake on + /// this branch. Nothing here depends on timing. #[tokio::test] async fn delivery_stops_at_the_first_success() { - let server = MockServer::start_async().await; - let failing = server - .mock_async(|when, then| { - when.path("/hook"); - then.status(500); - }) - .await; - - let hook = generic(&server.url("/hook"), &[]); - let policy = DeliveryPolicy { - attempts: 3, - retry_delay_ms: 100, - }; - let task = - tokio::spawn(async move { deliver_with(&hook, "ping", &policy).await }); - - // Let the first two attempts fail, then make the endpoint healthy again - // while the last backoff sleep is still running. - eventually("two failed attempts", async || { - failing - .hits_async() - .await - >= 2 - }) - .await; - let healthy = server - .mock_async(|when, then| { - when.path("/hook"); - then.status(200); - }) - .await; - let failed_attempts = failing - .hits_async() - .await; - failing - .delete_async() - .await; + let (url, calls) = sequenced_endpoint(&[500, 500, 200]).await; - task.await - .expect("the delivery task must not panic") + let hook = generic(&url, &[]); + deliver_with(&hook, "ping", &FAST) + .await .expect("the third attempt succeeded, so the delivery must succeed"); - assert_eq!(failed_attempts, 2); + assert_eq!( - healthy - .hits_async() - .await, - 1, - "the retry must stop at the first success" + calls.load(Ordering::SeqCst), + 3, + "the retry must stop at the first success, not spend the budget" + ); + } + + /// …and it really is the success that stops it: with a fourth attempt + /// available the delivery still ends on the 200. + #[tokio::test] + async fn a_success_ends_the_retry_loop_with_budget_to_spare() { + let (url, calls) = sequenced_endpoint(&[500, 200, 500]).await; + + let hook = generic(&url, &[]); + deliver_with( + &hook, + "ping", + &DeliveryPolicy { + attempts: 3, + retry_delay_ms: 20, + }, + ) + .await + .expect("the second attempt succeeded"); + + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "the third attempt must never have been made" ); } @@ -1541,6 +1718,58 @@ mod tests { ); } + /// [`LogThrottle`] is unit-tested in its own module; this proves it is + /// actually wired into the drop branch. + /// + /// It has to be, because that branch is reachable from an *unauthenticated* + /// caller: `api::users` emits `AuthenticationFailure` on a failed login, + /// `User::authenticate` short-circuits an unknown username in one indexed + /// SELECT, and every delivery past the ceiling used to write a line. At + /// 1000 requests a second that is 1000 lines a second, paced by the + /// attacker. + #[tokio::test] + async fn the_saturation_warning_is_logged_once_not_once_per_dropped_event() { + let slots = DeliverySlots::new(1); + // A hook id of its own: the throttle is process-wide, so sharing one + // with another test would make this depend on execution order. + let hook = db::Webhook { + id: Uuid::from_u128(0x5a7a_1a7e), + ..generic("http://127.0.0.1:1/hook", &[]) + }; + let _held = slots + .try_acquire(hook.id) + .expect("a fresh hook has a slot"); + + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .finish(); + { + let _guard = tracing::subscriber::set_default(subscriber); + for _ in 0..50 { + assert!( + !spawn_delivery_with(&slots, hook.clone(), "ping".into(), FAST), + "with no slot every one of these must be dropped" + ); + } + } + + let logged = logs.text(); + assert_eq!( + logged + .matches("dropping event") + .count(), + 1, + "fifty drops must produce one line, not fifty: {logged:?}" + ); + assert!( + logged.contains("dropped_since=0"), + "the line must carry the count it stands for: {logged:?}" + ); + } + /// The permit is taken *before* the spawn: acquiring it inside the task /// would bound sockets but let tasks pile up parked on the semaphore. /// Nothing is awaited between the call and the assertion, so the spawned diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index 360ad1dc8..42979b393 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -61,6 +61,40 @@ pub(crate) fn build_registry(hooks: &[db::Webhook]) -> Handlebars<'static> { registry } +/// A registry carrying exactly one hook's template, with the parse error +/// **propagated**. +/// +/// [`build_registry`] is deliberately lenient — one hook's typo must not stop +/// the others being delivered — but that leniency turns a syntax error into a +/// later "Template not found: " from `render`, naming an id the operator +/// never typed and hiding the real error in the server log. Callers with a +/// single hook in hand and an operator waiting on the answer use this instead. +pub(crate) fn single_registry( + hook: &db::Webhook, +) -> Result, handlebars::TemplateError> { + let mut registry = fresh_registry(); + registry.register_template_string( + &hook + .id + .to_string(), + &hook.template, + )?; + Ok(registry) +} + +/// Whether an operator-supplied template parses. +/// +/// The error text is derived from the operator's own template — never from a +/// remote response — so it is safe to hand back over the API. +/// The name the template is registered under while it is being checked. It +/// appears in handlebars' error text, so it has to read as something the +/// operator recognises rather than as an internal id. +const VALIDATION_NAME: &str = "webhook template"; + +pub(crate) fn validate(template: &str) -> Result<(), handlebars::TemplateError> { + Handlebars::new().register_template_string(VALIDATION_NAME, template) +} + pub(crate) fn register_helpers(registry: &mut Handlebars<'_>) { registry.register_helper("if_equals", Box::new(if_equals)); registry.register_helper("if_exist", Box::new(if_exist)); @@ -469,6 +503,68 @@ mod tests { ); } + // --- the stock Discord template --------------------------------------- + + /// Data covering every variable the stock template interpolates outside a + /// guard, with a title that is hostile to a JSON string literal. + fn stock_discord_data(name: &str) -> Value { + json!({ + "ServerId": "server-1", + "ServerName": "remux", + "ServerUrl": "https://media.example.test", + "ItemId": "1d0b6a1e", + "ItemType": "Movie", + "Name": name, + "Year": 2001, + }) + } + + /// The stock template ships from the dashboard (a WASM crate) and is + /// rendered by this registry (the server crate), so until it moved into the + /// SDK *nothing anywhere* exercised the two halves together — which is how + /// seven `{{{triple}}}` interpolations survived the switch from the + /// plugin's HTML escaping to [`escape_json_string`]. + /// + /// A triple brace bypasses the escape function, so a title carrying `"` or + /// `\` renders a body Discord answers 400 to. That is classified `Fatal`, + /// so there is no retry and the operator sees nothing. + #[test] + fn the_stock_discord_template_survives_a_title_that_is_hostile_to_json() { + let name = r#"Ocean's "11" \ Redux"#; + let body = render_template( + remux_sdks::remux::DISCORD_TEMPLATE, + stock_discord_data(name), + ); + + let parsed: Value = serde_json::from_str(&body).unwrap_or_else(|e| { + panic!("the stock template must render valid JSON: {e}\n{body}") + }); + assert_eq!( + parsed["embeds"][0]["title"], + json!(format!("{name} (2001) has been added to remux")), + "the title must arrive at Discord unmangled: {body}" + ); + } + + /// …and an ordinary title must render byte-identically to what the plugin's + /// own template produced, so the change is a fix and not a behaviour break. + #[test] + fn the_stock_discord_template_is_unchanged_for_an_ordinary_title() { + let body = render_template( + remux_sdks::remux::DISCORD_TEMPLATE, + stock_discord_data("A Movie"), + ); + let parsed: Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!( + parsed["embeds"][0]["title"], + json!("A Movie (2001) has been added to remux") + ); + assert_eq!( + parsed["embeds"][0]["thumbnail"]["url"], + json!("https://media.example.test/Items/1d0b6a1e/Images/Primary") + ); + } + // --- hook flags ------------------------------------------------------- #[test] diff --git a/crates/remux-server/src/services/webhooks/throttle.rs b/crates/remux-server/src/services/webhooks/throttle.rs new file mode 100644 index 000000000..a1f93b99f --- /dev/null +++ b/crates/remux-server/src/services/webhooks/throttle.rs @@ -0,0 +1,147 @@ +//! Rate-limiting for the two per-event webhook warnings. +//! +//! Both of them are driven by something the server does not control. The +//! saturation warning in [`super::sender`] fires once per delivery past a +//! hook's ceiling of four in flight, and the emission site behind it — +//! `AuthenticationFailure` — is reachable **without credentials**: at 1000 +//! failed logins a second that is 1000 `warn!` lines a second onto the +//! operator's disk, chosen by the attacker. The render-failure warning in +//! [`super::mod`] fires once per event for a template that does not render, so +//! a hook subscribed to `PlaybackProgress` with a broken template logs once per +//! progress tick, forever. +//! +//! Neither line is worth dropping — the first time each happens is exactly what +//! an operator needs to see. What is worth dropping is the repetition, so a +//! [`LogThrottle`] emits one line per key per window and carries the count of +//! what it suppressed since the last one. + +use std::{ + collections::HashMap, + sync::Mutex, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +/// One log line per key per window. +/// +/// Keyed by webhook id, so a flood against one hook never silences another — +/// the same reasoning as `sender::DeliverySlots`, and the same accepted leak: +/// entries are not pruned, which costs tens of bytes per hook an operator has +/// ever created. +pub(crate) struct LogThrottle { + window: Duration, + state: Mutex>, +} + +struct Entry { + /// When the last line was emitted for this key. + last: Instant, + /// Occurrences swallowed since then. + suppressed: u64, +} + +impl LogThrottle { + pub(crate) fn new(window: Duration) -> Self { + Self { + window, + state: Mutex::new(HashMap::new()), + } + } + + /// `Some(suppressed_since_the_last_line)` when the caller should log, and + /// `None` when it should stay quiet. The first occurrence for a key always + /// logs, with a count of zero. + pub(crate) fn allow(&self, key: Uuid) -> Option { + let now = Instant::now(); + // Short, await-free critical section. A poisoned lock is recovered + // rather than propagated: a panic elsewhere must not turn logging into + // a second failure. + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match state.get_mut(&key) { + None => { + state.insert( + key, + Entry { + last: now, + suppressed: 0, + }, + ); + Some(0) + } + Some(entry) if now.duration_since(entry.last) >= self.window => { + let suppressed = entry.suppressed; + entry.last = now; + entry.suppressed = 0; + Some(suppressed) + } + Some(entry) => { + entry.suppressed = entry + .suppressed + .saturating_add(1); + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(n: u128) -> Uuid { + Uuid::from_u128(n) + } + + /// The point of the whole module: an unauthenticated caller can drive the + /// call site at whatever rate it manages, and only the first line lands. + #[test] + fn only_the_first_occurrence_in_a_window_logs() { + let throttle = LogThrottle::new(Duration::from_secs(3600)); + assert_eq!( + throttle.allow(key(1)), + Some(0), + "the first occurrence must always be logged" + ); + for _ in 0..1000 { + assert_eq!(throttle.allow(key(1)), None); + } + } + + /// A flood against one hook must not silence a different one — the same + /// reason delivery slots are counted per hook. + #[test] + fn keys_are_throttled_independently() { + let throttle = LogThrottle::new(Duration::from_secs(3600)); + assert_eq!(throttle.allow(key(1)), Some(0)); + assert_eq!(throttle.allow(key(1)), None); + assert_eq!( + throttle.allow(key(2)), + Some(0), + "another hook's first occurrence must still be logged" + ); + } + + /// The line that does get through has to say how much it stands for, or the + /// operator reads one dropped event where there were a thousand. + #[test] + fn the_next_line_carries_what_was_suppressed() { + let throttle = LogThrottle::new(Duration::from_millis(30)); + assert_eq!(throttle.allow(key(1)), Some(0)); + for _ in 0..5 { + assert_eq!(throttle.allow(key(1)), None); + } + std::thread::sleep(Duration::from_millis(40)); + assert_eq!( + throttle.allow(key(1)), + Some(5), + "the line that gets through must carry the five it stands for" + ); + // …and the counter restarts from there. + assert_eq!(throttle.allow(key(1)), None); + std::thread::sleep(Duration::from_millis(40)); + assert_eq!(throttle.allow(key(1)), Some(1)); + } +} From 0a63184d5d6cdb078ba610bae42ef0195f5ba904 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Thu, 6 Aug 2026 01:18:54 +0200 Subject: [PATCH 24/29] docs(webhooks): trim comments to the constraints that matter --- crates/remux-dashboard/src/pages/webhooks.rs | 99 +++--- crates/remux-sdks/src/remux/mod.rs | 29 +- crates/remux-server/src/api/session.rs | 10 +- crates/remux-server/src/api/users.rs | 11 +- crates/remux-server/src/api/webhooks.rs | 218 ++++--------- crates/remux-server/src/lib.rs | 10 +- .../remux-server/src/services/webhooks/mod.rs | 240 +++++--------- .../src/services/webhooks/payload.rs | 62 ++-- .../src/services/webhooks/sender.rs | 297 +++++------------- .../src/services/webhooks/template.rs | 63 ++-- .../src/services/webhooks/throttle.rs | 33 +- 11 files changed, 343 insertions(+), 729 deletions(-) diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs index 4d9985862..b4612a5f3 100644 --- a/crates/remux-dashboard/src/pages/webhooks.rs +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -3,10 +3,8 @@ //! Two rules govern everything in this module. //! //! **A webhook URL is a credential.** Discord's is -//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the whole -//! authentication, so the URL is never written to the browser console (nothing -//! here logs at all) and the list renders only a truncated prefix that stops -//! short of any Discord token. +//! `https://discord.com/api/webhooks/{id}/{token}`, so the URL is never logged +//! and the list renders only a prefix that stops short of any Discord token. //! //! **Every mutation sends a complete [`WebhookDto`].** `WebhookDto` carries no //! `#[serde(default)]`, so a payload missing one field is a 422 rather than a @@ -27,21 +25,17 @@ use std::{collections::HashMap, str::FromStr}; use uuid::Uuid; /// The colour the server injects when a Discord hook names none (`0x3399FF`), -/// mirrored here so the field is never blank: the stock template interpolates -/// `EmbedColor` unguarded, and an empty swatch reads as a bug. +/// mirrored here so the field is never blank. /// -/// Lower-case on purpose. `` round-trips its value in lower -/// case, and [`WebhookForm::to_dto`] stores the normalized form, so keeping the -/// constant lower-case means the swatch, the text field, the stored value and -/// the placeholder are all one string. +/// Lower-case on purpose: `` round-trips its value in lower +/// case and [`WebhookForm::to_dto`] stores the normalized form, so the swatch, +/// the text field, the stored value and the placeholder are all one string. const DEFAULT_EMBED_COLOR: &str = "#3399ff"; /// Every [`NotificationType`], in the order the SDK declares them. /// -/// The list is hand-written, so a variant added to the SDK must be added here -/// too. `every_notification_type_round_trips_through_its_label` keeps the -/// entries themselves honest (each label parses back to the variant, and no -/// entry is duplicated); the array's declared length is what pins the count. +/// Hand-written, so a variant added to the SDK must be added here too — the +/// array's declared length is what pins the count. const NOTIFICATION_TYPES: [NotificationType; 15] = [ NotificationType::ItemAdded, NotificationType::ItemDeleted, @@ -152,8 +146,7 @@ fn destination_label(destination: &WebhookDestination) -> &'static str { } } -/// Badge styling for the list, reusing the existing user-badge variants rather -/// than adding CSS: Discord gets the accented one, Generic the muted one. +/// Reuses the existing user-badge variants rather than adding CSS. fn destination_badge_class(destination: &WebhookDestination) -> &'static str { match destination { WebhookDestination::Generic { .. } => "user-badge user-badge-self", @@ -179,11 +172,9 @@ fn sorted_notification_types(selected: &[NotificationType]) -> Vec String { format!("Failed to {action}: {}", error.user_message()) } @@ -338,10 +329,9 @@ impl WebhookForm { WebhookDestination::Discord { avatar_url: non_empty(&self.avatar_url), bot_username: non_empty(&self.bot_username), - // Normalized, not passed through: what the swatch shows, what - // is stored and what reaches Discord must be one value. An - // unparseable colour is saved as `null`, which makes the server - // inject the same default the swatch is already displaying. + // What the swatch shows, what is stored and what reaches + // Discord must be one value: an unparseable colour is saved as + // `null`, so the server injects the default already displayed. embed_color: normalize_hex_color(&self.embed_color), mention_type: self.mention_type, } @@ -401,8 +391,7 @@ impl WebhookForm { } /// Switch the destination, pre-filling the stock Discord template when — and -/// only when — the operator has not written one. Overwriting an edited template -/// would silently destroy their work. +/// only when — the operator has not written one. fn apply_destination_change(form: &mut WebhookForm, discord: bool) { form.discord = discord; if discord @@ -446,12 +435,9 @@ pub fn WebhooksPage(app_state: AppState) -> Element { let mut error = use_signal(|| Option::::None); let mut refresh = use_signal(|| 0_u32); - // Kept apart from `error` on purpose. `error` belongs to the list effect, - // which clears it on every successful reload and is only rendered when the - // page is not loading — so a failed toggle or delete written there would be - // hidden by the reload it triggers and then wiped. This one is owned by the - // mutation handlers, rendered unconditionally, and never touched by the - // effect. + // Kept apart from `error`, which the list effect clears on every successful + // reload and which only renders when the page is not loading: a mutation + // failure has to survive the reload it triggers. let mut action_error: Signal> = use_signal(|| None); let mut editing: Signal> = use_signal(|| None); let mut to_delete: Signal> = use_signal(|| None); @@ -577,9 +563,8 @@ pub fn WebhooksPage(app_state: AppState) -> Element { match c.execute(UpdateWebhook { id: hook_id, webhook: dto }).await { Ok(_) => action_error.set(None), // The reload below snaps the switch back to - // the stored value; without this the operator - // sees a toggle that "won't stick" and no - // reason why. + // the stored value, so the refusal has to be + // said out loud. Err(err) => action_error.set(Some(action_failure("update webhook", &err))), } let v = *refresh.peek() + 1; @@ -602,7 +587,7 @@ pub fn WebhooksPage(app_state: AppState) -> Element { tests.write().insert(hook_id, TestState::Running); let c = client_test.clone(); spawn(async move { - // A refused delivery comes back as Ok(result) with + // A refused delivery is Ok(result) with // success: false — a result, not an error. let outcome = match c.execute(TestWebhook { id: hook_id }).await { Ok(result) => TestState::Done(result), @@ -640,11 +625,10 @@ pub fn WebhooksPage(app_state: AppState) -> Element { on_close: move |_| editing.set(None), on_saved: move |_| { editing.set(None); - // A successful save supersedes whatever a previous toggle or - // delete failed at — leaving the banner up would make it lie. - // A successful *test* deliberately does not clear it: a + // A successful save supersedes a previously failed toggle or + // delete. A successful *test* deliberately does not: a // reachable endpoint says nothing about whether the failed - // write went through, and the test has its own per-row line. + // write went through. action_error.set(None); let v = *refresh.peek() + 1; refresh.set(v); @@ -691,8 +675,6 @@ pub fn WebhooksPage(app_state: AppState) -> Element { // and must leave its test result with it. tests.write().remove(&id); } - // Without this the row simply stays and the - // refusal is invisible. Err(e) => action_error.set(Some(action_failure("delete webhook", &e))), } to_delete.set(None); @@ -731,8 +713,8 @@ fn WebhookFormModal( let mut saving = use_signal(|| false); let mut save_error = use_signal(|| Option::::None); - // One snapshot per render: reading fields off a clone keeps every handler - // free to `state.write()` without overlapping the render's borrow. + // One snapshot per render, so every handler is free to `state.write()` + // without overlapping the render's borrow. let f = state .read() .clone(); @@ -806,13 +788,10 @@ fn WebhookFormModal( p { class: "field-hint", "These are injected into the template as MentionType, EmbedColor, AvatarUrl, Username and BotUsername." } - // The stock template's thumbnail and deep link are - // built from {{ServerUrl}}, which is the server's - // public URL setting. Unset it renders empty, the - // thumbnail URL comes out relative, and Discord - // answers 400 with nothing in the dashboard to say - // why — so the setting has to be discoverable from - // the one page that depends on it. + // {{ServerUrl}} renders empty when the server's + // public URL is unset, which Discord answers 400 to + // — so the setting has to be discoverable from the + // page that depends on it. p { class: "field-hint", "The stock template also uses ServerUrl for the thumbnail and the \"open in remux\" link. It comes from the server's public URL (the PUBLIC_URL environment variable or public_url in the config file) and renders empty when that is unset — Discord rejects the embed in that case." } @@ -1318,8 +1297,7 @@ mod tests { assert_eq!(labels.len(), 15, "the list must have no duplicates"); } - /// The form's list is hand-written; this is what stops a variant added to - /// the SDK from silently going missing from the checkbox grid. + /// The form's list is hand-written. #[test] fn the_list_covers_every_variant_the_sdk_declares() { assert_eq!( @@ -1345,8 +1323,6 @@ mod tests { // -- form round-trip ---------------------------------------------------- - /// The form must not silently drop a field: a fully populated hook that - /// goes through the form and back is the same JSON the server sent. #[test] fn a_discord_webhook_round_trips_without_losing_a_field() { let original = discord_dto(); @@ -1411,9 +1387,8 @@ mod tests { } } - /// What the swatch shows, what is saved and what Discord receives must be - /// one value: an unparseable colour is dropped on save so the server's - /// default — the colour the swatch is already displaying — applies. + /// An unparseable colour is dropped on save so the server's default — the + /// colour the swatch is already displaying — applies. #[test] fn an_unparseable_colour_is_not_sent_verbatim() { let form = WebhookForm { @@ -1532,7 +1507,7 @@ mod tests { } /// The endpoint answers `200 OK` with `success: false` when the *target* - /// refuses. That is a result, not an API error, and must render as one. + /// refuses — a result, not an API error. #[test] fn a_refused_delivery_reads_as_a_failed_result() { let message = test_message(&WebhookTestResult { @@ -1546,8 +1521,8 @@ mod tests { // -- mutation failures -------------------------------------------------- - /// A failed toggle, save or delete must reach the operator as the server's - /// sentence, not as the SDK's `Display` with its status and endpoint noise. + /// The server's sentence, not the SDK's `Display` with its status and + /// endpoint noise. #[test] fn a_failed_mutation_shows_the_servers_message_only() { let error = ClientError::Http { diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 59be3d8f1..cfce5fea5 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6258,25 +6258,20 @@ pub enum DiscordMentionType { /// UTF-8 BOM stripped), verbatim **except** for the plugin's triple-brace /// interpolations, which are double braces here. /// -/// The plugin needs `{{{X}}}` because its Handlebars escapes for *HTML*, which -/// would mangle a title into `Ocean's 11`. remux replaces that escape -/// function with a JSON-string escape, so the triple brace is no longer merely -/// unnecessary — it is unsafe: it defeats the escaping and a title containing -/// `"` or `\` renders a body Discord rejects as malformed JSON, fatally and -/// without a retry. For a title with none of those characters the output is -/// byte-identical either way. +/// The plugin needs `{{{X}}}` because its Handlebars escapes for *HTML*. remux +/// escapes for a JSON string instead, which makes the triple brace unsafe: it +/// defeats the escaping, and a title containing `"` or `\` then renders a body +/// Discord rejects, fatally and without a retry. /// -/// This is not decoration. remux follows the plugin exactly: for a Discord -/// destination the operator's template renders the **entire** Discord JSON -/// payload, with the destination's options injected as the variables -/// `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` and `BotUsername`. A -/// Discord webhook with an empty template therefore POSTs an empty body, which -/// is why the dashboard pre-fills this when Discord is picked. +/// For a Discord destination the operator's template renders the **entire** +/// Discord JSON payload, with the destination's options injected as the +/// variables `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` and +/// `BotUsername` — so an empty template POSTs an empty body, which is why the +/// dashboard pre-fills this when Discord is picked. /// -/// It lives in the SDK rather than in the dashboard because the dashboard is a -/// WASM crate and the Handlebars registry that renders this lives in the -/// server: both crates already depend on the SDK, so this is the only place -/// from which the server can compile the very template it ships to operators. +/// It lives in the SDK because the dashboard that ships it is a WASM crate and +/// the Handlebars registry that renders it lives in the server; both depend on +/// the SDK, so this is the only place both can reach. /// /// `{{ServerUrl}}` comes from the server's `public_url` setting. Unset, it /// renders empty and the `thumbnail` URL below is relative, which Discord diff --git a/crates/remux-server/src/api/session.rs b/crates/remux-server/src/api/session.rs index a42a65cc2..bc1893e77 100644 --- a/crates/remux-server/src/api/session.rs +++ b/crates/remux-server/src/api/session.rs @@ -296,12 +296,10 @@ pub async fn report_playback_stopped( .ws_tx .send(crate::ws::WsEvent::SessionsChanged); - // Reported only for a stop that recorded something. The endpoint - // answers 204 to any authenticated client that posts any item id, with - // or without a session behind it, and deriving the event from the - // *request* rather than from what was written would let that client - // forge playback against the operator's endpoint — and make - // `UserDataSaved` assert a save that never happened. + // Only for a stop that recorded something: the endpoint answers 204 to + // any authenticated client for any item id, so an event derived from + // the *request* would let that client forge playback against the + // operator's endpoint. if let Some(recorded) = recorded { if state .ctx diff --git a/crates/remux-server/src/api/users.rs b/crates/remux-server/src/api/users.rs index f8c78cf62..18a439114 100644 --- a/crates/remux-server/src/api/users.rs +++ b/crates/remux-server/src/api/users.rs @@ -345,16 +345,11 @@ pub async fn users_authenticatebyname( .unwrap_or(""), ) .await?; - // `authenticate` answers `Ok(None)` for both an unknown user and a wrong - // password, and only for those — a DB failure is an error, not a failed - // login, and must not be reported as one. The `?` below is untouched, so - // the refusal is the same 401 with the same body and the same timing. + // `authenticate` answers `Ok(None)` only for an unknown user or a wrong + // password — a DB failure is an error, not a failed login. // // Guarded, unlike the other auth events: this is the only emission site - // reachable without credentials, so a credential-stuffing run drives it at - // whatever rate the attacker can manage. With nothing subscribed that is an - // allocation and a broadcast send per attempt, and enough of them push the - // dispatcher into `Lagged` warn-spam. + // reachable without credentials, so its rate is the attacker's. if authenticated.is_none() && state .ctx diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index c93cf7137..8c60268f6 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -1,18 +1,14 @@ //! Admin CRUD over outgoing webhooks, plus the synchronous "test this webhook" //! endpoint the dashboard uses for immediate feedback. //! -//! Two invariants hold across every handler here. -//! //! **Every route is admin-only.** A webhook URL is a credential — Discord's is -//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the entire -//! authentication — so read access is as sensitive as write access. `session: -//! auth::AdminSession` in the signature is the whole mechanism; there is no -//! path into this module without it. +//! `https://discord.com/api/webhooks/{id}/{token}` — so read access is as +//! sensitive as write access. `auth::AdminSession` in the signature is the +//! whole mechanism. //! //! **Every mutation ends in `state.ctx.webhooks.invalidate()`.** The dispatcher //! caches the enabled hook set and reloads only when that flag is set, so a -//! write that skips the call returns a perfect 200 and then silently does -//! nothing until the process restarts. +//! write that skips the call does nothing until the process restarts. use axum::{ Json, @@ -35,18 +31,14 @@ use axum_anyhow::ApiResult as Result; /// A URL the server is willing to POST a webhook to. /// -/// Parse, don't validate: the value cannot be constructed from anything but an -/// absolute `http(s)` URL with a host, so nothing downstream — the DB row, the -/// dispatcher's cached snapshot, the delivery task — has to re-check. The -/// scheme restriction is not cosmetic: `Url::parse` cheerfully accepts -/// `file:///etc/shadow` and `javascript:alert(1)`, and neither belongs anywhere -/// near the delivery path. +/// Parse, don't validate: nothing downstream has to re-check. The scheme +/// restriction is load-bearing — `Url::parse` accepts `file:///etc/shadow` and +/// `javascript:alert(1)`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebhookUrl(Url); impl WebhookUrl { - /// The canonical serialization — this, not the operator's raw string, is - /// what gets stored. + /// The canonical serialization, not the operator's raw string. fn into_stored(self) -> String { self.0 .into() @@ -103,15 +95,11 @@ fn with_parsed_url(payload: WebhookDto) -> Result { /// `payload` with its template proved to parse — or a 400 carrying handlebars' /// own message. /// -/// Nothing compiled the template before it was stored, so a typo saved with a -/// clean 200 and the operator's entire feedback loop was: the save succeeds, -/// the Test button answers "Template not found: ", and production is -/// silent. The parse error is derived from the operator's own template — never -/// from a remote response, never from the URL — so returning it leaks nothing. +/// The parse error is derived from the operator's own template — never from a +/// remote response, never from the URL — so returning it leaks nothing. /// /// Checked even when `send_all_properties` bypasses the template at render -/// time: the flag is one checkbox away from being turned off, and a template -/// that cannot parse is a latent break either way. +/// time: the flag is one checkbox away from being turned off. fn with_checked_template(payload: WebhookDto) -> Result { match webhooks::validate_template(&payload.template) { Ok(()) => Ok(payload), @@ -122,8 +110,8 @@ fn with_checked_template(payload: WebhookDto) -> Result { } } -/// The stored webhook, or a 404. Every by-id route starts here so a missing row -/// is a 404 rather than a 500 out of the repository's re-read. +/// The stored webhook, or a 404 — so a missing row is not a 500 out of the +/// repository's re-read. async fn load(state: &AppState, id: &Uuid) -> Result { db::Webhook::get_by_id( &state @@ -240,8 +228,7 @@ pub async fn delete_webhook( /// /// Synchronous and outside the broadcast channel on purpose — see /// [`webhooks::deliver_test`]. A refusing or unreachable endpoint is a `200` -/// carrying `success: false`: the *request* worked, the *test* did not, and the -/// dashboard needs the difference. +/// carrying `success: false`: the *request* worked, the *test* did not. #[post("/remux/webhooks/{id}/test")] pub async fn test_webhook( State(state): State, @@ -272,8 +259,8 @@ mod tests { use serde_json::json; use std::time::{Duration, Instant}; - /// A body that is valid JSON and echoes exactly one variable, so a received - /// request pins both the template output and the variable dictionary. + /// Valid JSON echoing exactly one variable, so a received request pins both + /// the template output and the variable dictionary. const TEMPLATE: &str = r#"{"content":"{{Name}}"}"#; fn auth(token: &str) -> (HeaderName, HeaderValue) { @@ -283,8 +270,8 @@ mod tests { ) } - /// A fully populated create payload. `id` is deliberately non-nil so the - /// round-trip proves the server assigns its own. + /// `id` is deliberately non-nil so the round-trip proves the server assigns + /// its own. fn hook_dto(name: &str, url: &str) -> WebhookDto { WebhookDto { id: Uuid::from_u128(0xdead_beef), @@ -350,8 +337,7 @@ mod tests { } /// Give an unwanted delivery every chance to arrive before asserting it did - /// not. The dispatcher spawns deliveries, so "the canary was hit" only - /// proves the event was *dispatched*, not that a stray socket has settled. + /// not: the canary only proves the event was *dispatched*. async fn settle() { tokio::time::sleep(Duration::from_millis(250)).await; } @@ -404,8 +390,7 @@ mod tests { } } - /// The rejection travels back to the browser and into logs, so it must not - /// carry the URL it is rejecting. + /// The rejection travels back to the browser and into logs. #[test] fn a_url_rejection_never_echoes_the_url() { let secret = "gopher://discord.com/api/webhooks/1/aVerySecretToken"; @@ -486,7 +471,6 @@ mod tests { ); assert_eq!(updated.destination, update.destination); - // The update is persisted, not just echoed. let refetched: WebhookDto = server .get(&format!("/remux/webhooks/{}", created.id)) .add_header(h.clone(), v.clone()) @@ -553,8 +537,6 @@ mod tests { // --- url validation --------------------------------------------------- - /// The URL is parsed, not trusted: a hook whose URL cannot be posted to is - /// a hook that fails silently in a background task forever after. #[tokio::test] async fn a_url_that_does_not_parse_is_rejected_on_create_and_on_update() { let (server, _guard, token) = authenticated_server().await; @@ -610,12 +592,8 @@ mod tests { // --- template validation ---------------------------------------------- - /// A template that does not parse used to save with a clean 200, then fail - /// at render time with handlebars' "Template not found: " — a - /// diagnosis naming an id the operator never typed, while the real parse - /// error went only to the server log. Refuse the write instead, and say - /// why: the message comes from the operator's own template, not from any - /// remote response. + /// The write is refused with the parse error, which comes from the + /// operator's own template and not from any remote response. #[tokio::test] async fn a_template_that_does_not_parse_is_rejected_on_create_and_on_update() { let (server, _guard, token) = authenticated_server().await; @@ -665,9 +643,7 @@ mod tests { ); } - /// The template the dashboard pre-fills for a Discord destination has to be - /// acceptable to the endpoint that stores it, or picking Discord and - /// pressing Save is an instant 400. + /// The template the dashboard pre-fills has to be acceptable here. #[tokio::test] async fn the_stock_discord_template_is_accepted() { let (server, _guard, token) = authenticated_server().await; @@ -726,9 +702,8 @@ mod tests { } } - /// A webhook URL embeds a credential (Discord's is - /// `.../webhooks/{id}/{token}`), so a non-admin must not be able to read - /// one — not through the list, not through a by-id read. + /// A webhook URL embeds a credential, so a non-admin must not be able to + /// read one — not through the list, not through a by-id read. #[tokio::test] async fn a_non_admin_cannot_read_or_write_webhooks() { let (server, _guard, admin_token) = authenticated_server().await; @@ -808,10 +783,8 @@ mod tests { // --- the test endpoint ------------------------------------------------ - /// The test endpoint bypasses the broadcast channel entirely: the hook here - /// is disabled and subscribes to nothing, so the dispatcher would never - /// deliver to it. It must still be tested, synchronously, and the endpoint's - /// answer must come back to the caller. + /// The hook here is disabled and subscribes to nothing, so the dispatcher + /// would never deliver to it — it must still be testable. #[tokio::test] async fn the_test_endpoint_delivers_once_and_reports_the_status() { let (server, _guard, token) = authenticated_server().await; @@ -856,16 +829,10 @@ mod tests { assert_eq!(result.error, None); } - /// A failing endpoint is a failed *test*, not a failed request: the - /// dashboard needs the status to show it. And it is one attempt — the retry - /// policy belongs to background delivery, not to an operator waiting on an - /// answer. - /// - /// The remote's **response body** must not come back. The URL is - /// admin-controlled and unrestricted by host, so echoing what the endpoint - /// said would make this route a read primitive against anything the server - /// can reach; this asserts on the raw HTTP response, not just the parsed - /// field, so no route out of the handler is missed. + /// The remote's **response body** must not come back: the URL is + /// admin-controlled and unrestricted by host, so echoing it would make this + /// route a read primitive. Asserted on the raw HTTP response, not just the + /// parsed field. #[tokio::test] async fn the_test_endpoint_reports_a_rejecting_endpoint_without_retrying() { let (server, _guard, token) = authenticated_server().await; @@ -913,8 +880,7 @@ mod tests { .await; } - /// An unreachable endpoint must come back as a failed test rather than - /// hanging the handler or leaking the URL path into the response. + /// An unreachable endpoint must not hang the handler or leak the URL path. #[tokio::test] async fn the_test_endpoint_reports_an_unreachable_endpoint() { let (server, _guard, token) = authenticated_server().await; @@ -947,15 +913,9 @@ mod tests { // --- enrichment failures ---------------------------------------------- - /// An item-scoped event whose item cannot be resolved must not be - /// delivered at all. - /// - /// Two separate breakages, one cause. `matches` only applies the item-type - /// rule when it is handed a kind, and enrichment is where the kind comes - /// from — so a hook with *every* item type unticked used to fire on an - /// unresolvable item. And the body it fired with had no `Name`, `ItemId` or - /// `ItemType`, which the stock Discord template renders as - /// `"title": " () has been added to remux"`. + /// An item-scoped event whose item cannot be resolved must not be delivered + /// at all: `matches` only applies the item-type rule when it is handed a + /// kind, and enrichment is where the kind comes from. /// /// The canary is subscribed to a different, itemless event and is the /// synchronisation point: once it has been hit, the dispatcher is past the @@ -990,8 +950,7 @@ mod tests { &v, &WebhookDto { notification_types: vec![NotificationType::ItemAdded], - // Nothing is allowed through — this hook wants no item type at - // all, which is exactly what the missing kind used to bypass. + // This hook wants no item type at all. item_types: WebhookItemTypes { movies: false, episodes: false, @@ -1006,7 +965,6 @@ mod tests { ) .await; - // No such row exists, so `enrich_item` answers `None`. guard .0 .webhooks @@ -1033,16 +991,13 @@ mod tests { // --- dispatcher cache invalidation ------------------------------------ - /// `invalidate()` is how a saved webhook reaches the *running* dispatcher: - /// it caches the enabled hook set and reloads only when that flag is set. - /// A create, update or delete that forgets the call looks perfect over HTTP - /// and silently does nothing until the process restarts — so this drives - /// the real cycle (write over HTTP, emit an event, watch the socket). + /// `invalidate()` is how a saved webhook reaches the *running* dispatcher, + /// so this drives the real cycle: write over HTTP, emit an event, watch the + /// socket. /// /// The canary hook is never touched after its creation. Its hit count is /// the synchronisation point: once it has seen event N, the dispatcher has - /// finished dispatching event N, which is what makes the negative - /// assertions below meaningful rather than a race. + /// finished dispatching event N, so the negative assertions are not races. #[tokio::test] async fn create_update_and_delete_each_reach_the_running_dispatcher() { let (server, guard, token) = authenticated_server().await; @@ -1131,13 +1086,11 @@ mod tests { // --- the emission sites ----------------------------------------------- // - // These drive real HTTP endpoints and watch a real socket. Each mock - // matches the *exact* body it expects, so a hit proves both that the site - // emits and that the event carried the right data — a wrong payload leaves - // the mock at zero hits and fails the wait. + // Each mock matches the *exact* body it expects, so a hit proves both that + // the site emits and that the event carried the right data. - /// The one variable every template below echoes, plus the event kind, so a - /// site wired to the wrong variant cannot pass. + /// Echoes one variable plus the event kind, so a site wired to the wrong + /// variant cannot pass. fn echo_template(variable: &str) -> String { format!( r#"{{"content":"{{{{{variable}}}}}","type":"{{{{NotificationType}}}}"}}"# @@ -1148,7 +1101,6 @@ mod tests { format!(r#"{{"content":"{content}","type":"{notification_type}"}}"#) } - /// Report `item_id` as started playing, exactly as a client would. async fn report_playback_start( server: &TestServer, h: &HeaderName, @@ -1171,7 +1123,6 @@ mod tests { .assert_status(StatusCode::NO_CONTENT); } - /// The id of the authenticated user, as the API itself reports it. async fn my_user_id(server: &TestServer, h: &HeaderName, v: &HeaderValue) -> Uuid { let me: serde_json::Value = server .get("/users/me") @@ -1186,8 +1137,6 @@ mod tests { .expect("the reported id must be a uuid") } - /// `POST /sessions/playing` reaches a hook subscribed to `PlaybackStart`, - /// carrying the item that is being played. #[tokio::test] async fn a_playback_start_reaches_a_configured_webhook() { let (server, guard, token) = authenticated_server().await; @@ -1224,13 +1173,10 @@ mod tests { .await; } - /// A stop report that records nothing must report nothing. - /// - /// The endpoint answers 204 to any authenticated client for any item id, - /// with or without a session behind it. Deriving the event from the request - /// rather than from what was written would let that client forge playback - /// against the operator's endpoint — and make the `UserDataSaved` that - /// rides along assert a save that provably did not happen. + /// A stop report that records nothing must report nothing: the endpoint + /// answers 204 to any authenticated client for any item id, so an event + /// derived from the request rather than from what was written would let + /// that client forge playback against the operator's endpoint. #[tokio::test] async fn a_stop_for_an_unknown_item_emits_nothing_and_still_answers_204() { let (server, guard, token) = authenticated_server().await; @@ -1301,11 +1247,9 @@ mod tests { ); } - /// `reload` is what narrows the probe, and every other test here runs with - /// a freshly widened mask (`create` invalidates, and nothing forces a - /// reload before the request under test). So without this, a `reload` that - /// computed an empty mask — or dropped a bit — would pass the whole suite - /// while permanently suppressing every guarded event on a real server. + /// Every other test here runs with a freshly widened mask, so a `reload` + /// that computed an empty mask would pass the whole suite while permanently + /// suppressing every guarded event on a real server. #[tokio::test] async fn a_reload_narrows_the_probe_to_the_subscribed_types() { let (server, guard, token) = authenticated_server().await; @@ -1323,7 +1267,6 @@ mod tests { ) .await; - // Drive one event through so the dispatcher performs a real reload. guard .0 .webhooks @@ -1348,10 +1291,8 @@ mod tests { ); } - /// `DELETE /items/{id}` reaches a hook subscribed to `ItemDeleted` with the - /// deleted item's own data — which only works because the row is captured - /// before the DELETE. The row is gone by the time the payload is built, so - /// anything that re-read it would render an empty name. + /// The row is gone by the time the payload is built, so this only works + /// because it is captured before the DELETE. #[tokio::test] async fn an_item_deletion_carries_the_deleted_items_data() { let (server, guard, token) = authenticated_server().await; @@ -1405,9 +1346,8 @@ mod tests { .await; } - /// A failed login emits `AuthenticationFailure` — and answers with exactly - /// the same 401 it answered before any webhook existed. A successful login - /// must not emit it. + /// Emitting must not change the 401 a client sees, and a successful login + /// must not emit at all. #[tokio::test] async fn an_authentication_failure_emits_without_changing_the_401() { let (server, _guard, token) = authenticated_server().await; @@ -1425,7 +1365,6 @@ mod tests { .await }; - // Baseline: the refusal as it is with nothing listening. let before = bad_login().await; before.assert_status(StatusCode::UNAUTHORIZED); let before_body = before.text(); @@ -1464,8 +1403,7 @@ mod tests { }) .await; - // The credential check is the trigger, not the endpoint: a login that - // succeeds must not report a failure. + // The credential check is the trigger, not the endpoint. server .post("/users/authenticatebyname") .add_header( @@ -1489,21 +1427,12 @@ mod tests { /// /// `WebhookService::matches` is unit-tested against hand-built events, which /// cannot see what the *emission site* puts in one. The subscribed hook here - /// filters on the id `/users/me` reports, so a `PlaybackStart` emitted with - /// the device id, the session id, or any other uuid in `user.id` — all of - /// which pass every unit test — leaves it at zero hits and fails. - /// - /// Its body echoes `{{NotificationUsername}}` rather than `{{Name}}`: on a - /// playback event that variable comes from `put_user`, fed by the - /// `&db::User → UserEventData` conversion, and no other test pins it end to - /// end (`AuthenticationFailure` takes an inline branch that never touches - /// that conversion). A username half that shipped the device name, the - /// client name or an empty string would otherwise pass the whole suite. + /// filters on the id `/users/me` reports, and echoes + /// `{{NotificationUsername}}` — which comes from the `&db::User → + /// UserEventData` conversion that no other test pins end to end. /// /// That same hook is the canary for the two zero assertions: the dispatcher - /// picks all three targets in a single pass over the cached hook set, so its - /// delivery proves the event was processed and filtered rather than merely - /// still in flight. + /// picks all three targets in a single pass over the cached hook set. #[tokio::test] async fn a_playback_start_reaches_only_the_hooks_whose_filters_accept_it() { let (server, guard, token) = authenticated_server().await; @@ -1587,13 +1516,8 @@ mod tests { // --- the enabled switch, end to end ------------------------------------- - /// Disabling a hook over HTTP must stop the *running* dispatcher from - /// delivering to it. - /// - /// Neither half of that is proven by the parts: the repository test shows - /// `get_enabled` filters the query, and the invalidation test shows an - /// update reaches the dispatcher, but nothing pins the two together. A - /// `reload` that read `get_all` instead would keep every existing test green + /// Neither half of this is proven by the parts: a `reload` that read + /// `get_all` instead of `get_enabled` would keep every other test green /// while making the operator's kill switch do nothing until a restart. /// /// The canary is created once and never touched again; its second hit is @@ -1667,23 +1591,19 @@ mod tests { // --- the Discord destination, end to end -------------------------------- - /// A Discord hook, created over HTTP and driven by a real event, must reach - /// the endpoint as the JSON envelope its template describes. - /// - /// The destination's settings are template *variables*, so they only work if - /// the whole chain holds: the `Discord` variant survives the DB's JSON - /// column, the dispatcher's reload hands it to `with_hook_fields`, the - /// overlay lands under the plugin's key spellings, and the sender posts the - /// result as JSON. Every link is unit-tested; nothing composed them. + /// The destination's settings are template *variables*, so this only works + /// if the whole chain holds: the `Discord` variant survives the DB's JSON + /// column, the reload hands it to `with_hook_fields`, the overlay lands + /// under the plugin's key spellings, and the sender posts it as JSON. Every + /// link is unit-tested; nothing composed them. #[tokio::test] async fn a_discord_hook_posts_the_rendered_discord_envelope() { let (server, guard, token) = authenticated_server().await; let (h, v) = auth(&token); let media = crate::integration_test::insert_test_source(&guard.0).await; - // `#AA5CC3` as the integer Discord wants. Spelled out rather than - // computed so the plugin's off-by-one hex truncation cannot creep back - // in unnoticed. + // `#AA5CC3` as the integer Discord wants, spelled out rather than + // computed so the plugin's off-by-one hex truncation cannot creep back. let expected = format!( r#"{{"username":"remux","avatar_url":"https://example.test/a.png","content":"@everyone","embeds":[{{"color":11164867,"description":"{}"}}]}}"#, media.title diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index d7fbc0a88..7fe8890c9 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -454,12 +454,10 @@ pub struct Config { /// guessed. /// /// The config layer uses no env prefix, so this field's environment - /// variable is the bare `PUBLIC_URL` — a name Create-React-App builds - /// export and some PaaS runtimes already set, often to `/`. A value that is - /// not an absolute URL produces relative links that the consumer rejects - /// (Discord refuses a non-absolute embed URL), so an unexpectedly populated - /// `PUBLIC_URL` in the environment is worth ruling out first when those - /// links misbehave. + /// variable is the bare `PUBLIC_URL` — a name Create-React-App builds and + /// some PaaS runtimes already set, often to `/`. A value that is not an + /// absolute URL produces links the consumer rejects (Discord refuses a + /// non-absolute embed URL). #[serde(default)] pub public_url: Option, } diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index a8b029246..610207196 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -2,9 +2,8 @@ //! that turns events into HTTP deliveries. //! //! Emission is fire-and-forget (`WebhookService::emit`) so no request handler -//! ever waits on a webhook. A single dispatcher task owns the receiver, keeps a -//! cached snapshot of the enabled webhooks, and fans each event out to the -//! hooks that match it. +//! ever waits on a webhook. A single dispatcher task owns the receiver and +//! keeps a cached snapshot of the enabled webhooks. pub mod events; mod payload; @@ -38,19 +37,16 @@ const EVENT_CHANNEL_CAPACITY: usize = 4096; /// `Name` seen by the template of the synthetic event [`deliver_test`] sends. pub const TEST_EVENT_TITLE: &str = "Test notification"; -/// How often a hook may repeat its "template render failed" line. -/// -/// The failure is per *event*, so a hook subscribed to `PlaybackProgress` with -/// a template that does not render logs once per progress tick, forever. The -/// first line is what an operator needs; the rest is the same line again. +/// How often a hook may repeat its "template render failed" line. The failure +/// is per *event*, so an unthrottled line repeats for every progress tick. const RENDER_FAILURE_WARN_WINDOW: std::time::Duration = std::time::Duration::from_secs(60); static RENDER_FAILURE_WARNINGS: std::sync::LazyLock = std::sync::LazyLock::new(|| throttle::LogThrottle::new(RENDER_FAILURE_WARN_WINDOW)); -/// The enabled webhooks as last read from the database, plus everything derived -/// from them that would otherwise be recomputed per event. +/// The enabled webhooks as last read from the database, plus everything that +/// would otherwise be recomputed per event. pub(crate) struct LoadedWebhooks { pub hooks: Vec, /// Every hook's template, pre-compiled under its id, plus the custom helpers. @@ -85,18 +81,15 @@ struct Inner { wanted_mask: AtomicU32, } -/// One bit per [`NotificationType`], indexed by its discriminant (the enum is -/// fieldless). `None` for a type that would not fit in the mask — see -/// [`WebhookService::wants`], which answers those optimistically, so an enum -/// too wide for the mask costs wasted work but never a lost event. +/// One bit per [`NotificationType`], indexed by its discriminant. `None` for a +/// type too wide for the mask, which [`WebhookService::wants`] then answers +/// optimistically — wasted work, never a lost event. fn wanted_bit(notification_type: NotificationType) -> Option { 1u32.checked_shl(notification_type as u32) } -/// The degradation above is correct but *silent*: a 33rd variant would quietly -/// turn the probe into "always true" for everything past the 32nd, and no test -/// would notice. Make outgrowing the mask a build error instead, so the choice -/// (widen the mask, or accept the loss) is made deliberately. +/// Outgrowing the mask degrades silently — the probe would answer "always true" +/// past the 32nd variant — so make it a build error instead. const _: () = assert!( ::COUNT <= u32::BITS as usize, "NotificationType has outgrown the u32 `wants` mask — widen it to u64" @@ -118,17 +111,15 @@ impl WebhookService { // stale until the CRUD endpoints say so. dirty: AtomicBool::new(false), cache: RwLock::new(LoadedWebhooks::default()), - // Everything is "wanted" until the first reload has run: the - // dispatcher buffers the events emitted during startup and - // filters them properly once its snapshot is loaded, so the - // probe must not tell callers to skip building them. + // Everything is "wanted" until the first reload has run: + // skipping is only correct against a loaded snapshot. wanted_mask: AtomicU32::new(u32::MAX), }), } } /// Publish an event. Never blocks and never fails the caller: with no - /// dispatcher running (or a lagging one) the event is simply dropped. + /// dispatcher running, or a lagging one, the event is dropped. pub fn emit(&self, event: WebhookEvent) { let _ = self .tx @@ -137,27 +128,19 @@ impl WebhookService { /// Whether any enabled webhook subscribes to `notification_type`. /// - /// `emit` is cheap, but the *caller* is not: building an event means - /// cloning usernames and device names, and for `ItemDeleted` re-reading and - /// boxing a whole [`db::Media`]. On a `PlaybackProgress` stream with no - /// webhooks configured that cost is paid per progress tick for nothing. - /// Guard those sites with this. + /// `emit` is cheap, but building an event is not — cloning usernames and + /// device names, and for `ItemDeleted` re-reading a whole [`db::Media`], per + /// progress tick. Guard those sites with this. /// /// Lock-free (two atomic loads) and deliberately conservative: a pending /// reload, or a subscription set too wide for the mask, answers `true`. It /// is an optimisation, never the authority — the dispatcher re-checks every /// event against the real snapshot. /// - /// The `dirty` half is not redundant with the widening in [`Self::invalidate`], - /// and leaving it out is a *sticky* bug rather than a transient one. The - /// mask is narrowed by `reload` from a snapshot it read some time earlier, - /// so an `invalidate` that lands mid-reload has its widen clobbered by a - /// mask that predates it. Were `wants` to answer from the mask alone, it - /// would then suppress exactly the guarded events that would otherwise have - /// woken the dispatcher and made it consume the still-set `dirty` flag — so - /// nothing would heal it until some *unguarded* event happened to fire, - /// which on a quiet server can be hours. Consulting `dirty` keeps the - /// staleness self-healing, which is what it was before this probe existed. + /// `dirty` must be consulted alongside the mask: a mask narrowed from a + /// snapshot that predates an `invalidate` would otherwise suppress exactly + /// the guarded events that wake the dispatcher into consuming the flag, + /// leaving the staleness with nothing to heal it. pub fn wants(&self, notification_type: NotificationType) -> bool { let Some(bit) = wanted_bit(notification_type) else { return true; @@ -178,9 +161,7 @@ impl WebhookService { pub fn invalidate(&self) { // Widened before the flag is raised: a hook that just gained a // subscription must not have its events skipped in the window before - // the dispatcher reloads. `reload` declines to narrow again while the - // flag is still up, and `wants` consults the flag too, so this is the - // fast path rather than the correctness argument. + // the dispatcher reloads. self.inner .wanted_mask .store(u32::MAX, Ordering::Relaxed); @@ -191,21 +172,12 @@ impl WebhookService { /// Replace the cached snapshot from the database. On error the previous /// hook set is kept and the cache is marked stale again — a transient DB - /// failure must not silently disable every webhook. - /// - /// Re-raising `dirty` is what makes that promise true for the *first* - /// reload, and only the first reload can break it: [`Self::spawn_dispatcher`] - /// calls this when the previous set is [`LoadedWebhooks::default`], i.e. - /// empty, so "keep the previous set" keeps nothing. A `SQLITE_BUSY` at boot - /// would otherwise leave the cache empty with the flag down — nothing to - /// retry the load, and `wanted_mask` still `u32::MAX` from [`Self::new`], so - /// every guarded call site keeps paying full price to build events the - /// dispatcher then discards. Recovery would need an admin to touch a - /// webhook, or a restart. + /// failure must not silently disable every webhook. Nothing else re-raises + /// the flag, so a failure that left it down would never be retried. /// /// The server identity is reloaded here too, which is why settings writers - /// call [`Self::invalidate`]: it is built once and then read by every - /// payload, so a rename would otherwise ship the old name until restart. + /// call [`Self::invalidate`]: a rename would otherwise ship the old name in + /// every payload until restart. /// /// Invariant: this is the only writer of `cache`, and it is only ever /// called from the dispatcher task itself, at a point where that task @@ -226,9 +198,8 @@ impl WebhookService { .write() .await .server = server; - // Ask for another attempt. Without this the failure is - // permanent: the flag was consumed before the call, so nothing - // else will ever set it. + // Ask for another attempt: the flag was consumed before the + // call, so nothing else will set it. self.inner .dirty .store(true, Ordering::Release); @@ -258,13 +229,8 @@ impl WebhookService { wanted, server, }; - // Published after the snapshot, and only when the flag is down. On the - // dispatcher's steady-state path the flag was consumed just before this - // call, so finding it set again means `hooks` predates an `invalidate` - // whose widening this store would otherwise silently clobber — leaving - // the mask narrow, and stale, for as long as the flag stays unconsumed. - // The startup reload has no preceding swap, so there the check simply - // holds the mask open until a snapshot nobody has invalidated lands. + // Only narrow when the flag is down: finding it set again means `hooks` + // predates an `invalidate` whose widening this store would clobber. if !self .inner .dirty @@ -276,15 +242,14 @@ impl WebhookService { } } - /// Whether `hook` wants `event`. Pure: `item_kind` is the kind of the item - /// the event is about, or `None` when the event carries no item. + /// Whether `hook` wants `event`. `item_kind` is `None` when the event + /// carries no item. pub(crate) fn matches( hook: &db::Webhook, event: &WebhookEvent, item_kind: Option<&db::MediaKind>, ) -> bool { - // 1. Subscription. An empty list matches nothing — this mirrors the - // Jellyfin webhook plugin and is not an oversight. + // 1. Subscription. An empty list matches nothing, mirroring the plugin. if !hook .notification_types .contains(&event.notification_type()) @@ -328,9 +293,8 @@ impl WebhookService { /// receiver. /// /// The receiver is created here rather than inside the task: a broadcast - /// channel drops sends that happen while it has no subscriber, and - /// `init_app` starts emitting (library scan, startup tasks) before the - /// spawned task gets its first poll. + /// channel drops sends made with no subscriber, and `init_app` starts + /// emitting before the spawned task gets its first poll. pub fn spawn_dispatcher(self, ctx: AppContext) -> JoinHandle<()> { let mut rx = self .tx @@ -374,22 +338,17 @@ impl WebhookService { } let item = payload::enrich_item(&ctx, &event).await; - // An item-scoped event whose item could not be resolved has - // nothing left to deliver, and delivering it anyway is worse - // than dropping it twice over: `item_kind` is `None`, so - // `matches` skips the item-type rule entirely and a hook with - // every type unticked fires; and the dictionary has no `Name`, - // `ItemId` or `ItemType`, so the stock template renders - // `"title": " () has been added to remux"`. `ItemDeleted` - // carries its row inline and never lands here. + // An unresolved item must not be delivered: `matches` skips the + // item-type rule without a kind, so a hook with every type + // unticked would fire. `ItemDeleted` carries its row inline and + // never lands here. if event .item_id() .is_some() && item.is_none() { - // `debug`, not `warn`: `enrich_item` already logged the - // real cause at warn, and a scan that deletes rows behind - // an in-flight event makes this expected rather than wrong. + // `enrich_item` already logged the cause at warn, and a + // scan deleting rows behind an in-flight event is expected. debug!( notification_type = %event.notification_type(), "webhook event dropped, its item could not be resolved" @@ -411,22 +370,18 @@ impl WebhookService { continue; } - // Built once per event; `render` applies the per-hook overlay - // (a Generic destination's operator-defined fields). + // Built once per event; `render` applies the per-hook overlay. let data = payload::build_data(&cache.server, &event, item.as_ref()); for hook in targets { match template::render(hook, &cache.registry, &data) { - // Delivery is spawned so one slow endpoint cannot stall - // the dispatcher or the hooks behind it, and bounded so - // a dead one cannot grow tasks without limit. + // Spawned so one slow endpoint cannot stall the + // dispatcher, and bounded so a dead one cannot grow + // tasks without limit. Ok(Some(body)) => { sender::spawn_delivery(hook.clone(), body); } // `skip_empty_message_body` suppressed the delivery. Ok(None) => {} - // Throttled: the failure is a property of the template, - // not of the event, so an unthrottled line repeats for - // every tick of a `PlaybackProgress` subscription. Err(e) => { if let Some(suppressed) = RENDER_FAILURE_WARNINGS.allow(hook.id) @@ -449,13 +404,9 @@ impl WebhookService { // --- the admin "test this webhook" path -------------------------------------- -/// Whether an operator-supplied template parses, for write-time validation. -/// -/// The error is handlebars' own parse error, derived from the operator's text -/// and nothing else — no remote response, no URL — so it is safe to return over -/// the API. Rejecting at write time is the difference between "your template -/// has an unclosed block on line 4" and a hook that saves clean, says -/// "Template not found" when tested, and stays silent in production. +/// Whether an operator-supplied template parses, for write-time validation. The +/// error is handlebars' own, derived from the operator's text and nothing else +/// — no remote response, no URL — so it is safe to return over the API. pub fn validate_template(template: &str) -> Result<(), handlebars::TemplateError> { template::validate(template) } @@ -463,16 +414,12 @@ pub fn validate_template(template: &str) -> Result<(), handlebars::TemplateError /// Render `hook`'s body for the synthetic test event. /// /// The template is compiled here rather than taken from the dispatcher's cached -/// registry: the hook being tested was very likely saved a moment ago, and that -/// cache only reloads when the dispatcher next sees an event. Testing a hook -/// against a stale template would be worse than not testing it. +/// registry, which only reloads when the dispatcher next sees an event — the +/// hook being tested was very likely saved a moment ago. /// -/// Compiled through [`template::single_registry`], not `build_registry`: the -/// latter warns-and-skips an unparseable template, which is right for the -/// dispatcher — one hook's typo must not stop the others — and wrong here, -/// because `render` would then fail with handlebars' "Template not found: -/// " while the operator's actual syntax error went only to the server -/// log. +/// Through [`template::single_registry`], not `build_registry`: the latter +/// warns-and-skips an unparseable template, so `render` would fail with +/// "Template not found: " instead of the operator's own syntax error. fn test_body( server: &payload::ServerInfo, hook: &db::Webhook, @@ -488,19 +435,16 @@ fn test_body( /// Deliver one synthetic `Generic` event to `hook` and report what happened. /// -/// Deliberately not routed through [`WebhookService::emit`]: the broadcast path -/// is fire-and-forget, filtered by the hook's own subscription and retried in -/// the background, and none of that can answer "did *this* webhook work?". -/// A hook that is disabled, or subscribes to nothing, is still testable — that -/// is the point of the button. +/// Deliberately not routed through [`WebhookService::emit`]: a hook that is +/// disabled, or subscribes to nothing, must still be testable, and the +/// fire-and-forget path cannot answer "did *this* webhook work?". /// /// One attempt, no retry, and the answer handed straight back to the caller. pub async fn deliver_test(ctx: &AppContext, hook: &db::Webhook) -> WebhookTestResult { let server = payload::ServerInfo::load(ctx).await; match test_body(&server, hook) { Ok(Some(body)) => sender::send_test(hook, &body).await, - // `skip_empty_message_body` would drop this delivery in production, so - // reporting a success here would be a lie. + // `skip_empty_message_body` would drop this delivery in production. Ok(None) => WebhookTestResult { success: false, status_code: None, @@ -620,8 +564,6 @@ mod tests { } } - /// The dashboard's test button is only useful if the body it sends is the - /// body a real event would send, built from the same dictionary. #[test] fn the_test_event_renders_the_title_and_the_server_variables() { let hook = db::Webhook { @@ -639,13 +581,8 @@ mod tests { ); } - /// The hook's template is compiled for this call, so a hook the dispatcher - /// has never seen is still testable. - /// - /// And what comes back must be the *parse* error. Routed through - /// `build_registry` this failed with "Template not found: ", naming - /// an id the operator never typed while the real error went to the log — - /// so asserting `is_err()` alone was not enough to keep it honest. + /// What comes back must be the *parse* error: `build_registry` would answer + /// "Template not found: ", so `is_err()` alone is not enough here. #[test] fn a_template_that_does_not_compile_reports_its_parse_error() { let hook = db::Webhook { @@ -665,8 +602,6 @@ mod tests { ); } - /// The same error is what the CRUD endpoints refuse a write with, so it has - /// to name something the operator can act on. #[test] fn validate_template_rejects_a_template_that_does_not_parse() { assert!(validate_template(r#"{"content":"{{Name}}"}"#).is_ok()); @@ -680,8 +615,6 @@ mod tests { ); } - /// `skip_empty_message_body` drops the delivery in production; the test - /// endpoint must say so rather than claim a success it never attempted. #[test] fn an_empty_body_is_reported_rather_than_posted() { let hook = db::Webhook { @@ -697,9 +630,8 @@ mod tests { // --- cached snapshot ------------------------------------------------- - /// The registry is built in two places (here and in `reload`). Both must - /// carry the custom helpers, or every template using one breaks until — or - /// from — the first `invalidate()`. + /// The registry is built in two places (here and in `reload`); both must + /// carry the custom helpers. #[test] fn the_default_snapshot_registry_carries_the_custom_helpers() { let snapshot = LoadedWebhooks::default(); @@ -713,12 +645,8 @@ mod tests { assert_eq!(body, "ok"); } - /// The startup reload is the one call with nothing to "keep": the previous - /// set is `LoadedWebhooks::default()`, i.e. empty. A transient - /// `SQLITE_BUSY` there used to be permanent — the flag had already been - /// consumed, so nothing would ever ask for another attempt, and every - /// webhook stayed disabled until an admin touched one or the process - /// restarted. + /// The startup reload has nothing to "keep" and the flag has already been + /// consumed, so a failure there is permanent unless it asks for a retry. #[tokio::test] async fn a_failed_reload_asks_for_another_attempt() { let (_server, guard) = crate::integration_test::new_test_server() @@ -755,8 +683,7 @@ mod tests { ); } - /// The mirror image: a load that worked must not ask to be redone, or the - /// dispatcher reloads on every single event. + /// Or the dispatcher would reload on every single event. #[tokio::test] async fn a_successful_reload_leaves_the_cache_clean() { let (_server, guard) = crate::integration_test::new_test_server() @@ -783,9 +710,8 @@ mod tests { // --- the `wants` probe ------------------------------------------------ - /// Every notification type must own a bit. Two types sharing one would make - /// `wants` answer for the wrong subscription — and the bit index is the - /// enum's discriminant, which nothing else in the code pins down. + /// The bit index is the enum's discriminant, which nothing else pins down: + /// two types sharing a bit would make `wants` answer for the wrong one. #[test] fn every_notification_type_has_its_own_bit() { let types = [ @@ -818,10 +744,8 @@ mod tests { ); } - /// Before the dispatcher's first load — and for the whole window a pending - /// reload is open — the probe must not tell callers to skip building - /// events. Skipping is only ever correct against a snapshot that is known - /// to be current. + /// Skipping is only ever correct against a snapshot known to be current, so + /// the probe stays open before the first load and while a reload is pending. #[tokio::test] async fn the_probe_is_open_until_a_snapshot_says_otherwise() { let service = WebhookService::new(); @@ -844,16 +768,9 @@ mod tests { ); } - /// The narrowing store at the end of `reload` publishes a mask derived from - /// rows read some time earlier. An `invalidate` that lands in between must - /// not have its widening clobbered by it. - /// - /// This is the interleaving, in order: the dispatcher consumes the flag and - /// starts reloading, the operator saves a hook mid-reload, the reload - /// finishes from its now-outdated snapshot. Left unhandled the result is - /// *sticky*, not transient — the closed probe suppresses exactly the - /// guarded events that would have woken the dispatcher into consuming the - /// flag, so nothing reopens it until some unguarded event happens to fire. + /// `reload` publishes a mask derived from rows read some time earlier, and + /// an `invalidate` landing in between must not have its widening clobbered: + /// a closed probe suppresses the very events that would reopen it. #[tokio::test] async fn a_reload_that_races_an_invalidate_leaves_the_probe_open() { let (_server, guard) = crate::integration_test::new_test_server() @@ -861,9 +778,8 @@ mod tests { .expect("test server"); let service = WebhookService::new(); - // The dispatcher takes the flag and begins reading the database, which - // at this point holds no webhooks at all — so this reload can only - // compute an empty mask. + // The database holds no webhooks, so this reload computes an empty + // mask. service.invalidate(); service .inner @@ -887,9 +803,8 @@ mod tests { // --- rule 1: notification types ------------------------------------- - /// Deliberate parity with the Jellyfin webhook plugin: a webhook that - /// subscribes to nothing receives nothing, even with every other filter - /// wide open. + /// Deliberate parity with the Jellyfin webhook plugin, even with every + /// other filter wide open. #[test] fn empty_notification_types_match_nothing() { let hook = hook(vec![], vec![], ALL_ENABLED); @@ -1046,9 +961,8 @@ mod tests { } } - /// Each kind is gated by exactly one flag: enabling only that flag matches, - /// and disabling only that flag (every other flag on) does not. Together - /// these pin the mapping — a kind wired to the wrong flag fails both halves. + /// Both halves are needed to pin the mapping: enabling only that flag must + /// match, and disabling only that flag must not. #[test] fn each_media_kind_is_gated_by_its_own_flag() { for (kind, only_this) in item_type_cases() { diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index 745459dc9..b1af90df6 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -74,9 +74,8 @@ pub(crate) struct ItemContext { /// The identity of this server, resolved whenever the dispatcher's snapshot is /// (re)loaded — a rename must not keep shipping the old name until restart. /// -/// `Default` is the pre-first-load placeholder only. The dispatcher loads the -/// snapshot before it reads its first event, so no delivery is ever built from -/// it. +/// `Default` is the pre-first-load placeholder only: the dispatcher loads the +/// snapshot before it reads its first event. #[derive(Debug, Clone, Default)] pub(crate) struct ServerInfo { pub id: String, @@ -194,7 +193,7 @@ pub(crate) fn build_data( /// - `Generic` contributes the operator-defined `fields` under their own keys /// (`GenericClient.SendAsync`). /// - `Discord` contributes `MentionType`, `EmbedColor`, `AvatarUrl`, `Username` -/// and `BotUsername` (`DiscordClient.SendAsync`) — which is what lets a +/// and `BotUsername` (`DiscordClient.SendAsync`), which is what lets a /// Discord template copied from the plugin render the whole payload itself. /// `EmbedColor` is the one intended deviation: always present, see below. /// @@ -247,12 +246,9 @@ pub(crate) fn with_hook_fields<'a>( ); // Intended deviation: the plugin omits `EmbedColor` when the hook // names no colour, which makes its own stock `Discord.handlebars` - // render `"color": ""` and Discord reject the payload with a 400. - // The key is therefore always present, defaulted. This costs no - // template-behaviour parity: across all five stock Discord - // templates `{{EmbedColor}}` appears exactly once, as a bare - // interpolation, never guarded by `if_exist` — the four per-event - // templates hardcode a literal colour and ignore the variable. + // render `"color": ""` and Discord reject the payload. The key is + // therefore always present, defaulted — no stock template guards + // `{{EmbedColor}}` with `if_exist`, so nothing changes behaviour. merged.insert( "EmbedColor".into(), Value::Number( @@ -289,11 +285,11 @@ fn mention_type_variable(mention_type: DiscordMentionType) -> &'static str { /// `#RRGGBB` (or bare `RRGGBB`) as the integer Discord wants, mirroring the /// plugin's `FormatColorCode` — except that the plugin slices `hexCode[1..6]` -/// and silently drops the last hex digit, turning `#AA5CC3` into 697 804. That -/// bug is deliberately **not** reproduced: an admin gets the colour they pick. +/// and drops the last hex digit, turning `#AA5CC3` into 697 804. That bug is +/// deliberately **not** reproduced. /// /// Anything unparseable falls back to [`DEFAULT_EMBED_COLOR`] rather than -/// throwing as the plugin does — this is operator input and must never fail a +/// throwing as the plugin does: this is operator input and must never fail a /// delivery. pub(crate) fn parse_embed_color(hex: &str) -> u32 { let hex = hex @@ -954,7 +950,6 @@ mod tests { assert_eq!(str_at(&data, "PremiereDate"), "2021-03-04"); } - /// Jellyfin ids carry no dashes. #[test] fn item_id_is_the_dashless_uuid() { let item = episode(); @@ -1010,8 +1005,6 @@ mod tests { assert_eq!(str_at(&data, "RunTime"), "01:30:45"); } - /// Imported templates print the runtime unconditionally, so the keys are - /// always present — zeroed rather than missing when it is unknown. #[test] fn runtime_variables_fall_back_to_zero() { let item = ItemContext { @@ -1059,8 +1052,6 @@ mod tests { ); } - /// The plugin reads `Year` off the *series* for an episode, not off the - /// episode's own air date. #[test] fn episode_year_comes_from_the_series() { let base = episode(); @@ -1089,8 +1080,6 @@ mod tests { assert_eq!(str_at(&data, "PremiereDate"), "2021-03-04"); } - /// The plugin's stock template has a dedicated Season branch that prints - /// the series name and the season number. #[test] fn season_gets_the_series_keys_and_its_own_number() { let item = ItemContext { @@ -1201,8 +1190,7 @@ mod tests { assert_eq!(data["Audio_0_Channels"], Value::from(6)); assert_eq!(data["Audio_0_Bitrate"], Value::from(640_000)); - // The index counts per type, not the raw media stream index: the second - // audio track is Audio_1 even though its stream index is 2. + // The second audio track is Audio_1 even though its stream index is 2. assert_eq!(str_at(&data, "Audio_1_Codec"), "ac3"); assert_eq!(str_at(&data, "Audio_1_Language"), "fra"); assert!( @@ -1258,7 +1246,7 @@ mod tests { assert_eq!(str_at(&data, "NotificationUsername"), "alice"); } - /// 90 % of the runtime is the threshold, inclusive. + /// The threshold is inclusive. #[test] fn played_to_completion_flips_at_ninety_percent() { let item = episode(); @@ -1396,9 +1384,8 @@ mod tests { let merged = with_hook_fields(&base, &hook); assert_eq!(str_at(&merged, "channel"), "#general"); assert_eq!(str_at(&merged, "kind"), "alert"); - // The common dictionary survives the overlay. assert_eq!(str_at(&merged, "Name"), "The One With The Test"); - // …and the overlay does not mutate it. + // The overlay does not mutate the shared dictionary. assert!(!base.contains_key("channel")); } @@ -1470,7 +1457,6 @@ mod tests { } /// `DiscordClient.SendAsync` always sets `MentionType`, empty for `None`. - /// This is what `{{MentionType}}` in a plugin template resolves against. #[test] fn discord_always_exposes_the_mention_type() { for (mention_type, expected) in [ @@ -1503,8 +1489,8 @@ mod tests { } /// Presence parity: the plugin only inserts these keys when configured, so - /// an unset one must be *missing*, not present-and-empty — that is what - /// makes `{{#if_exist AvatarUrl}}` behave as it does in the plugin. + /// an unset one must be *missing*, not present-and-empty, or + /// `{{#if_exist AvatarUrl}}` flips. #[test] fn discord_omits_the_unset_identity_options() { for hook in [ @@ -1524,7 +1510,7 @@ mod tests { } /// The plugin formats the colour into an integer before it reaches the - /// template (`FormatColorCode`), so `{{EmbedColor}}` is a number. + /// template, so `{{EmbedColor}}` is a number. #[test] fn discord_exposes_the_embed_color_as_an_integer() { let data = discord_vars(&discord_with( @@ -1538,8 +1524,7 @@ mod tests { /// Intended deviation from the plugin, which omits the key: the stock /// `Discord.handlebars` interpolates `{{EmbedColor}}` bare, so an absent - /// key renders `"color": ""` and Discord rejects the payload. The - /// invariant belongs here, not in a dashboard form three crates away. + /// key renders `"color": ""` and Discord rejects the payload. #[test] fn discord_always_exposes_an_embed_color() { for embed_color in [None, Some(""), Some("nonsense")] { @@ -1557,7 +1542,6 @@ mod tests { } } - /// A `Generic` hook must not gain Discord keys, and vice versa. #[test] fn discord_variables_are_not_exposed_to_generic_hooks() { let data = with_hook_fields( @@ -1588,7 +1572,7 @@ mod tests { } /// The plugin's `FormatColorCode` slices `hexCode[1..6]` and drops the last - /// digit, so `#AA5CC3` reaches Discord as 697 804. That bug is not ours. + /// digit, so `#AA5CC3` reaches Discord as 697 804. #[test] fn parse_embed_color_does_not_reproduce_the_plugin_truncation() { assert_ne!(parse_embed_color("#AA5CC3"), 697_804); @@ -1690,9 +1674,8 @@ mod tests { (series, season, episode) } - /// Pins the parent/grandparent assignment, which nothing else covers: with - /// the two swapped, `SeriesName` would render the season title on every - /// episode webhook and every hand-built `ItemContext` test would stay green. + /// Pins the parent/grandparent assignment, which nothing else covers: every + /// hand-built `ItemContext` test would stay green with the two swapped. #[tokio::test] async fn enrich_item_resolves_the_season_as_parent_and_the_series_as_grandparent() { let (_server, guard) = crate::integration_test::new_test_server() @@ -1732,7 +1715,6 @@ mod tests { assert_eq!(grandparent.id, series.id, "grandparent must be the series"); assert_eq!(grandparent.kind, db::MediaKind::Series); - // The consequence a swap would produce, asserted end to end. let data = build_data( &server(), &WebhookEvent::ItemAdded { @@ -1783,9 +1765,8 @@ mod tests { ); } - /// `ItemDeleted` must read the row off the event — the DB row is already - /// gone by the time the dispatcher sees it — while still resolving the - /// parents, which are not deleted. + /// `ItemDeleted` reads the row off the event — the DB row is already gone + /// by the time the dispatcher sees it — while still resolving the parents. #[tokio::test] async fn enrich_item_uses_the_row_embedded_in_item_deleted() { let (_server, guard) = crate::integration_test::new_test_server() @@ -1836,7 +1817,6 @@ mod tests { ); } - /// `ServerUrl` comes from `Config::public_url`. #[tokio::test] async fn server_info_reads_the_public_url_from_config() { let (_server, guard) = crate::integration_test::new_test_server() diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index ef76f7362..da3e38e1b 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -1,25 +1,19 @@ //! HTTP delivery of a rendered webhook body. //! -//! Everything that decides *what* goes on the wire lives in pure functions -//! ([`shape_request`], [`detect_content_type`], [`classify_status`], -//! [`parse_retry_after`]); [`attempt_once`] only performs the POST. A new -//! destination is a new `WebhookDestination` variant plus an arm in +//! A new destination is a new `WebhookDestination` variant plus an arm in //! [`shape_request`]. //! //! The rendered body is never rewrapped here. Destination-specific *content* — //! the Discord payload, a Generic hook's extra fields — is produced by the //! template, from the variables [`super::payload::with_hook_fields`] puts in -//! scope; that is how the Jellyfin webhook plugin works, and it is what lets a -//! template written for the plugin render verbatim. +//! scope, as in the Jellyfin webhook plugin. //! //! Delivery is fire-and-forget: [`spawn_delivery`] never blocks its caller and -//! every error is logged and swallowed, so a broken endpoint can neither stall -//! the dispatcher nor surface anywhere in the server. +//! every error is logged and swallowed. //! //! **A webhook URL is a credential.** Discord's is -//! `https://discord.com/api/webhooks/{id}/{token}` and that token is the entire -//! authentication — anyone holding it can post as the webhook indefinitely. No -//! log line in this module may contain a URL path or query; see [`redact_url`]. +//! `https://discord.com/api/webhooks/{id}/{token}`, so no log line in this +//! module may contain a URL path or query; see [`redact_url`]. use super::throttle::LogThrottle; use crate::db; @@ -43,16 +37,14 @@ use uuid::Uuid; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Timeout for the admin "test this webhook" request, applied per request so -/// [`REQUEST_TIMEOUT`] keeps governing background delivery. Shorter because the -/// caller is an operator watching a button, not a retrying background task. +/// [`REQUEST_TIMEOUT`] keeps governing background delivery. const TEST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); /// Ceiling on deliveries in flight **per hook**. const MAX_CONCURRENT_DELIVERIES_PER_HOOK: usize = 4; -/// Upper bound on a `Retry-After` we will obey. The value is remote input and -/// the waiter holds a delivery slot while it sleeps, so an endpoint must not be -/// able to pin one indefinitely. +/// Upper bound on a `Retry-After` we will obey: the value is remote input and +/// the waiter holds a delivery slot while it sleeps. const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); /// `Encoding.UTF8` on the plugin's `StringContent` puts the charset on the @@ -63,15 +55,11 @@ const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8"; /// At most this many bytes of a failed response body make it into the log line. const MAX_LOGGED_RESPONSE: usize = 512; -/// One client for the whole process: a client per delivery would rebuild the -/// TLS config and throw away the connection pool on every event. +/// One client for the whole process, so the connection pool survives. /// -/// Redirects are **not** followed. `reqwest`'s default is up to ten hops, which -/// would let a webhook URL the operator vetted hand the request to a host they -/// never saw — the redirect target is chosen by the remote server, at request -/// time, and would be reached from inside the network the server runs in. No -/// real webhook receiver (Discord, Slack, Gotify, Teams) redirects, so there is -/// nothing to trade away: a 3xx is simply reported as the non-2xx it is. +/// Redirects are **not** followed: the target is chosen by the remote server at +/// request time and would be reached from inside the network this server runs +/// in. A 3xx is reported as the non-2xx it is. static WEBHOOK_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .user_agent("remux-server/1.0") @@ -86,14 +74,9 @@ static DELIVERY_SLOTS: LazyLock = /// How often a hook may repeat its "dropping event" line. /// -/// Without a ceiling this is an amplification primitive rather than a log line. -/// The `AuthenticationFailure` emission in `api::users` is the only one -/// reachable **without credentials**, and `User::authenticate` short-circuits -/// an unknown username in one indexed SELECT — so a credential-stuffing run is -/// cheap for the attacker and every delivery past the per-hook ceiling of -/// [`MAX_CONCURRENT_DELIVERIES_PER_HOOK`] used to write one `warn!`. At 1000 -/// requests a second that is 1000 lines a second onto the operator's disk, -/// paced entirely by the attacker. +/// Unthrottled this is an amplification primitive: the drop branch is reachable +/// from an unauthenticated caller (`AuthenticationFailure` in `api::users`), so +/// the line rate would be the attacker's request rate. const SATURATION_WARN_WINDOW: Duration = Duration::from_secs(60); static SATURATION_WARNINGS: LazyLock = @@ -103,18 +86,12 @@ static SATURATION_WARNINGS: LazyLock = /// Delivery slots, counted **per hook**. /// -/// A single process-wide pool would let one blackholing endpoint hold every -/// slot for its full retry window — three attempts of up to 30 s each, plus -/// backoff — after which deliveries to every *healthy* hook are dropped too. -/// Keying by hook id keeps a broken Discord URL from disabling an operator's -/// working Slack and Gotify hooks; the total is still bounded, at -/// `enabled hooks × limit`. +/// Not a single process-wide pool: one blackholing endpoint would hold every +/// slot for its full retry window and drop deliveries to healthy hooks too. The +/// total stays bounded at `enabled hooks × limit`. /// -/// TODO: entries are never removed, so a delete-and-recreate cycle leaves the -/// old hook's semaphore behind forever. It is tens of bytes per entry and only -/// an operator can create one, so it is not worth a mechanism today; when it -/// is, `WebhookService::reload` in `mod.rs` already knows the live hook set and -/// is the natural place to prune from. +/// TODO: entries are never removed. `WebhookService::reload` in `mod.rs` knows +/// the live hook set and is the natural place to prune from. pub(crate) struct DeliverySlots { limit: usize, per_hook: Mutex>>, @@ -132,9 +109,8 @@ impl DeliverySlots { /// deliveries in flight. Never blocks and never waits. pub(crate) fn try_acquire(&self, hook_id: Uuid) -> Option { let semaphore = { - // Short, await-free critical section. A poisoned lock is recovered - // rather than propagated: a panic elsewhere must not disable - // webhooks for the rest of the process. + // Short, await-free critical section. A poisoned lock is recovered: + // a panic elsewhere must not disable webhooks for the process. let mut per_hook = self .per_hook .lock() @@ -150,22 +126,17 @@ impl DeliverySlots { } } -/// Hand a rendered body to the delivery pool. -/// -/// Returns immediately. When the hook already has its share of deliveries in -/// flight the event is dropped rather than queued: an unbounded backlog behind -/// a dead endpoint is worse than a missed notification, and the dispatcher must -/// never wait here. +/// Hand a rendered body to the delivery pool. A hook with its share already in +/// flight has the event dropped rather than queued: the dispatcher never waits. pub(crate) fn spawn_delivery(hook: db::Webhook, body: String) { spawn_delivery_with(&DELIVERY_SLOTS, hook, body, DeliveryPolicy::default()); } -/// [`spawn_delivery`] with its collaborators injected, and the accept/drop -/// decision returned so both branches are observable. +/// [`spawn_delivery`] with its collaborators injected. /// /// The permit is taken **before** the spawn, on purpose: acquiring it inside /// the task would bound concurrent sockets but let tasks pile up parked on the -/// semaphore — the same unbounded growth in a different allocation. +/// semaphore. pub(crate) fn spawn_delivery_with( slots: &DeliverySlots, hook: db::Webhook, @@ -173,9 +144,8 @@ pub(crate) fn spawn_delivery_with( policy: DeliveryPolicy, ) -> bool { let Some(permit) = slots.try_acquire(hook.id) else { - // Throttled — see [`SATURATION_WARN_WINDOW`]. The drop itself is not - // rate-limited, only the line about it, and the line carries how many - // it now stands for. + // Only the line is rate-limited, never the drop — see + // [`SATURATION_WARN_WINDOW`]. if let Some(dropped_since) = SATURATION_WARNINGS.allow(hook.id) { warn!( webhook = %hook.name, @@ -234,14 +204,11 @@ async fn deliver_logged(hook: db::Webhook, body: String, policy: DeliveryPolicy) } } -/// The retried delivery, with its outcome still visible. [`deliver`] is this -/// plus the logging. +/// The retried delivery, with its outcome still visible. /// /// Only *transient* failures are retried. Hand-rolled rather than built on -/// `remux_utils::retry!` because that macro retries every error -/// unconditionally, which would spend three attempts on a 401 and — worse for -/// Discord — hammer a 429 on a fixed backoff while ignoring the `Retry-After` -/// the endpoint just sent, escalating the very rate limit it is reacting to. +/// `remux_utils::retry!`, which retries unconditionally and on a fixed backoff: +/// that would spend attempts on a 401 and ignore a 429's `Retry-After`. pub(crate) async fn deliver_with( hook: &db::Webhook, body: &str, @@ -262,7 +229,6 @@ pub(crate) async fn deliver_with( ); return Ok(()); } - // Nothing about a second identical request would change the answer. Err(e) if e.retryability == Retryability::Fatal => { return Err(e.into()); } @@ -295,29 +261,14 @@ pub(crate) async fn send_once( /// One POST, reported as the admin API's "test this webhook" result. /// -/// A single attempt on purpose: the retry policy exists so a transient failure -/// does not lose a *notification*, but here an operator is waiting on the -/// answer and what they need to see is what the endpoint said just now. +/// A single attempt on purpose: an operator is waiting on the answer and what +/// they need is what the endpoint said just now. /// /// **The remote response body never travels back to the caller.** The hook's /// URL, headers and body are all admin-controlled and unrestricted by host, so -/// echoing the response would turn this endpoint into a read primitive: point a -/// hook at an internal service, press Test, and read its reply out of the admin -/// API — from where it reaches browser devtools and support tickets. Only the -/// status line comes back. -/// -/// The body is not thrown away, though: this path writes its own server-side -/// `warn!` carrying the truncated response, under exactly the redaction -/// [`deliver_logged`] applies. It has to write its own, because -/// `deliver_logged` is never on this path — and without it a failed test was -/// *undiagnosable*, reporting `endpoint returned 400 Bad Request` to the -/// operator and nothing at all to the log. The commonest cause is a stock -/// Discord template rendering `{{ServerUrl}}` empty because `public_url` is -/// unset, which Discord rejects as a non-absolute embed URL and which the -/// status line alone cannot distinguish from anything else. -/// -/// The transport-error text is [`SendError`]'s, which is already redacted — a -/// webhook URL is a credential and must not travel back either. +/// echoing it would make this endpoint a read primitive. Only the status comes +/// back; the body is logged server-side instead, under the same redaction +/// [`deliver_logged`] applies (which is never on this path). pub(crate) async fn send_test(hook: &db::Webhook, body: &str) -> WebhookTestResult { send_test_with(hook, body, TEST_REQUEST_TIMEOUT).await } @@ -345,9 +296,7 @@ async fn send_test_with( }; // Server-side only, and the same redaction guarantee as `deliver_logged`: - // no URL path or query, ever. `error` is `SendError`'s message, which - // already carries at most MAX_LOGGED_RESPONSE bytes of the remote body and - // has the URL stripped out of any transport error. + // no URL path or query, ever. warn!( webhook = %hook.name, webhook_id = %hook.id, @@ -357,8 +306,7 @@ async fn send_test_with( ); match error { - // Status only. `e.message` carries up to MAX_LOGGED_RESPONSE bytes of - // the remote body and must not leave the server. + // Status only: `e.message` carries part of the remote body. SendError { status: Some(status), .. @@ -367,8 +315,7 @@ async fn send_test_with( status_code: Some(status.as_u16()), error: Some(format!("endpoint returned {status}")), }, - // Nothing reached the endpoint: DNS, connect, TLS or timeout. The - // message is ours, not the remote's. + // Nothing reached the endpoint, so the message is ours to give. e => WebhookTestResult { success: false, status_code: None, @@ -388,12 +335,8 @@ async fn attempt_once( /// One POST, classified. /// /// `reqwest` treats a 4xx/5xx as a perfectly good response, so the status is -/// checked here: without this every failed delivery would be reported as a -/// success and the retry would never fire. Redirects are not followed (see -/// [`WEBHOOK_CLIENT`]), so a 3xx lands in the same non-2xx branch. -/// -/// `timeout` overrides the client default for this request only; `None` keeps -/// [`REQUEST_TIMEOUT`]. +/// checked here. Redirects are not followed (see [`WEBHOOK_CLIENT`]), so a 3xx +/// lands in the same non-2xx branch. async fn attempt_once_within( hook: &db::Webhook, body: &str, @@ -422,7 +365,6 @@ async fn attempt_once_within( Retryability::Transient }, retry_after: None, - // Nothing reached the endpoint, so there is no status to report. status: None, // `reqwest`'s Display includes the URL, which is the credential. message: format!("request failed: {}", redact_reqwest_error(&e)), @@ -463,8 +405,7 @@ pub(crate) struct SendError { /// The endpoint's own instruction, when it sent one. pub retry_after: Option, /// The status the endpoint answered with, or `None` when the request never - /// got that far. Reported by [`send_test`]; the retry loop only cares about - /// [`Retryability`]. + /// got that far. Reported by [`send_test`]. pub status: Option, message: String, } @@ -497,10 +438,8 @@ pub(crate) fn classify_status(status: StatusCode) -> Retryability { /// How long the endpoint asked us to wait, for a rate limit only. /// /// `Retry-After` first, then Discord's `X-RateLimit-Reset-After`. Restricted to -/// 429 on purpose: Discord attaches its rate-limit headers to responses -/// generally, so honouring them on a 5xx would let a `x-ratelimit-reset-after: -/// 0` collapse the exponential backoff into three immediate retries against an -/// endpoint that is already struggling. +/// 429 on purpose: Discord attaches these headers to responses generally, so +/// honouring them on a 5xx would collapse the backoff into an immediate burst. fn retry_after(status: StatusCode, headers: &HeaderMap) -> Option { if status != StatusCode::TOO_MANY_REQUESTS { return None; @@ -518,14 +457,12 @@ fn retry_after(status: StatusCode, headers: &HeaderMap) -> Option { /// `Retry-After` as a delay, capped at [`MAX_RETRY_AFTER`]. /// -/// Only the delta-seconds form is understood — that is what Discord sends, and -/// it may be fractional. An HTTP-date, or anything unparseable, yields `None` -/// and the normal backoff applies. +/// Only the delta-seconds form is understood — that is what Discord sends. +/// Anything else yields `None` and the normal backoff applies. /// /// The cap is applied to the `f64` **before** the conversion: /// `Duration::from_secs_f64` panics outside `Duration`'s range, and this value -/// comes straight off a remote response header — `Retry-After: 1e30` must be a -/// clamped wait, not a panic in the delivery task. +/// comes straight off a remote response header. pub(crate) fn parse_retry_after(value: &str) -> Option { let seconds: f64 = value .trim() @@ -563,8 +500,7 @@ pub(crate) struct ShapedRequest { /// Turn a rendered body into the request `hook`'s destination expects. pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest { match &hook.destination { - // The rendered body goes out verbatim; the operator's headers are - // applied on top, with `Content-Type` pulled out because it describes + // `Content-Type` is pulled out of the operator's headers: it describes // the content rather than being a header of its own. WebhookDestination::Generic { headers, .. } => { let mut content_type = @@ -610,8 +546,7 @@ pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest } // Same as the plugin's `DiscordClient`: the template already rendered // the whole Discord payload — post it as-is, as JSON, with no headers - // of its own. The destination's settings reached the template through - // `payload::with_hook_fields`, not through this function. + // of its own. WebhookDestination::Discord { .. } => ShapedRequest { body: rendered.to_string(), content_type: HeaderValue::from_static(JSON_CONTENT_TYPE), @@ -620,9 +555,8 @@ pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest } } -/// The content type a rendered body should be sent as when the operator has not -/// named one: templates that produce JSON are the common case, but a template -/// is free to produce anything. +/// The content type to send when the operator has not named one. A deviation +/// from the plugin, which sends everything as `text/plain`. pub(crate) fn detect_content_type(body: &str) -> &'static str { if serde_json::from_str::(body).is_ok() { JSON_CONTENT_TYPE @@ -633,12 +567,8 @@ pub(crate) fn detect_content_type(body: &str) -> &'static str { // --- redaction -------------------------------------------------------------- -/// Scheme and host only. -/// -/// A webhook URL's path is a credential: Discord's is -/// `https://discord.com/api/webhooks/{id}/{token}`, and that token is the whole -/// authentication. Log excerpts end up in bug reports, so nothing past the host -/// may appear in one. +/// Scheme and host only: a webhook URL's path is a credential (Discord's is +/// `.../webhooks/{id}/{token}`), so nothing past the host may reach a log. pub(crate) fn redact_url(url: &str) -> String { match reqwest::Url::parse(url) { Ok(parsed) => match parsed.host_str() { @@ -687,8 +617,6 @@ mod tests { time::Instant, }; - /// Short enough that the suite does not crawl, long enough that the two - /// backoff sleeps are observable. const FAST: DeliveryPolicy = DeliveryPolicy { attempts: 3, retry_delay_ms: 20, @@ -750,12 +678,9 @@ mod tests { .to_string() } - /// A local endpoint that answers `statuses` in order — by call count, not - /// by wall clock — repeating the last one once the list runs out. - /// - /// `httpmock` cannot vary a response by call count, and swapping mocks - /// mid-retry means racing the backoff. Returns the URL and the call - /// counter. + /// A local endpoint that answers `statuses` by call count, not by wall + /// clock, repeating the last one once the list runs out. `httpmock` cannot + /// do this, and swapping mocks mid-retry races the backoff. async fn sequenced_endpoint( statuses: &'static [u16], ) -> (String, Arc) { @@ -800,8 +725,8 @@ mod tests { // --- redaction -------------------------------------------------------- - /// A Discord webhook token is the entire credential — it must never reach a - /// log line, and log lines are what operators paste into issue trackers. + /// A Discord webhook token is the entire credential and must never reach a + /// log line. #[test] fn redact_url_keeps_only_the_scheme_and_host() { let secret = "https://discord.com/api/webhooks/123456789/aVerySecretToken"; @@ -818,12 +743,10 @@ mod tests { redact_url("https://hooks.example.test/services/T/B/xyz?token=abc"), "https://hooks.example.test" ); - // Operator input may not parse at all. assert_eq!(redact_url("not a url"), ""); } - /// The transport error's own `Display` embeds the URL; the message we log - /// must not. + /// The transport error's own `Display` embeds the URL. #[tokio::test] async fn a_transport_error_message_carries_no_url_path() { let hook = generic("http://127.0.0.1:1/api/webhooks/123/secret-token", &[]); @@ -902,8 +825,6 @@ mod tests { assert_eq!(names, vec!["x-token", "x-other"]); } - /// Header names and values are operator input: a malformed pair must be - /// dropped, never panic. #[test] fn generic_skips_empty_and_malformed_headers() { let hook = generic( @@ -940,9 +861,7 @@ mod tests { // --- shape_request: discord ------------------------------------------- /// Parity with the plugin's `DiscordClient`: the template renders the whole - /// Discord payload, so the sender must post it byte for byte. Wrapping it - /// in a server-built envelope would break every template copied from the - /// plugin. + /// Discord payload, so the sender must post it byte for byte. #[test] fn discord_posts_the_rendered_body_unmodified() { let rendered = r#"{"content": "@everyone", "embeds": [{"title": "A Movie"}]}"#; @@ -961,8 +880,6 @@ mod tests { "the plugin sends no custom headers to Discord" ); - // Even a body that is not valid JSON goes out untouched, as JSON: the - // template — not the sender — owns the payload. let broken = shape_request(&hook, "not json at all"); assert_eq!(broken.body, "not json at all"); assert!( @@ -976,10 +893,9 @@ mod tests { // --- the actual wire request ------------------------------------------ - /// `shape_request` deciding something is worthless if the decision never - /// reaches the socket. This matches on the received bytes, so deleting the - /// header loop in `attempt_once` — silently dropping an operator's auth - /// token from every delivery — fails here. + /// Matches on the received bytes, so deleting the header loop in + /// `attempt_once` — which would silently drop an operator's auth token from + /// every delivery — fails here. #[tokio::test] async fn the_posted_request_carries_the_body_headers_and_content_type() { let server = MockServer::start_async().await; @@ -1007,8 +923,6 @@ mod tests { .await; } - /// The operator's `Content-Type` must reach the wire too, not just - /// `ShapedRequest`. #[tokio::test] async fn the_operator_content_type_reaches_the_wire() { let server = MockServer::start_async().await; @@ -1032,7 +946,6 @@ mod tests { .await; } - /// Discord gets the rendered bytes and nothing else. #[tokio::test] async fn a_discord_delivery_posts_the_template_output_byte_for_byte() { let server = MockServer::start_async().await; @@ -1085,8 +998,7 @@ mod tests { assert_eq!(parse_retry_after("0"), Some(Duration::ZERO)); } - /// The value is remote input: an HTTP-date, junk, or a hostile number must - /// not pin a delivery slot. + /// The value is remote input and the waiter holds a delivery slot. #[test] fn parse_retry_after_rejects_what_it_cannot_trust() { assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None); @@ -1096,10 +1008,8 @@ mod tests { assert_eq!(parse_retry_after("999999"), Some(MAX_RETRY_AFTER)); } - /// `Duration::from_secs_f64` panics outside `Duration`'s range, so the cap - /// has to be applied to the `f64` before the conversion. These all parse as - /// finite, positive floats and would otherwise panic the delivery task — - /// remotely, from a response header, on any failing status. + /// These all parse as finite, positive floats, so they reach + /// `Duration::from_secs_f64` — which panics outside `Duration`'s range. #[test] fn parse_retry_after_clamps_instead_of_panicking_on_huge_values() { for value in [ @@ -1116,9 +1026,7 @@ mod tests { } } - /// Discord attaches rate-limit headers to responses generally. Obeying them - /// on a 5xx would turn three spaced attempts into an immediate burst - /// against an endpoint that is already failing. + /// Discord attaches rate-limit headers to responses generally. #[test] fn rate_limit_headers_are_only_honoured_on_a_429() { let mut headers = HeaderMap::new(); @@ -1187,9 +1095,7 @@ mod tests { /// The hook's URL, headers and body are all admin-controlled and no host /// policy restricts them, so returning the endpoint's *response body* would - /// make the test button a read primitive: aim a hook at an internal service, - /// press Test, and read its reply out of the admin API response. Only the - /// status may come back. + /// make the test button a read primitive. Only the status may come back. #[tokio::test] async fn send_test_reports_the_status_without_the_remote_response_body() { let server = MockServer::start_async().await; @@ -1257,15 +1163,8 @@ mod tests { } } - /// A failed test used to be *undiagnosable*: the operator was told - /// `endpoint returned 400 Bad Request` — deliberately, the response body is - /// an SSRF read primitive — and the server log was told nothing at all, - /// because `deliver_logged` is not on this path. The commonest real cause, - /// a stock Discord template whose `{{ServerUrl}}` rendered empty because - /// `public_url` is unset, is invisible from the status line alone. - /// - /// So the body is logged, and the credential still is not: the same - /// redaction `deliver_logged` applies. + /// The caller only ever gets the status, so the endpoint's reason has to + /// reach the server log instead — and the credential still must not. #[tokio::test] async fn a_failed_test_is_logged_server_side_without_the_url_path() { let server = MockServer::start_async().await; @@ -1330,9 +1229,8 @@ mod tests { assert_eq!(result.error, None); } - /// The test request carries its own, shorter timeout so a blackholing - /// endpoint cannot hold an admin request handler for the full - /// [`REQUEST_TIMEOUT`]. Injected here so the test costs milliseconds. + /// A blackholing endpoint must not hold an admin request handler for the + /// full [`REQUEST_TIMEOUT`]. #[tokio::test] async fn send_test_applies_its_own_timeout_to_the_request() { let server = MockServer::start_async().await; @@ -1359,10 +1257,8 @@ mod tests { // --- redirects ---------------------------------------------------------- - /// `reqwest` follows up to ten redirects by default, which would let a - /// vetted webhook URL hand the request to a host chosen by the remote server - /// at request time — reached from inside the network the server runs in. - /// The redirect is reported as the non-2xx it is instead. + /// `reqwest` follows up to ten redirects by default, which would let the + /// remote server pick a host reached from inside this server's network. #[tokio::test] async fn a_redirect_is_not_followed() { let server = MockServer::start_async().await; @@ -1394,8 +1290,6 @@ mod tests { assert_eq!(result.status_code, Some(302)); } - /// The same policy has to hold on the background delivery path, and a 302 - /// must not be retried — repeating it verbatim would never succeed. #[tokio::test] async fn a_redirect_is_not_followed_or_retried_during_delivery() { let server = MockServer::start_async().await; @@ -1452,8 +1346,7 @@ mod tests { ); } - /// A 400 means the request itself is wrong: repeating it verbatim cannot - /// help, and on Discord it burns rate limit. + /// Repeating a 4xx verbatim cannot help, and on Discord it burns rate limit. #[tokio::test] async fn a_fatal_status_is_attempted_exactly_once() { for status in [400u16, 401, 403, 404] { @@ -1481,7 +1374,6 @@ mod tests { } } - /// A 429 *is* retried — and on the endpoint's own schedule. #[tokio::test] async fn a_rate_limit_is_retried_on_the_endpoint_schedule() { let server = MockServer::start_async().await; @@ -1519,9 +1411,8 @@ mod tests { ); } - /// The same headers on a 5xx must be ignored: the exponential schedule has - /// to survive an endpoint that advertises a zero rate-limit reset while it - /// is failing for an unrelated reason. + /// The exponential schedule has to survive an endpoint that advertises a + /// zero rate-limit reset while failing for an unrelated reason. #[tokio::test] async fn a_server_error_keeps_the_exponential_schedule() { let server = MockServer::start_async().await; @@ -1550,8 +1441,6 @@ mod tests { .await, 3 ); - // Two backoff sleeps of at least 150 ms and 300 ms. Honouring the - // headers would collapse this to a burst of three immediate requests. assert!( started.elapsed() >= Duration::from_millis(400), "the backoff was skipped, took only {:?}", @@ -1559,11 +1448,6 @@ mod tests { ); } - /// The endpoint answers by call count rather than by wall clock. The - /// previous version of this test swapped a failing mock for a healthy one - /// from the outside and needed two mock-server round-trips to land inside a - /// ~200-250 ms backoff window — which is a race, and the likeliest flake on - /// this branch. Nothing here depends on timing. #[tokio::test] async fn delivery_stops_at_the_first_success() { let (url, calls) = sequenced_endpoint(&[500, 500, 200]).await; @@ -1580,8 +1464,6 @@ mod tests { ); } - /// …and it really is the success that stops it: with a fourth attempt - /// available the delivery still ends on the 200. #[tokio::test] async fn a_success_ends_the_retry_loop_with_budget_to_spare() { let (url, calls) = sequenced_endpoint(&[500, 200, 500]).await; @@ -1613,7 +1495,6 @@ mod tests { deliver_with(&hook, "ping", &FAST) .await .expect_err("a connection failure must surface as an error internally"); - // …but the fire-and-forget entry point returns quietly. deliver(hook, "ping".into()).await; } @@ -1629,8 +1510,7 @@ mod tests { // --- delivery slots ---------------------------------------------------- - /// The point of keying by hook: a saturated endpoint must not consume the - /// slots of a healthy one. + /// A saturated endpoint must not consume the slots of a healthy one. #[test] fn slots_are_counted_per_hook() { let slots = DeliverySlots::new(2); @@ -1665,8 +1545,8 @@ mod tests { ); } - /// Entry condition 3: the drop branch drops, and it drops silently rather - /// than queueing — a saturated hook must produce no request at all. + /// The drop branch drops rather than queueing: a saturated hook must + /// produce no request at all. #[tokio::test] async fn spawn_delivery_drops_the_event_when_the_hook_is_saturated() { let server = MockServer::start_async().await; @@ -1687,7 +1567,6 @@ mod tests { !spawn_delivery_with(&slots, hook.clone(), "ping".into(), FAST), "with no slot the delivery must be dropped" ); - // A different hook is untouched by the first one's saturation. let other = db::Webhook { id: Uuid::from_u128(200), ..hook.clone() @@ -1703,7 +1582,6 @@ mod tests { "the slot is available again once the delivery finishes" ); - // Exactly the two accepted deliveries reached the endpoint. eventually("both accepted deliveries", async || { mock.hits_async() .await @@ -1719,14 +1597,8 @@ mod tests { } /// [`LogThrottle`] is unit-tested in its own module; this proves it is - /// actually wired into the drop branch. - /// - /// It has to be, because that branch is reachable from an *unauthenticated* - /// caller: `api::users` emits `AuthenticationFailure` on a failed login, - /// `User::authenticate` short-circuits an unknown username in one indexed - /// SELECT, and every delivery past the ceiling used to write a line. At - /// 1000 requests a second that is 1000 lines a second, paced by the - /// attacker. + /// wired into the drop branch, which is reachable from an *unauthenticated* + /// caller (`AuthenticationFailure` on a failed login). #[tokio::test] async fn the_saturation_warning_is_logged_once_not_once_per_dropped_event() { let slots = DeliverySlots::new(1); @@ -1772,8 +1644,8 @@ mod tests { /// The permit is taken *before* the spawn: acquiring it inside the task /// would bound sockets but let tasks pile up parked on the semaphore. - /// Nothing is awaited between the call and the assertion, so the spawned - /// task cannot have run — this observes the synchronous acquisition only. + /// Nothing is awaited before the assertion, so the spawned task cannot have + /// run — this observes the synchronous acquisition only. #[tokio::test] async fn spawn_delivery_takes_its_permit_before_spawning() { let server = MockServer::start_async().await; @@ -1800,7 +1672,6 @@ mod tests { "the permit must already be held before the task is polled" ); - // And it is held for the whole delivery, then released. eventually("the slot to come back", async || { slots .try_acquire(hook.id) diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index 42979b393..76dd7a182 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -25,10 +25,9 @@ pub(crate) fn fresh_registry() -> Handlebars<'static> { // most variables are event-specific and templates are user-written. registry.set_strict_mode(false); // Bodies are JSON, not HTML: `{{Var}}` almost always sits inside a JSON - // string literal, so that is what values are escaped for. HTML escaping - // would mangle `Ocean's` into `Ocean's`, and no escaping at all would - // let a title like `The "Burbs` break the body. `{{{Var}}}` stays the raw - // escape hatch, exactly as in the Jellyfin plugin's stock templates. + // string literal, so that is what values are escaped for — a deviation from + // the plugin, whose HTML escaping mangles `Ocean's` into `Ocean's`. + // `{{{Var}}}` stays the raw escape hatch, as in the plugin. registry.register_escape_fn(escape_json_string); register_helpers(&mut registry); registry @@ -65,10 +64,9 @@ pub(crate) fn build_registry(hooks: &[db::Webhook]) -> Handlebars<'static> { /// **propagated**. /// /// [`build_registry`] is deliberately lenient — one hook's typo must not stop -/// the others being delivered — but that leniency turns a syntax error into a -/// later "Template not found: " from `render`, naming an id the operator -/// never typed and hiding the real error in the server log. Callers with a -/// single hook in hand and an operator waiting on the answer use this instead. +/// the others being delivered — but that turns a syntax error into a later +/// "Template not found: " from `render`. Callers with a single hook in +/// hand and an operator waiting on the answer use this instead. pub(crate) fn single_registry( hook: &db::Webhook, ) -> Result, handlebars::TemplateError> { @@ -82,15 +80,14 @@ pub(crate) fn single_registry( Ok(registry) } -/// Whether an operator-supplied template parses. -/// -/// The error text is derived from the operator's own template — never from a -/// remote response — so it is safe to hand back over the API. /// The name the template is registered under while it is being checked. It /// appears in handlebars' error text, so it has to read as something the /// operator recognises rather than as an internal id. const VALIDATION_NAME: &str = "webhook template"; +/// Whether an operator-supplied template parses. The error text is derived from +/// the operator's own template — never from a remote response — so it is safe +/// to hand back over the API. pub(crate) fn validate(template: &str) -> Result<(), handlebars::TemplateError> { Handlebars::new().register_template_string(VALIDATION_NAME, template) } @@ -113,8 +110,7 @@ pub(crate) fn render( let data = super::payload::with_hook_fields(data, hook); let body = if hook.send_all_properties { - // The whole dictionary, template ignored — this is the "show me every - // variable" mode of the plugin. + // The plugin's "show me every variable" mode: template ignored. serde_json::to_string_pretty(data.as_ref())? } else { registry.render( @@ -309,8 +305,8 @@ mod tests { .clone() } - /// Renders `template` against `pairs` through the real registry path - /// (pre-compiled, registered under the hook id). + /// Renders through the real registry path: pre-compiled, registered under + /// the hook id. fn render_template(template: &str, pairs: Value) -> String { let hook = hook(template); let registry = build_registry(std::slice::from_ref(&hook)); @@ -394,8 +390,6 @@ mod tests { // --- link_to / url_encode / json_encode ------------------------------- - /// Single-quoted `href`, as the plugin emits: the anchor has to survive - /// inside a JSON string literal, which a double quote would terminate. #[test] fn link_to_emits_a_single_quoted_anchor() { let body = render_template( @@ -428,8 +422,8 @@ mod tests { ); } - /// The template supplies the quotes (`"title": "{{json_encode Name}}"`), so - /// the helper must not add its own — that is what the plugin does. + /// The plugin idiom supplies the quotes (`"title": "{{json_encode Name}}"`), + /// so the helper must not add its own. #[test] fn json_encode_escapes_without_adding_quotes() { assert_eq!( @@ -447,7 +441,6 @@ mod tests { "2" ); - // The canonical plugin idiom must produce valid JSON. let body = render_template( r#"{"title": "{{json_encode Name}}"}"#, json!({ "Name": "He said \"hi\"" }), @@ -457,9 +450,8 @@ mod tests { assert_eq!(parsed["title"], json!("He said \"hi\"")); } - /// Bodies are JSON, not HTML: `'` and `&` must stay readable, while `"`, - /// `\` and control characters must be escaped for the string literal the - /// value almost always sits in. + /// Bodies are JSON, not HTML: `'` and `&` stay readable, `"`, `\` and + /// control characters are escaped for the surrounding string literal. #[test] fn plain_substitution_is_escaped_for_a_json_string() { assert_eq!( @@ -479,7 +471,6 @@ mod tests { r"line\nbreak" ); - // A body built the usual way survives a hostile title. let body = render_template( r#"{"title": "{{Name}}"}"#, json!({ "Name": "The \"Burbs\\" }), @@ -489,8 +480,7 @@ mod tests { assert_eq!(parsed["title"], json!("The \"Burbs\\")); } - /// Triple braces stay the raw escape hatch, as in the plugin's stock - /// templates — which is also why the double-brace form must escape. + /// Triple braces stay the raw escape hatch, as in the plugin. #[test] fn triple_braces_bypass_the_escaping() { assert_eq!( @@ -519,15 +509,10 @@ mod tests { }) } - /// The stock template ships from the dashboard (a WASM crate) and is - /// rendered by this registry (the server crate), so until it moved into the - /// SDK *nothing anywhere* exercised the two halves together — which is how - /// seven `{{{triple}}}` interpolations survived the switch from the - /// plugin's HTML escaping to [`escape_json_string`]. - /// - /// A triple brace bypasses the escape function, so a title carrying `"` or - /// `\` renders a body Discord answers 400 to. That is classified `Fatal`, - /// so there is no retry and the operator sees nothing. + /// The stock template ships from the SDK and is rendered by this registry, + /// and a triple brace bypasses [`escape_json_string`] — so a title carrying + /// `"` or `\` would render a body Discord answers 400 to, fatally and + /// without a retry. #[test] fn the_stock_discord_template_survives_a_title_that_is_hostile_to_json() { let name = r#"Ocean's "11" \ Redux"#; @@ -546,8 +531,8 @@ mod tests { ); } - /// …and an ordinary title must render byte-identically to what the plugin's - /// own template produced, so the change is a fix and not a behaviour break. + /// …and an ordinary title renders exactly what the plugin's own template + /// produced. #[test] fn the_stock_discord_template_is_unchanged_for_an_ordinary_title() { let body = render_template( @@ -683,8 +668,6 @@ mod tests { // --- registry wiring -------------------------------------------------- - /// The whole point of the cached registry: each hook's template is compiled - /// once, at reload time, and rendered by name afterwards. #[test] fn build_registry_precompiles_every_hook_template() { let first = db::Webhook { diff --git a/crates/remux-server/src/services/webhooks/throttle.rs b/crates/remux-server/src/services/webhooks/throttle.rs index a1f93b99f..cb6f8a876 100644 --- a/crates/remux-server/src/services/webhooks/throttle.rs +++ b/crates/remux-server/src/services/webhooks/throttle.rs @@ -1,19 +1,10 @@ //! Rate-limiting for the two per-event webhook warnings. //! -//! Both of them are driven by something the server does not control. The -//! saturation warning in [`super::sender`] fires once per delivery past a -//! hook's ceiling of four in flight, and the emission site behind it — -//! `AuthenticationFailure` — is reachable **without credentials**: at 1000 -//! failed logins a second that is 1000 `warn!` lines a second onto the -//! operator's disk, chosen by the attacker. The render-failure warning in -//! [`super::mod`] fires once per event for a template that does not render, so -//! a hook subscribed to `PlaybackProgress` with a broken template logs once per -//! progress tick, forever. -//! -//! Neither line is worth dropping — the first time each happens is exactly what -//! an operator needs to see. What is worth dropping is the repetition, so a -//! [`LogThrottle`] emits one line per key per window and carries the count of -//! what it suppressed since the last one. +//! Both are driven by something the server does not control: the saturation +//! warning in [`super::sender`] is reachable without credentials, and the +//! render-failure warning fires once per event for a template that does not +//! render. Neither line is worth dropping the first time, so a [`LogThrottle`] +//! emits one per key per window carrying the count it suppressed. use std::{ collections::HashMap, @@ -24,10 +15,8 @@ use uuid::Uuid; /// One log line per key per window. /// -/// Keyed by webhook id, so a flood against one hook never silences another — -/// the same reasoning as `sender::DeliverySlots`, and the same accepted leak: -/// entries are not pruned, which costs tens of bytes per hook an operator has -/// ever created. +/// Keyed by webhook id, so a flood against one hook never silences another. +/// Entries are not pruned, at tens of bytes per hook ever created. pub(crate) struct LogThrottle { window: Duration, state: Mutex>, @@ -95,8 +84,6 @@ mod tests { Uuid::from_u128(n) } - /// The point of the whole module: an unauthenticated caller can drive the - /// call site at whatever rate it manages, and only the first line lands. #[test] fn only_the_first_occurrence_in_a_window_logs() { let throttle = LogThrottle::new(Duration::from_secs(3600)); @@ -110,8 +97,7 @@ mod tests { } } - /// A flood against one hook must not silence a different one — the same - /// reason delivery slots are counted per hook. + /// A flood against one hook must not silence a different one. #[test] fn keys_are_throttled_independently() { let throttle = LogThrottle::new(Duration::from_secs(3600)); @@ -124,8 +110,7 @@ mod tests { ); } - /// The line that does get through has to say how much it stands for, or the - /// operator reads one dropped event where there were a thousand. + /// The line that gets through must say how much it stands for. #[test] fn the_next_line_carries_what_was_suppressed() { let throttle = LogThrottle::new(Duration::from_millis(30)); From 601c360342c7b630c472bb68b4bf1b12b8e853f5 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Sat, 8 Aug 2026 11:05:00 +0200 Subject: [PATCH 25/29] fix(webhooks): adapt the enrich_item fixture to the removed series_imdb field The upstream merge dropped ExternalIds::series_imdb. Season and episode ids derive from the series' external ids plus their season/episode numbers, so the child rows now carry the series' ids directly. --- crates/remux-server/src/services/webhooks/payload.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/remux-server/src/services/webhooks/payload.rs b/crates/remux-server/src/services/webhooks/payload.rs index b1af90df6..45607e694 100644 --- a/crates/remux-server/src/services/webhooks/payload.rs +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -1627,10 +1627,9 @@ mod tests { imdb: Some(imdb(SERIES_IMDB)), ..Default::default() }; - let child_ids = db::ExternalIds { - series_imdb: Some(imdb(SERIES_IMDB)), - ..Default::default() - }; + // Season and episode ids derive from the series' external ids plus the + // season/episode numbers, so the children carry the series' ids. + let child_ids = series_ids.clone(); let mut series = db::Media { id: derived_id(db::MediaKind::Series, &series_ids, None, None), From af46d7022e778bbe061cb2089483cd643692c93a Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Sun, 9 Aug 2026 15:17:55 +0200 Subject: [PATCH 26/29] refactor(utils): make the retry backoff curve a shared function `retry!` computed `base * 2^attempt` plus jitter inline, and the webhook sender re-derived the same formula because it retries only *some* failures and cannot use the macro. Two copies of one curve drift. `retry::backoff(base_ms, attempt)` is now the single definition and the macro calls it, so the hand-rolled loops can too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018MA1Q5i4k7y7K58nKfADW4 --- crates/remux-utils/src/lib.rs | 2 +- crates/remux-utils/src/retry.rs | 33 ++++++++++++++++++++++----------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/remux-utils/src/lib.rs b/crates/remux-utils/src/lib.rs index 569afd749..c20d262af 100644 --- a/crates/remux-utils/src/lib.rs +++ b/crates/remux-utils/src/lib.rs @@ -3,7 +3,7 @@ mod store; pub use store::Store; -mod retry; +pub mod retry; mod types; pub use types::NonEmptyString; diff --git a/crates/remux-utils/src/retry.rs b/crates/remux-utils/src/retry.rs index 192b5f310..7c8753e64 100644 --- a/crates/remux-utils/src/retry.rs +++ b/crates/remux-utils/src/retry.rs @@ -1,3 +1,21 @@ +/// `base_ms * 2^attempt` plus jitter in `[0, base_ms/2)`. +/// +/// The one definition of the project's backoff curve, so [`retry!`] and the +/// hand-rolled loops that cannot use it (webhook delivery retries only *some* +/// failures) cannot drift apart. +/// +/// `attempt` is 0-based; the exponent is capped and every step saturates. +pub fn backoff(base_ms: u64, attempt: u32) -> ::std::time::Duration { + let exponential = base_ms.saturating_mul(1u64 << attempt.min(10)); + // `SystemTime` nanos as cheap entropy — this only needs to de-correlate + // concurrent retriers, not resist prediction. + let jitter = ::std::time::SystemTime::now() + .duration_since(::std::time::UNIX_EPOCH) + .map(|since| since.subsec_nanos() as u64 % (base_ms / 2 + 1)) + .unwrap_or(0); + ::std::time::Duration::from_millis(exponential.saturating_add(jitter)) +} + /// Retry a fallible async expression with exponential backoff and jitter. /// /// # Parameters @@ -33,17 +51,10 @@ macro_rules! retry { Err(e) => { __last_err = Some(e); if __attempt + 1 < ($attempts as u32) { - let __base_ms = $delay as u64; - // Exponential: base * 2^attempt, capped to avoid overflow - let __exp_ms = __base_ms.saturating_mul(1u64 << __attempt.min(10)); - // Jitter: [0, base/2) using SystemTime nanos as cheap entropy - let __jitter_ms = ::std::time::SystemTime::now() - .duration_since(::std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos() as u64 % (__base_ms / 2 + 1)) - .unwrap_or(0); - ::tokio::time::sleep( - ::std::time::Duration::from_millis(__exp_ms + __jitter_ms), - ) + ::tokio::time::sleep($crate::retry::backoff( + $delay as u64, + __attempt, + )) .await; } } From 32248e7b0248e80c063fe4c6544b68b8a15061ae Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Sun, 9 Aug 2026 15:18:04 +0200 Subject: [PATCH 27/29] refactor(sdks): derive EnumIter on NotificationType The dashboard's subscription checkboxes came from a hand-written array of all 15 variants, pinned only by its declared length. A variant added to the SDK had to be added there too or it silently disappeared from the UI. `NotificationType::iter()` now feeds the list, in the same declaration order the form already treated as canonical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018MA1Q5i4k7y7K58nKfADW4 --- Cargo.lock | 1 + crates/remux-dashboard/Cargo.toml | 2 + crates/remux-dashboard/src/pages/webhooks.rs | 53 ++++++++------------ crates/remux-sdks/src/remux/mod.rs | 3 ++ 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 860b53cda..566cbf0e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6596,6 +6596,7 @@ dependencies = [ "remux-sdks", "serde", "serde_json", + "strum", "urlencoding", "uuid", "web-sys", diff --git a/crates/remux-dashboard/Cargo.toml b/crates/remux-dashboard/Cargo.toml index 104d9758e..c7c65dd24 100644 --- a/crates/remux-dashboard/Cargo.toml +++ b/crates/remux-dashboard/Cargo.toml @@ -18,6 +18,8 @@ js-sys = "0.3" serde_json = "1" base64 = "0.22" urlencoding = "2.1.3" +# `IntoEnumIterator`, for the `EnumIter` the SDK derives on `NotificationType`. +strum = "0.27" [profile.release] opt-level = "z" diff --git a/crates/remux-dashboard/src/pages/webhooks.rs b/crates/remux-dashboard/src/pages/webhooks.rs index b4612a5f3..f08a2ed78 100644 --- a/crates/remux-dashboard/src/pages/webhooks.rs +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -22,6 +22,7 @@ use remux_sdks::remux::{ }; use remux_sdks::ClientError; use std::{collections::HashMap, str::FromStr}; +use strum::IntoEnumIterator; use uuid::Uuid; /// The colour the server injects when a Discord hook names none (`0x3399FF`), @@ -34,25 +35,13 @@ const DEFAULT_EMBED_COLOR: &str = "#3399ff"; /// Every [`NotificationType`], in the order the SDK declares them. /// -/// Hand-written, so a variant added to the SDK must be added here too — the -/// array's declared length is what pins the count. -const NOTIFICATION_TYPES: [NotificationType; 15] = [ - NotificationType::ItemAdded, - NotificationType::ItemDeleted, - NotificationType::Generic, - NotificationType::PlaybackStart, - NotificationType::PlaybackProgress, - NotificationType::PlaybackStop, - NotificationType::AuthenticationSuccess, - NotificationType::AuthenticationFailure, - NotificationType::SessionStart, - NotificationType::TaskCompleted, - NotificationType::UserCreated, - NotificationType::UserDeleted, - NotificationType::UserUpdated, - NotificationType::UserPasswordChanged, - NotificationType::UserDataSaved, -]; +/// Derived from the enum rather than listed here: a variant added to the SDK +/// reaches the checkbox list on its own, with no second list to fall out of +/// step. `EnumIter` yields declaration order, which is the order the form and +/// [`sorted_notification_types`] treat as canonical. +fn notification_types() -> impl Iterator { + NotificationType::iter() +} /// Labels for the seven [`WebhookItemTypes`] flags, indexed the same way as /// [`item_type_flag`] / [`set_item_type_flag`]. @@ -165,10 +154,8 @@ fn non_empty(value: &str) -> Option { /// `selected` in the canonical order, de-duplicated, so the payload does not /// depend on the order the operator ticked the boxes. fn sorted_notification_types(selected: &[NotificationType]) -> Vec { - NOTIFICATION_TYPES - .iter() + notification_types() .filter(|t| selected.contains(t)) - .copied() .collect() } @@ -903,7 +890,7 @@ fn WebhookFormModal( } } div { class: "check-row-group", - for notification in NOTIFICATION_TYPES { + for notification in notification_types() { { let label = notification.to_string(); let checked = f.notification_types.contains(¬ification); @@ -1278,32 +1265,36 @@ mod tests { #[test] fn every_notification_type_round_trips_through_its_label() { - let mut labels: Vec = NOTIFICATION_TYPES - .iter() + let mut labels: Vec = notification_types() .map(|t| t.to_string()) .collect(); for (label, expected) in labels .iter() - .zip(NOTIFICATION_TYPES.iter()) + .zip(notification_types()) { assert_eq!( NotificationType::from_str(label).ok(), - Some(*expected), + Some(expected), "label {label} did not parse back" ); } labels.sort(); labels.dedup(); - assert_eq!(labels.len(), 15, "the list must have no duplicates"); + assert_eq!( + labels.len(), + NotificationType::COUNT, + "the list must have no duplicates" + ); } - /// The form's list is hand-written. + /// `EnumIter` is what keeps the form in step with the SDK; this pins that it + /// really does yield every variant. #[test] fn the_list_covers_every_variant_the_sdk_declares() { assert_eq!( - NOTIFICATION_TYPES.len(), + notification_types().count(), NotificationType::COUNT, - "a NotificationType variant is missing from NOTIFICATION_TYPES" + "EnumIter must yield every NotificationType variant" ); } diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 47de39939..06b38e65b 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6380,6 +6380,9 @@ pub struct RefreshItemQuery { strum_macros::EnumString, strum_macros::Display, strum_macros::EnumCount, + // The dashboard builds its subscription checkboxes from this, so a variant + // added here reaches the UI without a second list to keep in step. + strum_macros::EnumIter, )] pub enum NotificationType { ItemAdded, From 520dbecd5f077342a7c0b04daac9f0b4ca919bdb Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Sun, 9 Aug 2026 15:18:16 +0200 Subject: [PATCH 28/29] fix(webhooks): harden template validation, event pacing and reload retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review findings on the webhook service. They land together because they interlock through `mod.rs` signatures. - Template validation only parsed. Handlebars resolves a helper name at *render* time, so `{{url_encod Name}}` saved cleanly and then dropped every delivery. `validate` now renders against the synthetic payload the admin test button uses, through a registry that carries the custom helpers. A stored template with a helper typo can no longer be saved until it is fixed. - A library scan emitted `ItemAdded` faster than the dispatcher drains it, overflowing the 4096-event channel into a `Lagged` line. `Pacer` slows the burst instead of batching it, which would break the one-event-per-item contract the plugin's templates rely on. Two bounds keep a sick dispatcher from stalling the scan. - A failed `reload` left `dirty` raised, so a database outage became one failing query per event — at a rate an unauthenticated caller can drive through `AuthenticationFailure`. Retries now back off to 8s. - `DeliverySlots` never dropped an entry, so a deleted hook kept its semaphore until restart. `reload` prunes against the live hook set, keeping any entry whose permit is still out. - `detect_content_type` built a whole `Value` tree to answer a yes/no question; `IgnoredAny` runs the same parser without it. The delivery permit still spans retries and `Retry-After` sleeps, which review flagged. That is deliberate and now documented: releasing it would turn dropped events into tasks parked on a semaphore, and keep pushing at an endpoint that just asked us to stop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018MA1Q5i4k7y7K58nKfADW4 --- crates/remux-server/src/api/webhooks.rs | 10 +- .../remux-server/src/services/webhooks/mod.rs | 468 +++++++++++++++++- .../src/services/webhooks/sender.rs | 113 ++++- .../src/services/webhooks/template.rs | 79 ++- .../src/tasks/catalog_import_shared.rs | 13 +- 5 files changed, 639 insertions(+), 44 deletions(-) diff --git a/crates/remux-server/src/api/webhooks.rs b/crates/remux-server/src/api/webhooks.rs index 8c60268f6..acea76ee7 100644 --- a/crates/remux-server/src/api/webhooks.rs +++ b/crates/remux-server/src/api/webhooks.rs @@ -92,11 +92,11 @@ fn with_parsed_url(payload: WebhookDto) -> Result { } } -/// `payload` with its template proved to parse — or a 400 carrying handlebars' -/// own message. +/// `payload` with its template proved to parse *and* render — or a 400 carrying +/// handlebars' own message. /// -/// The parse error is derived from the operator's own template — never from a -/// remote response, never from the URL — so returning it leaks nothing. +/// The error is derived from the operator's own template — never from a remote +/// response, never from the URL — so returning it leaks nothing. /// /// Checked even when `send_all_properties` bypasses the template at render /// time: the flag is one checkbox away from being turned off. @@ -104,7 +104,7 @@ fn with_checked_template(payload: WebhookDto) -> Result { match webhooks::validate_template(&payload.template) { Ok(()) => Ok(payload), Err(e) => { - let detail = format!("webhook template does not parse: {e}"); + let detail = format!("webhook template is not usable: {e}"); Err(e.context_bad_request(&detail)) } } diff --git a/crates/remux-server/src/services/webhooks/mod.rs b/crates/remux-server/src/services/webhooks/mod.rs index 610207196..8d98512a2 100644 --- a/crates/remux-server/src/services/webhooks/mod.rs +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -34,6 +34,131 @@ use tracing::{debug, warn}; /// (one enrichment round-trip) never drops events under normal playback load. const EVENT_CHANNEL_CAPACITY: usize = 4096; +/// How hard a [`Pacer`] tries to let the dispatcher catch up. Extracted so tests +/// do not have to wait out the real thresholds. +#[derive(Debug, Clone, Copy)] +struct PacingPolicy { + /// Backlog above which emission waits. + high_water: usize, + /// Longest a single emission waits before going ahead anyway. + max_wait: std::time::Duration, + /// Gap between backlog checks. + poll_interval: std::time::Duration, + /// Consecutive exhausted waits after which the burst stops pacing. See + /// [`Pacer`]. + give_up_after: u32, +} + +impl Default for PacingPolicy { + fn default() -> Self { + Self { + // Half the channel, so a paced burst keeps a margin for the unpaced + // events (playback, auth) that share it. + high_water: EVENT_CHANNEL_CAPACITY / 2, + max_wait: std::time::Duration::from_secs(5), + poll_interval: std::time::Duration::from_millis(50), + give_up_after: 3, + } + } +} + +/// Emits a burst of events at a rate the dispatcher can keep up with. +/// +/// A library scan produces `ItemAdded` in tens of thousands, faster than the +/// dispatcher — one enrichment query per event — drains them, so unpaced the +/// overflow becomes a `Lagged` line and the "new movie" notification is silently +/// lost. Batching would fix the throughput but not the contract: one event per +/// item is what the plugin's templates are written against. +/// +/// Two bounds stop a sick dispatcher from stalling the scan: a wait gives up +/// after `max_wait` and emits anyway, and `give_up_after` exhausted waits in a +/// row drop the burst back to unpaced emission — dropped events at full speed +/// beats `max_wait` per item for a whole library. Pacing resumes once the +/// backlog is back under the mark, so an early hiccup does not cost the rest. +/// +/// One per burst: the give-up counter is what makes the second bound work. +pub struct Pacer { + service: WebhookService, + policy: PacingPolicy, + consecutive_timeouts: u32, + /// Latched: a burst that gave up does not start pacing again. + gave_up: bool, +} + +impl Pacer { + fn new(service: WebhookService, policy: PacingPolicy) -> Self { + Self { + service, + policy, + consecutive_timeouts: 0, + gave_up: false, + } + } + + /// Emit `event`, waiting first if the dispatcher is behind. + pub async fn emit(&mut self, event: WebhookEvent) { + // One channel read, no wait: a cleared stall resumes pacing, a dispatcher + // that really is wedged stays given up on. + if self.gave_up + && self + .service + .tx + .len() + <= self + .policy + .high_water + { + self.gave_up = false; + self.consecutive_timeouts = 0; + } + if !self.gave_up { + self.wait_for_room() + .await; + } + self.service + .emit(event); + } + + async fn wait_for_room(&mut self) { + let deadline = std::time::Instant::now() + + self + .policy + .max_wait; + while self + .service + .tx + .len() + > self + .policy + .high_water + { + if std::time::Instant::now() >= deadline { + self.consecutive_timeouts += 1; + if self.consecutive_timeouts + >= self + .policy + .give_up_after + { + warn!( + waits = self.consecutive_timeouts, + "webhook dispatcher is not draining, emitting the rest of \ + this burst unpaced" + ); + self.gave_up = true; + } + return; + } + tokio::time::sleep( + self.policy + .poll_interval, + ) + .await; + } + // Under the mark within the budget — the normal case is not waiting at all. + self.consecutive_timeouts = 0; + } +} + /// `Name` seen by the template of the synthetic event [`deliver_test`] sends. pub const TEST_EVENT_TITLE: &str = "Test notification"; @@ -95,6 +220,70 @@ const _: () = assert!( "NotificationType has outgrown the u32 `wants` mask — widen it to u64" ); +/// Whether [`WebhookService::reload`] got a snapshot out of the database. +/// +/// A failure leaves `dirty` set, so the next event would retry the query — one +/// failing query per event for as long as the database is down, at a rate an +/// unauthenticated caller can drive through `AuthenticationFailure`. Hence +/// [`ReloadRetry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReloadOutcome { + Loaded, + Failed, +} + +/// Base delay of the reload retry, grown exponentially per consecutive failure. +const RELOAD_RETRY_BASE_MS: u64 = 500; + +/// Cap on consecutive failures counted, so the delay tops out at +/// `RELOAD_RETRY_BASE_MS * 2^4` ≈ 8s rather than growing without bound. +/// +/// Kept low because nothing interrupts the wait: it is also how long a recovered +/// database goes unnoticed, and how long `dirty` stays raised — during which +/// [`WebhookService::wants`] answers `true` for everything. +const RELOAD_RETRY_MAX_EXPONENT: u32 = 4; + +/// When the dispatcher may next attempt a reload. +/// +/// Owned by the dispatcher task, which is the only caller of +/// [`WebhookService::reload`] — see the invariant documented there. +#[derive(Debug, Default)] +struct ReloadRetry { + /// `None` once a reload has succeeded: the next one runs immediately. + not_before: Option, + consecutive_failures: u32, +} + +impl ReloadRetry { + fn is_due(&self) -> bool { + match self.not_before { + Some(deadline) => std::time::Instant::now() >= deadline, + None => true, + } + } + + fn record(&mut self, outcome: ReloadOutcome) { + match outcome { + ReloadOutcome::Loaded => { + self.not_before = None; + self.consecutive_failures = 0; + } + ReloadOutcome::Failed => { + let attempt = self + .consecutive_failures + .min(RELOAD_RETRY_MAX_EXPONENT); + self.not_before = Some( + std::time::Instant::now() + + remux_utils::retry::backoff(RELOAD_RETRY_BASE_MS, attempt), + ); + self.consecutive_failures = self + .consecutive_failures + .saturating_add(1); + } + } + } +} + #[derive(Clone)] pub struct WebhookService { tx: broadcast::Sender>, @@ -126,6 +315,11 @@ impl WebhookService { .send(Arc::new(event)); } + /// A [`Pacer`] for one burst of events. See its documentation. + pub fn pacer(&self) -> Pacer { + Pacer::new(self.clone(), PacingPolicy::default()) + } + /// Whether any enabled webhook subscribes to `notification_type`. /// /// `emit` is cheap, but building an event is not — cloning usernames and @@ -184,7 +378,7 @@ impl WebhookService { /// holds no read guard. That is what makes it safe for the dispatcher to /// hold the read guard across `enrich_item().await` — no other task can be /// waiting for the write lock. - async fn reload(&self, ctx: &AppContext) { + async fn reload(&self, ctx: &AppContext) -> ReloadOutcome { // Never fails (falls back to defaults), so it is applied even when the // hook query below does not. let server = payload::ServerInfo::load(ctx).await; @@ -199,13 +393,23 @@ impl WebhookService { .await .server = server; // Ask for another attempt: the flag was consumed before the - // call, so nothing else will set it. + // call, so nothing else will set it. The caller decides *when* + // that attempt happens — see [`ReloadOutcome`]. self.inner .dirty .store(true, Ordering::Release); - return; + return ReloadOutcome::Failed; } }; + + // Slots are keyed by hook id and created on first delivery, so this is + // the only place that ever learns a hook is gone. + sender::retain_delivery_slots( + &hooks + .iter() + .map(|hook| hook.id) + .collect(), + ); let wanted: HashSet = hooks .iter() .flat_map(|hook| { @@ -240,6 +444,7 @@ impl WebhookService { .wanted_mask .store(mask, Ordering::Relaxed); } + ReloadOutcome::Loaded } /// Whether `hook` wants `event`. `item_kind` is `None` when the event @@ -300,8 +505,13 @@ impl WebhookService { .tx .subscribe(); tokio::spawn(async move { - self.reload(&ctx) - .await; + // Local to the task on purpose: `reload` is only ever called from + // here, so this needs no synchronisation. + let mut retry = ReloadRetry::default(); + retry.record( + self.reload(&ctx) + .await, + ); loop { let event = match rx @@ -316,13 +526,19 @@ impl WebhookService { Err(RecvError::Closed) => return, }; - if self - .inner - .dirty - .swap(false, Ordering::AcqRel) + // Only consume the flag when a reload is due: a failed reload + // re-raises it, so without the deadline check that would be one + // failing query per event. + if retry.is_due() + && self + .inner + .dirty + .swap(false, Ordering::AcqRel) { - self.reload(&ctx) - .await; + retry.record( + self.reload(&ctx) + .await, + ); } let cache = self @@ -404,10 +620,11 @@ impl WebhookService { // --- the admin "test this webhook" path -------------------------------------- -/// Whether an operator-supplied template parses, for write-time validation. The -/// error is handlebars' own, derived from the operator's text and nothing else -/// — no remote response, no URL — so it is safe to return over the API. -pub fn validate_template(template: &str) -> Result<(), handlebars::TemplateError> { +/// Whether an operator-supplied template parses **and renders**, for write-time +/// validation. The error is handlebars' own, derived from the operator's text +/// and nothing else — no remote response, no URL — so it is safe to return over +/// the API. +pub fn validate_template(template: &str) -> anyhow::Result<()> { template::validate(template) } @@ -468,6 +685,227 @@ mod tests { use remux_sdks::remux::{DiscordMentionType, WebhookDestination, WebhookItemTypes}; use uuid::Uuid; + // --- reload pacing ----------------------------------------------------- + + #[test] + fn a_fresh_reload_retry_is_due_immediately() { + assert!(ReloadRetry::default().is_due()); + } + + #[test] + fn a_failed_reload_is_not_retried_until_its_deadline() { + let mut retry = ReloadRetry::default(); + retry.record(ReloadOutcome::Failed); + assert!( + !retry.is_due(), + "a failing DB must not be re-queried on the very next event" + ); + assert_eq!(retry.consecutive_failures, 1); + } + + /// The delay has to grow, otherwise a long outage is still one query per + /// event once the first short delay has elapsed. + #[test] + fn consecutive_failures_push_the_deadline_further_out() { + let mut retry = ReloadRetry::default(); + retry.record(ReloadOutcome::Failed); + let first = retry + .not_before + .expect("a failure sets a deadline"); + for _ in 0..4 { + retry.record(ReloadOutcome::Failed); + } + assert!( + retry + .not_before + .expect("still set") + > first, + "the deadline must move out as failures accumulate" + ); + } + + #[test] + fn a_successful_reload_clears_the_pacing() { + let mut retry = ReloadRetry::default(); + retry.record(ReloadOutcome::Failed); + retry.record(ReloadOutcome::Loaded); + assert!(retry.is_due(), "a recovered DB must be readable at once"); + assert_eq!(retry.consecutive_failures, 0); + } + + // --- pacing ------------------------------------------------------------ + + fn test_event() -> WebhookEvent { + WebhookEvent::Generic { + title: "t".into(), + extra: Vec::new(), + } + } + + /// A policy whose waits are short enough to sit in a unit test, with enough + /// slack that the parallel suite cannot flake it. + const FAST_PACING: PacingPolicy = PacingPolicy { + high_water: 2, + max_wait: std::time::Duration::from_millis(500), + poll_interval: std::time::Duration::from_millis(10), + give_up_after: 2, + }; + + fn fast_pacer(service: &WebhookService) -> Pacer { + Pacer::new(service.clone(), FAST_PACING) + } + + /// Fills the channel past the high-water mark and keeps a receiver that + /// never drains, so the backlog only ever grows. + fn wedged(service: &WebhookService) -> broadcast::Receiver> { + let rx = service + .tx + .subscribe(); + for _ in 0..=FAST_PACING.high_water { + service.emit(test_event()); + } + rx + } + + #[tokio::test] + async fn pacing_does_not_wait_while_the_backlog_is_low() { + let service = WebhookService::new(); + let _rx = service + .tx + .subscribe(); + let started = std::time::Instant::now(); + fast_pacer(&service) + .emit(test_event()) + .await; + assert!( + started.elapsed() < FAST_PACING.max_wait, + "an idle dispatcher must not slow emission down" + ); + assert_eq!( + service + .tx + .len(), + 1 + ); + } + + /// The wait is bounded, so a dispatcher that never drains must not stall a + /// scan for good. + #[tokio::test] + async fn pacing_emits_anyway_once_the_wait_runs_out() { + let service = WebhookService::new(); + let _rx = wedged(&service); + + let started = std::time::Instant::now(); + fast_pacer(&service) + .emit(test_event()) + .await; + + assert!( + started.elapsed() >= FAST_PACING.max_wait, + "it must actually have paced" + ); + assert_eq!( + service + .tx + .len(), + FAST_PACING.high_water + 2, + "past the deadline the event goes out anyway rather than hanging" + ); + } + + /// Otherwise a wedged dispatcher would cost `max_wait` per item for the + /// length of a library — worse than the dropped events it replaces. + #[tokio::test] + async fn pacing_gives_up_on_the_rest_of_a_burst_after_repeated_timeouts() { + let service = WebhookService::new(); + let _rx = wedged(&service); + let mut pacer = fast_pacer(&service); + + for _ in 0..FAST_PACING.give_up_after { + pacer + .emit(test_event()) + .await; + } + assert!(pacer.gave_up, "repeated timeouts must latch the give-up"); + + let started = std::time::Instant::now(); + pacer + .emit(test_event()) + .await; + // Half the budget, not the poll interval: a tighter bound would measure + // the test runner's scheduling rather than the pacer. + assert!( + started.elapsed() < FAST_PACING.max_wait / 2, + "a burst that gave up must not pace again" + ); + } + + /// A hiccup early in a scan must not cost the pacing for the rest of it. + #[tokio::test] + async fn pacing_resumes_after_a_stall_clears() { + let service = WebhookService::new(); + let mut rx = wedged(&service); + let mut pacer = fast_pacer(&service); + for _ in 0..FAST_PACING.give_up_after { + pacer + .emit(test_event()) + .await; + } + assert!(pacer.gave_up, "the stall must have latched the give-up"); + + while rx + .try_recv() + .is_ok() + {} + pacer + .emit(test_event()) + .await; + + assert!( + !pacer.gave_up, + "a drained backlog must put the burst back under pacing" + ); + assert_eq!(pacer.consecutive_timeouts, 0); + } + + /// The pacing has to end as soon as the dispatcher drains, not at the + /// deadline — and a drain must clear the give-up counter. + #[tokio::test] + async fn pacing_resumes_as_soon_as_the_backlog_drains() { + let service = WebhookService::new(); + let mut rx = wedged(&service); + + let drainer = tokio::spawn(async move { + tokio::time::sleep(FAST_PACING.poll_interval).await; + while rx + .try_recv() + .is_ok() + {} + rx + }); + + let mut pacer = fast_pacer(&service); + let started = std::time::Instant::now(); + pacer + .emit(test_event()) + .await; + let waited = started.elapsed(); + let _rx = drainer + .await + .expect("the drainer must not panic"); + + assert!( + waited < FAST_PACING.max_wait, + "emission must resume on drain, not wait out the deadline: {waited:?}" + ); + assert!( + !pacer.gave_up, + "a dispatcher that drains must not be given up on" + ); + assert_eq!(pacer.consecutive_timeouts, 0); + } + const NONE_ENABLED: WebhookItemTypes = WebhookItemTypes { movies: false, episodes: false, diff --git a/crates/remux-server/src/services/webhooks/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs index da3e38e1b..d8743ae39 100644 --- a/crates/remux-server/src/services/webhooks/sender.rs +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -18,13 +18,13 @@ use super::throttle::LogThrottle; use crate::db; use remux_sdks::remux::{WebhookDestination, WebhookTestResult}; +use remux_utils::retry::backoff; use reqwest::{ StatusCode, header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, }; -use serde_json::Value; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, LazyLock, Mutex}, time::Duration, }; @@ -90,8 +90,9 @@ static SATURATION_WARNINGS: LazyLock = /// slot for its full retry window and drop deliveries to healthy hooks too. The /// total stays bounded at `enabled hooks × limit`. /// -/// TODO: entries are never removed. `WebhookService::reload` in `mod.rs` knows -/// the live hook set and is the natural place to prune from. +/// Entries are created on first delivery and dropped by [`Self::retain`], which +/// [`super::WebhookService::reload`] calls with the live hook set — otherwise a +/// deleted or disabled hook would keep its entry until restart. pub(crate) struct DeliverySlots { limit: usize, per_hook: Mutex>>, @@ -124,6 +125,24 @@ impl DeliverySlots { .try_acquire_owned() .ok() } + + /// Forget every hook not in `live`, except one with a delivery still in + /// flight: dropping that entry would let the next delivery build a fresh + /// semaphore and exceed the limit. A later pass collects it. + pub(crate) fn retain(&self, live: &HashSet) { + let mut per_hook = self + .per_hook + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + per_hook.retain(|hook_id, semaphore| { + live.contains(hook_id) || semaphore.available_permits() < self.limit + }); + } +} + +/// [`DeliverySlots::retain`] on the process-wide slots. +pub(crate) fn retain_delivery_slots(live: &HashSet) { + DELIVERY_SLOTS.retain(live); } /// Hand a rendered body to the delivery pool. A hook with its share already in @@ -158,8 +177,14 @@ pub(crate) fn spawn_delivery_with( return false; }; tokio::spawn(async move { - // Held for the whole delivery, retries included: the slot is the - // ceiling on work owed to one endpoint, not on one HTTP round-trip. + // Held for the whole delivery, retries and `Retry-After` sleeps + // included: the slot is the ceiling on work owed to one endpoint, not on + // one HTTP round-trip. So a rate-limiting endpoint can pin all its slots + // for ~210s (`attempts × REQUEST_TIMEOUT + (attempts - 1) × + // MAX_RETRY_AFTER`) and have its new events dropped — deliberately: + // releasing the permit around the sleep would only turn "dropped" into + // "tasks parked on a semaphore", and keep pushing at an endpoint that + // just asked us to stop. let _permit = permit; deliver_logged(hook, body, policy).await; }); @@ -476,16 +501,8 @@ pub(crate) fn parse_retry_after(value: &str) -> Option { )) } -/// `base * 2^attempt` plus jitter in `[0, base/2)`, mirroring -/// `remux_utils::retry!`. -fn backoff(base_ms: u64, attempt: u32) -> Duration { - let exponential = base_ms.saturating_mul(1u64 << attempt.min(10)); - let jitter = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|since| since.subsec_nanos() as u64 % (base_ms / 2 + 1)) - .unwrap_or(0); - Duration::from_millis(exponential.saturating_add(jitter)) -} +// The backoff curve is `remux_utils::retry::backoff`, imported at the top: only +// the *decision* to retry is webhook-specific, the delay is not. // --- request shaping -------------------------------------------------------- @@ -557,8 +574,11 @@ pub(crate) fn shape_request(hook: &db::Webhook, rendered: &str) -> ShapedRequest /// The content type to send when the operator has not named one. A deviation /// from the plugin, which sends everything as `text/plain`. +/// +/// Validated, not deserialized: only the parse's success matters, so +/// `IgnoredAny` runs the same parser without building a `Value` tree. pub(crate) fn detect_content_type(body: &str) -> &'static str { - if serde_json::from_str::(body).is_ok() { + if serde_json::from_str::(body).is_ok() { JSON_CONTENT_TYPE } else { TEXT_CONTENT_TYPE @@ -1545,6 +1565,65 @@ mod tests { ); } + #[test] + fn retain_forgets_hooks_that_are_gone() { + let slots = DeliverySlots::new(2); + let live = Uuid::from_u128(1); + let removed = Uuid::from_u128(2); + drop( + slots + .try_acquire(live) + .expect("a fresh hook has slots"), + ); + drop( + slots + .try_acquire(removed) + .expect("a fresh hook has slots"), + ); + + slots.retain(&HashSet::from([live])); + + let per_hook = slots + .per_hook + .lock() + .expect("uncontended"); + assert!(per_hook.contains_key(&live)); + assert!( + !per_hook.contains_key(&removed), + "a hook no longer in the live set must not keep its entry" + ); + } + + /// Evicting an entry whose permit is still out would let the next delivery + /// build a second semaphore and exceed the per-hook limit. + #[test] + fn retain_keeps_a_hook_with_a_delivery_still_in_flight() { + let slots = DeliverySlots::new(1); + let removed = Uuid::from_u128(2); + let permit = slots + .try_acquire(removed) + .expect("a fresh hook has slots"); + + slots.retain(&HashSet::new()); + assert!( + slots + .try_acquire(removed) + .is_none(), + "the in-flight permit must still be counted after a prune" + ); + + drop(permit); + slots.retain(&HashSet::new()); + assert!( + !slots + .per_hook + .lock() + .expect("uncontended") + .contains_key(&removed), + "a later prune collects it once the permit is back" + ); + } + /// The drop branch drops rather than queueing: a saturated hook must /// produce no request at all. #[tokio::test] diff --git a/crates/remux-server/src/services/webhooks/template.rs b/crates/remux-server/src/services/webhooks/template.rs index 76dd7a182..f7f7cc0a0 100644 --- a/crates/remux-server/src/services/webhooks/template.rs +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -85,11 +85,34 @@ pub(crate) fn single_registry( /// operator recognises rather than as an internal id. const VALIDATION_NAME: &str = "webhook template"; -/// Whether an operator-supplied template parses. The error text is derived from -/// the operator's own template — never from a remote response — so it is safe -/// to hand back over the API. -pub(crate) fn validate(template: &str) -> Result<(), handlebars::TemplateError> { - Handlebars::new().register_template_string(VALIDATION_NAME, template) +/// Whether an operator-supplied template is usable. The error text is derived +/// from the operator's own template — never from a remote response — so it is +/// safe to hand back over the API. +/// +/// Two checks, because parsing alone is not enough: handlebars resolves a helper +/// name at *render* time, so `{{url_encod Name}}` parses cleanly and then fails +/// every delivery. Through [`fresh_registry`], without which the render would +/// reject every template that uses one of the custom helpers. +pub(crate) fn validate(template: &str) -> anyhow::Result<()> { + let mut registry = fresh_registry(); + registry.register_template_string(VALIDATION_NAME, template)?; + // Missing variables render empty (`strict_mode` is off) and the helpers treat + // a `Null` param as empty, so only a template broken for *every* event fails. + registry.render(VALIDATION_NAME, &validation_data())?; + Ok(()) +} + +/// The payload the dry run renders against: the same synthetic `Generic` event +/// the admin test button uses, so validation and the test button agree. +fn validation_data() -> Map { + super::payload::build_data( + &super::payload::ServerInfo::default(), + &super::WebhookEvent::Generic { + title: super::TEST_EVENT_TITLE.to_string(), + extra: Vec::new(), + }, + None, + ) } pub(crate) fn register_helpers(registry: &mut Handlebars<'_>) { @@ -726,4 +749,50 @@ mod tests { "a helper called without its parameters must surface as an error" ); } + + // --- validate --------------------------------------------------------- + + /// Pins *why* [`validate`] renders instead of only registering: handlebars + /// looks a helper up at render time, so no registry — helpers or not — + /// rejects an unknown helper at parse time. + #[test] + fn parsing_alone_accepts_an_unknown_helper() { + assert!( + fresh_registry() + .register_template_string(VALIDATION_NAME, "{{url_encod Name}}") + .is_ok(), + "parsing is not helper-aware; validate() must render to catch this" + ); + } + + #[test] + fn validate_rejects_a_helper_typo() { + assert!( + validate("{{url_encod Name}}").is_err(), + "a misspelt helper would drop every delivery, so it must not save" + ); + } + + #[test] + fn validate_rejects_a_syntax_error() { + assert!(validate("{{#if_equals A \"a\"}}unclosed").is_err()); + } + + #[test] + fn validate_accepts_the_shipped_discord_template() { + validate(remux_sdks::remux::DISCORD_TEMPLATE) + .expect("the template we ship must pass our own validation"); + } + + /// The dry run must not reject a template whose variables simply are not in + /// the synthetic payload — most variables are event-specific. + #[test] + fn validate_accepts_variables_absent_from_the_dry_run_payload() { + validate( + "{{SeriesName}} {{url_encode ItemId}} {{json_encode Provider_imdb}}\ + {{#if_exist SeasonNumber}}s{{/if_exist}}\ + {{#if_equals ItemType \"Episode\"}}e{{/if_equals}}", + ) + .expect("event-specific variables must not fail validation"); + } } diff --git a/crates/remux-server/src/tasks/catalog_import_shared.rs b/crates/remux-server/src/tasks/catalog_import_shared.rs index 4c6e44a27..af497fbcc 100644 --- a/crates/remux-server/src/tasks/catalog_import_shared.rs +++ b/crates/remux-server/src/tasks/catalog_import_shared.rs @@ -43,6 +43,11 @@ where None => Uuid::nil(), }; + // One pacer for the whole scan, not one per chunk — see `webhooks::Pacer`. + let mut pacer = ctx + .webhooks + .pacer(); + while let Some(items) = chunks .next() .await @@ -138,13 +143,17 @@ where // are new. A scan can produce tens of thousands of them, so the whole // loop is skipped — at the cost of one atomic load per chunk — when no // webhook subscribes. + // + // Through the pacer, not `emit`: a scan outruns the dispatcher and would + // overflow the event channel. See `webhooks::Pacer`. if ctx .webhooks .wants(NotificationType::ItemAdded) { for item in new_items.iter() { - ctx.webhooks - .emit(WebhookEvent::ItemAdded { item_id: item.id }); + pacer + .emit(WebhookEvent::ItemAdded { item_id: item.id }) + .await; } } From a398f380510cdcb5630877356215dd11cf713e39 Mon Sep 17 00:00:00 2001 From: Mickael Depardon Date: Mon, 10 Aug 2026 10:32:00 +0200 Subject: [PATCH 29/29] fix(db): restore collection child counts so empty collections stay hidden The nested-collection refactor (#205) dropped the two branches that populate child_count for Collection rows (manual membership via media_relations, and the per-collection smart/catalog COUNT). With child_count left as None, exclude_childless retained every collection, so an empty promoted smart collection showed up in /UserViews again. Both doc comments still promised empty smart/catalog collections get dropped, and the /UserViews handler still passes exclude_childless, so this was collateral damage rather than an intended behavior change. Restores both branches and realigns the retain-block comment. Collection stays out of the parent_id child-count list on purpose: in the new model a collection's parent_id children are nested collections, not content, and group containers short-circuit the retain anyway. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HH1WVbqGvyo97ojxuwmjUr --- crates/remux-server/src/db/media.rs | 115 +++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/remux-server/src/db/media.rs b/crates/remux-server/src/db/media.rs index 7cbd2cc67..8df6ea9d6 100644 --- a/crates/remux-server/src/db/media.rs +++ b/crates/remux-server/src/db/media.rs @@ -4203,6 +4203,116 @@ impl Media { } } + // For manual collections: count members via media_relations (role='collection'). + // The parent_id branch above never covers these — they store membership in + // media_relations, and Collection is not in the parent_id kind list. + let manual_coll_ids: Vec = records + .iter() + .filter(|m| { + m.kind == MediaKind::Collection + && m.collection_kind == Some(CollectionKind::Manual) + }) + .map(|m| m.id) + .collect(); + if !manual_coll_ids.is_empty() { + let mut mc_qb = sqlx::QueryBuilder::new( + "SELECT left_media_id, COUNT(*) FROM media_relations \ + WHERE role = 'collection' AND left_media_id IN (", + ); + let mut sep = mc_qb.separated(", "); + for id in &manual_coll_ids { + sep.push_bind(id); + } + if let Some(pf) = child_policy_filter { + mc_qb.push( + ") AND right_media_id IN (SELECT id FROM media WHERE 1=1", + ); + apply_filter_rules(&mut mc_qb, pf); + mc_qb.push(")"); + } else { + mc_qb.push(")"); + } + mc_qb.push(" GROUP BY left_media_id"); + match mc_qb + .build() + .fetch_all(db) + .await + { + Ok(rows) => { + let mut cc_map: HashMap = HashMap::new(); + for row in rows { + cc_map.insert(row.get(0), row.get(1)); + } + for media in &mut records { + if manual_coll_ids.contains(&media.id) { + media.child_count = Some( + *cc_map + .get(&media.id) + .unwrap_or(&0), + ); + } + } + } + Err(e) => { + warn!("failed to load manual collection child counts: {e}") + } + } + } + + // For smart/catalog collections: run each collection's filter rules to + // get the true item count. This also powers exclude_childless filtering. + for media in records + .iter_mut() + .filter(|m| { + m.kind == MediaKind::Collection + && matches!( + m.collection_kind, + Some(CollectionKind::Smart) | Some(CollectionKind::Catalog) + ) + }) + { + let kinds: Option> = media + .collection_media_kind + .as_ref() + .map(|k| match k { + CollectionMediaKind::Movie => vec!["movie"], + CollectionMediaKind::Series => vec!["series"], + CollectionMediaKind::Mixed => vec!["movie", "series"], + CollectionMediaKind::Music => vec!["track", "album", "artist"], + CollectionMediaKind::Playlist => vec!["playlist"], + CollectionMediaKind::Collection => vec!["collection"], + }); + let mut qb = + sqlx::QueryBuilder::new("SELECT COUNT(*) FROM media WHERE 1=1"); + if let Some(ks) = &kinds { + if !ks.is_empty() { + qb.push(" AND kind IN ("); + let mut sep = qb.separated(", "); + for k in ks { + sep.push_bind(*k); + } + qb.push(")"); + } + } + if let Some(sf) = media.parse_smart_filter() { + apply_filter_rules(&mut qb, sf); + } + if let Some(pf) = child_policy_filter { + apply_filter_rules(&mut qb, pf); + } + match qb + .build_query_scalar() + .fetch_one(db) + .await + { + Ok(cnt) => media.child_count = Some(cnt), + Err(e) => warn!( + "failed to load child count for collection {}: {e}", + media.id + ), + } + } + // For series: populate recursive_item_count with total episode count let series_ids: Vec = records .iter() @@ -4483,8 +4593,9 @@ impl Media { } } - // Drop empty containers when requested. child_count is already populated - // for all container kinds (including smart/catalog) by the branches above. + // Drop empty containers when requested. child_count is populated above: + // Folder/Playlist by the parent_id and media_relations branches, Collection + // by the manual and smart/catalog branches. // Structural "collection of collections" containers always show. let sql_total = count?;