diff --git a/Cargo.lock b/Cargo.lock index d9251ff27..1f7c53bd5 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" @@ -5257,6 +5273,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" @@ -6576,6 +6607,7 @@ dependencies = [ "remux-sdks", "serde", "serde_json", + "strum", "urlencoding", "uuid", "web-sys", @@ -6676,6 +6708,7 @@ dependencies = [ "flate2", "futures", "futures-util", + "handlebars", "headers", "http 1.4.0", "http-body 1.0.1", 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/layout.rs b/crates/remux-dashboard/src/layout.rs index d344ebd93..a71af0681 100644 --- a/crates/remux-dashboard/src/layout.rs +++ b/crates/remux-dashboard/src/layout.rs @@ -110,6 +110,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 cacdfc8a2..42f3d410b 100644 --- a/crates/remux-dashboard/src/pages/mod.rs +++ b/crates/remux-dashboard/src/pages/mod.rs @@ -8,6 +8,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; @@ -22,3 +23,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..f08a2ed78 --- /dev/null +++ b/crates/remux-dashboard/src/pages/webhooks.rs @@ -0,0 +1,1596 @@ +//! 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}`, 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 +//! 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, DISCORD_TEMPLATE, +}; +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`), +/// 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 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. +/// +/// 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`]. +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_string()) +} + +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", + } +} + +/// 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", + 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() + .filter(|t| selected.contains(t)) + .collect() +} + +/// The message an operator sees when a mutation fails. `ClientError`'s +/// `Display` also carries the status and endpoint; `user_message()` is the half +/// the rest of the dashboard shows. +fn action_failure(action: &str, error: &ClientError) -> 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 { + 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), + // 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, + } + } 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. +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); + + // 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); + 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(action_failure("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." + } + // 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() { + 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(); + action_error.set(None); + spawn(async move { + 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, 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; + 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 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), + Err(e) => TestState::Failed(format!( + "Could not run the test: {}", + e.user_message() + )), + }; + 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); + // 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. + action_error.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); + action_error.set(None); + let cc = c.clone(); + spawn(async move { + match cc.execute(DeleteWebhook { id }).await { + 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); + } + Err(e) => action_error.set(Some(action_failure("delete webhook", &e))), + } + 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, so every handler is 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); + // 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(); + 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." + } + // {{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." + } + 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(), + } + } + 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 { + 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(action_failure("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::*; + use remux_sdks::EnumCount; + + 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() + .map(|t| t.to_string()) + .collect(); + for (label, expected) in labels + .iter() + .zip(notification_types()) + { + assert_eq!( + NotificationType::from_str(label).ok(), + Some(expected), + "label {label} did not parse back" + ); + } + labels.sort(); + labels.dedup(); + assert_eq!( + labels.len(), + NotificationType::COUNT, + "the list must have no duplicates" + ); + } + + /// `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().count(), + NotificationType::COUNT, + "EnumIter must yield every NotificationType variant" + ); + } + + #[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 ---------------------------------------------------- + + #[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:?}"), + } + } + + /// 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(); + 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 — a result, not an API error. + #[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}"); + } + + // -- mutation failures -------------------------------------------------- + + /// 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 { + 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 { + 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 657e1a50b..fcf63813e 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")] @@ -136,6 +138,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::(); diff --git a/crates/remux-sdks/src/lib.rs b/crates/remux-sdks/src/lib.rs index baaf3ce30..327dc2eee 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; diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 7f7c15bdf..5c9fe8a8d 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -6382,6 +6382,316 @@ 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, + 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, + 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, +} + +/// 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*. 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. +/// +/// 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 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 +/// 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 { + 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::*; @@ -6843,4 +7153,196 @@ 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); + } + + 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_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); + } } diff --git a/crates/remux-server/Cargo.toml b/crates/remux-server/Cargo.toml index ba290645e..1f3d7b165 100644 --- a/crates/remux-server/Cargo.toml +++ b/crates/remux-server/Cargo.toml @@ -132,6 +132,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" +# `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/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/api/items.rs b/crates/remux-server/src/api/items.rs index 0c57285a9..5f1c779d6 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}; @@ -936,6 +937,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 @@ -947,6 +970,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/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/session.rs b/crates/remux-server/src/api/session.rs index c16f29e68..bc1893e77 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,7 +278,7 @@ pub async fn report_playback_stopped( .map(|s| s.play_session_id) }); if let Some(ref psid) = effective_psid { - state + let recorded = state .ctx .sessions .stopped( @@ -196,6 +295,44 @@ pub async fn report_playback_stopped( .ctx .ws_tx .send(crate::ws::WsEvent::SessionsChanged); + + // 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 + .webhooks + .wants(NotificationType::PlaybackStop) + { + state + .ctx + .webhooks + .emit(WebhookEvent::PlaybackStop { + playback: playback_event( + &session, + &data, + recorded.item_id, + recorded.position_ticks, + ), + }); + } + if state + .ctx + .webhooks + .wants(NotificationType::UserDataSaved) + { + state + .ctx + .webhooks + .emit(WebhookEvent::UserDataSaved { + user: (&session.user).into(), + item_id: recorded.item_id, + save_reason: UserDataSaveReason::PlaybackFinished, + }); + } + } } Ok(StatusCode::NO_CONTENT.into_response()) } @@ -783,6 +920,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 +950,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 3e6e2672c..dd5c92fb5 100644 --- a/crates/remux-server/src/api/system.rs +++ b/crates/remux-server/src/api/system.rs @@ -243,6 +243,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 689a6961b..5a25cc4a2 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, get_items, 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,43 @@ 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)` 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 its rate is the attacker's. + if authenticated.is_none() + && state + .ctx + .webhooks + .wants(NotificationType::AuthenticationFailure) + { + 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 +373,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 +394,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 @@ -360,6 +449,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 @@ -445,6 +541,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()) } @@ -465,6 +567,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()) } @@ -485,6 +593,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()) } @@ -505,6 +619,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()) } @@ -534,6 +654,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()) } @@ -556,6 +677,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()) } @@ -710,6 +832,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( @@ -737,6 +865,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 @@ -748,6 +897,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()) } @@ -849,6 +1004,12 @@ pub async fn change_password( .ctx .ws_tx .send(WsEvent::SessionsChanged); + state + .ctx + .webhooks + .emit(WebhookEvent::UserPasswordChanged { + user: (&user).into(), + }); Ok(StatusCode::NO_CONTENT.into_response()) } @@ -879,6 +1040,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()) } @@ -915,6 +1082,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 new file mode 100644 index 000000000..acea76ee7 --- /dev/null +++ b/crates/remux-server/src/api/webhooks.rs @@ -0,0 +1,1650 @@ +//! Admin CRUD over outgoing webhooks, plus the synchronous "test this webhook" +//! endpoint the dashboard uses for immediate feedback. +//! +//! **Every route is admin-only.** A webhook URL is a credential — Discord's is +//! `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 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: 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, not the operator's raw string. + 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)) + } + } +} + +/// `payload` with its template proved to parse *and* render — or a 400 carrying +/// handlebars' own message. +/// +/// 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. +fn with_checked_template(payload: WebhookDto) -> Result { + match webhooks::validate_template(&payload.template) { + Ok(()) => Ok(payload), + Err(e) => { + let detail = format!("webhook template is not usable: {e}"); + Err(e.context_bad_request(&detail)) + } + } +} + +/// 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 + .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_checked_template(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_checked_template(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. +#[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::{ + DiscordMentionType, NotificationType, WebhookDestination, WebhookItemTypes, + WebhookKeyValue, + }; + use serde_json::json; + use std::time::{Duration, Instant}; + + /// 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) { + ( + http::header::AUTHORIZATION, + HeaderValue::from_str(&auth_header_with_token(token)).unwrap(), + ) + } + + /// `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 canary only proves the event was *dispatched*. + 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. + #[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); + + 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 --------------------------------------------------- + + #[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" + ); + } + + // --- template validation ---------------------------------------------- + + /// 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; + 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 has to be acceptable here. + #[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] + 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, 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 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; + 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); + } + + /// 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; + 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(leak); + }) + .await; + + let created = create( + &server, + &h, + &v, + &hook_dto("under test", &endpoint_server.url("/hook")), + ) + .await; + + let response = server + .post(&format!("/remux/webhooks/{}/test", created.id)) + .add_header(h, v) + .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; + } + + /// 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; + 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}" + ); + } + + // --- enrichment failures ---------------------------------------------- + + /// 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 + /// `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], + // This hook wants no item type at all. + 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; + + 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, + /// 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, 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; + 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" + ); + } + + // --- the emission sites ----------------------------------------------- + // + // 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. + + /// 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}}}}"}}"# + ) + } + + fn echoed(content: &str, notification_type: NotificationType) -> String { + format!(r#"{{"content":"{content}","type":"{notification_type}"}}"#) + } + + 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); + } + + 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") + } + + #[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; + + report_playback_start(&server, &h, &v, media.id).await; + + eventually("the playback start to reach the webhook", async || { + hits(&endpoint).await == 1 + }) + .await; + } + + /// 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; + 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" + ); + } + + /// 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; + 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; + + 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" + ); + } + + /// 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; + 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; + } + + /// 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; + 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 + }; + + 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. + 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" + ); + } + + // --- 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, 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. + #[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") + // "test" is the user `authenticated_server` seeds and logs in. + .body(echoed("test", 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("NotificationUsername"), + ..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 ------------------------------------- + + /// 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 + /// 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 -------------------------------- + + /// 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. + 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; + } +} diff --git a/crates/remux-server/src/db/auth.rs b/crates/remux-server/src/db/auth.rs index da6672b27..784791b55 100644 --- a/crates/remux-server/src/db/auth.rs +++ b/crates/remux-server/src/db/auth.rs @@ -373,6 +373,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, @@ -400,31 +431,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/db/media.rs b/crates/remux-server/src/db/media.rs index a320d0655..dea2fb495 100644 --- a/crates/remux-server/src/db/media.rs +++ b/crates/remux-server/src/db/media.rs @@ -2739,6 +2739,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, @@ -4176,6 +4191,62 @@ 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}") + } + } + } + // Batch child counts for smart/catalog collections in a single UNION ALL query // instead of one COUNT per collection (N+1). Collect the needed data first so // the immutable borrow on records is released before we write back. @@ -4551,8 +4622,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?; @@ -7587,6 +7659,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/db/mod.rs b/crates/remux-server/src/db/mod.rs index 9570b0837..c65412934 100644 --- a/crates/remux-server/src/db/mod.rs +++ b/crates/remux-server/src/db/mod.rs @@ -19,6 +19,7 @@ pub mod settings; pub mod stream_group; pub mod task; pub mod user; +pub mod webhook; pub use activity::*; pub use api_key::*; pub use image::*; @@ -28,6 +29,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..2b779c29e --- /dev/null +++ b/crates/remux-server/src/db/webhook.rs @@ -0,0 +1,672 @@ +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() + }, + // 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. 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, + created_at: None, + updated_at: None, + } + } + + /// `(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; + 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); + assert!(fetched.send_all_properties); + assert!(!fetched.trim_whitespace); + assert!(fetched.skip_empty_message_body); + } + + #[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() + }, + // 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: true, + 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); + assert!(!fetched.send_all_properties); + assert!(fetched.trim_whitespace); + assert!(!fetched.skip_empty_message_body); + } + + #[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); + } +} 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 diff --git a/crates/remux-server/src/lib.rs b/crates/remux-server/src/lib.rs index 2fc6cdd36..d590af45f 100644 --- a/crates/remux-server/src/lib.rs +++ b/crates/remux-server/src/lib.rs @@ -299,6 +299,7 @@ pub async fn init_app( )), web_paths, addons, + webhooks: services::webhooks::WebhookService::new(), started_at: Utc::now(), }; @@ -314,8 +315,16 @@ 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. + ctx.webhooks + .clone() + .spawn_dispatcher(ctx.clone()); + db::StreamGroup::migrate_from_settings(&conn).await; let task_service = tasks::TaskService::new(ctx.clone()).await?; @@ -386,6 +395,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, } @@ -475,6 +485,19 @@ 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. + /// + /// The config layer uses no env prefix, so this field's environment + /// 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, #[serde(default = "default_activity_log_retention_days")] pub activity_log_retention_days: u32, #[serde(default = "default_jellyfin_version")] @@ -561,6 +584,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, activity_log_retention_days: default_activity_log_retention_days(), jellyfin_version: default_jellyfin_version(), } diff --git a/crates/remux-server/src/playback_session.rs b/crates/remux-server/src/playback_session.rs index d59b1fa97..85f2a2b14 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 { @@ -37,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>, @@ -420,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; @@ -437,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 @@ -754,10 +783,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 +816,83 @@ 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, + // 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 + .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/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..a46b26b0c --- /dev/null +++ b/crates/remux-server/src/services/webhooks/events.rs @@ -0,0 +1,475 @@ +//! 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, PlayMethod}; +use uuid::Uuid; + +/// The user a webhook event is attributed to. +#[derive(Debug, Clone)] +pub struct UserEventData { + pub id: Uuid, + 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 { + pub id: String, + pub name: String, + pub client_name: String, + 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 { + 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(PlayMethod::DirectStream), + } + } + + 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..8d98512a2 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/mod.rs @@ -0,0 +1,1480 @@ +//! 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 and +//! keeps a cached snapshot of the enabled webhooks. + +pub mod events; +mod payload; +mod sender; +mod template; +mod throttle; + +pub use events::{ + DeviceEventData, PlaybackEventData, UserDataSaveReason, UserEventData, WebhookEvent, +}; + +use crate::{AppContext, db}; +use remux_sdks::remux::{NotificationType, WebhookTestResult}; +use std::{ + collections::HashSet, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; +use tokio::{ + sync::{RwLock, broadcast, broadcast::error::RecvError}, + task::JoinHandle, +}; +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. +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"; + +/// 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 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. + 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 +/// 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(), + server: payload::ServerInfo::default(), + } + } +} + +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. `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) +} + +/// 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" +); + +/// 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>, + 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()), + // 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 dropped. + pub fn emit(&self, event: WebhookEvent) { + let _ = self + .tx + .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 + /// 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. + /// + /// `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; + }; + self.inner + .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 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. + 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 + /// hook set is kept and the cache is marked stale again — a transient DB + /// 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`]: 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 + /// 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) -> 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; + + 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; + // Ask for another attempt: the flag was consumed before the + // call, so nothing else will set it. The caller decides *when* + // that attempt happens — see [`ReloadOutcome`]. + self.inner + .dirty + .store(true, Ordering::Release); + 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| { + hook.notification_types + .iter() + .copied() + }) + .collect(); + let mask = wanted + .iter() + .filter_map(|t| wanted_bit(*t)) + .fold(0u32, |mask, bit| mask | bit); + let mut cache = self + .inner + .cache + .write() + .await; + *cache = LoadedWebhooks { + registry: template::build_registry(&hooks), + hooks, + wanted, + server, + }; + // 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 + .load(Ordering::Acquire) + { + self.inner + .wanted_mask + .store(mask, Ordering::Relaxed); + } + ReloadOutcome::Loaded + } + + /// 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, mirroring the plugin. + 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 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 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 + .subscribe(); + tokio::spawn(async move { + // 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 + .recv() + .await + { + Ok(event) => event, + Err(RecvError::Lagged(dropped)) => { + warn!(dropped, "webhook dispatcher lagged"); + continue; + } + Err(RecvError::Closed) => return, + }; + + // 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) + { + retry.record( + self.reload(&ctx) + .await, + ); + } + + let cache = self + .inner + .cache + .read() + .await; + if !cache + .wanted + .contains(&event.notification_type()) + { + continue; + } + + let item = payload::enrich_item(&ctx, &event).await; + // 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() + { + // `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" + ); + continue; + } + 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; + } + + // 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) { + // 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) => {} + Err(e) => { + 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" + ); + } + } + } + } + } + }) + } +} + +// --- the admin "test this webhook" path -------------------------------------- + +/// 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) +} + +/// Render `hook`'s body for the synthetic test event. +/// +/// The template is compiled here rather than taken from the dispatcher's cached +/// registry, which only reloads when the dispatcher next sees an event — the +/// hook being tested was very likely saved a moment ago. +/// +/// 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, +) -> 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::single_registry(hook)?; + template::render(hook, ®istry, &data) +} + +/// Deliver one synthetic `Generic` event to `hook` and report what happened. +/// +/// 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. + 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::*; + use chrono::Utc; + 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, + 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, + }, + } + } + + // --- 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(), + } + } + + #[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"}"# + ); + } + + /// 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 { + 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!( + error.contains("{{#if_equals A}}unclosed"), + "the parse error must quote the operator's own template: {error}" + ); + } + + #[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}" + ); + } + + #[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 + /// carry the custom helpers. + #[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"); + } + + /// 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() + .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" + ); + } + + /// 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() + .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 ------------------------------------------------ + + /// 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 = [ + 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" + ); + } + + /// 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(); + 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" + ); + } + + /// `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() + .await + .expect("test server"); + let service = WebhookService::new(); + + // The database holds no webhooks, so this reload computes 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, 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, + } + } + + /// 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() { + 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..45607e694 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/payload.rs @@ -0,0 +1,1853 @@ +//! The variable dictionary handed to webhook templates. +//! +//! 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::{DeviceEventData, PlaybackEventData, UserEventData, WebhookEvent}; +use crate::{AppContext, db}; +use remux_sdks::remux::{ + DiscordMentionType, 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"; + +/// 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`. +#[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, +} + +/// 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. +#[derive(Debug, Clone, Default)] +pub(crate) struct ServerInfo { + pub id: String, + pub name: String, + pub version: String, + /// `Config::public_url`, or empty when the operator has not set one. + 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: ctx + .config + .public_url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .unwrap_or_default() + .to_string(), + } + } +} + +/// 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, +) -> Option { + 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 + } + } +} + +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( + server: &ServerInfo, + event: &WebhookEvent, + 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: 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. +/// `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>( + data: &'a Map, + hook: &db::Webhook, +) -> Cow<'a, Map> { + match &hook.destination { + WebhookDestination::Generic { fields, .. } => { + if fields.is_empty() { + return Cow::Borrowed(data); + } + let mut merged = data.clone(); + for field in fields { + // `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 + .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`) and a username lands under both + // `Username` and `BotUsername`, present only when configured. + 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()), + ); + // 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. 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( + 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())); + } + 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) + } + } +} + +/// 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 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 +/// 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 ----------------------------------------------------------------- + +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()); + + // 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 { + put( + data, + "PremiereDate", + released_at + .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::Season => put_season(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); + } +} + +/// `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 + { + 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() + { + 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, + ); +} + +/// 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) { + 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( + "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( + value + .as_ref() + .to_string(), + ), + ); +} + +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, + } + } + + // --- 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"); + } + + #[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_fall_back_to_zero() { + let item = ItemContext { + media: db::Media { + runtime: None, + ..movie().media + }, + ..movie() + }; + let data = build_data(&server(), &item_added(), Some(&item)); + assert_eq!(data["RunTimeTicks"], Value::from(0)); + assert_eq!(str_at(&data, "RunTime"), "00:00:00"); + } + + // --- 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 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"); + } + + #[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 { + 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 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"); + } + + /// The threshold is 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"); + assert_eq!(str_at(&merged, "Name"), "The One With The Test"); + // The overlay does not mutate the shared dictionary. + 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())); + 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`. + #[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, or + /// `{{#if_exist AvatarUrl}}` flips. + #[test] + 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"] { + 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, 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)); + } + + /// 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. + #[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" + ); + } + } + + #[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. + #[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"; + + 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() + }; + // 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), + 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: 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() + .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); + + 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` 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() + .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) + ); + } + + #[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/sender.rs b/crates/remux-server/src/services/webhooks/sender.rs new file mode 100644 index 000000000..d8743ae39 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/sender.rs @@ -0,0 +1,1761 @@ +//! HTTP delivery of a rendered webhook body. +//! +//! 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, as in the Jellyfin webhook plugin. +//! +//! Delivery is fire-and-forget: [`spawn_delivery`] never blocks its caller and +//! every error is logged and swallowed. +//! +//! **A webhook URL is a credential.** Discord's is +//! `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; +use remux_sdks::remux::{WebhookDestination, WebhookTestResult}; +use remux_utils::retry::backoff; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, +}; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, LazyLock, Mutex}, + time::Duration, +}; +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 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. +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. +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. +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, so the connection pool survives. +/// +/// 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") + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build the webhook HTTP client") +}); + +static DELIVERY_SLOTS: LazyLock = + LazyLock::new(|| DeliverySlots::new(MAX_CONCURRENT_DELIVERIES_PER_HOOK)); + +/// How often a hook may repeat its "dropping event" line. +/// +/// 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 = + LazyLock::new(|| LogThrottle::new(SATURATION_WARN_WINDOW)); + +// --- concurrency ------------------------------------------------------------ + +/// Delivery slots, counted **per hook**. +/// +/// 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`. +/// +/// 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>>, +} + +impl DeliverySlots { + pub(crate) fn new(limit: usize) -> Self { + Self { + 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: + // a panic elsewhere must not disable webhooks for 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() + } + + /// 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 +/// 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. +/// +/// 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. +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 { + // 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, + 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 { + // 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; + }); + 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) { + 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. +/// +/// Only *transient* failures are retried. Hand-rolled rather than built on +/// `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, + policy: &DeliveryPolicy, +) -> anyhow::Result<()> { + 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(()); + } + 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) +} + +/// One POST, reported as the admin API's "test this webhook" result. +/// +/// 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 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 +} + +/// [`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 { + 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. + 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 part of the remote body. + SendError { + status: Some(status), + .. + } => WebhookTestResult { + success: false, + status_code: Some(status.as_u16()), + error: Some(format!("endpoint returned {status}")), + }, + // Nothing reached the endpoint, so the message is ours to give. + e => WebhookTestResult { + success: false, + 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. 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, + timeout: Option, +) -> Result { + let shaped = shape_request(hook, body); + let mut request = WEBHOOK_CLIENT + .post(&hook.url) + .header(CONTENT_TYPE, shaped.content_type); + 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() + .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, + status: 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() { + return Ok(response); + } + let retryability = classify_status(status); + let retry_after = retry_after(status, response.headers()); + let detail = response + .text() + .await + .unwrap_or_default(); + Err(SendError { + retryability, + retry_after, + status: Some(status), + 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, + /// The status the endpoint answered with, or `None` when the request never + /// got that far. Reported by [`send_test`]. + pub status: 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) + } +} + +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, for a rate limit only. +/// +/// `Retry-After` first, then Discord's `X-RateLimit-Reset-After`. Restricted to +/// 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; + } + ["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. +/// 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. +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.as_secs_f64()), + )) +} + +// 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 -------------------------------------------------------- + +/// 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 { + // `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 = + 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)), + // 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") + } + } + } + ShapedRequest { + body: rendered.to_string(), + content_type, + headers: extra, + } + } + // 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. + WebhookDestination::Discord { .. } => ShapedRequest { + body: rendered.to_string(), + content_type: HeaderValue::from_static(JSON_CONTENT_TYPE), + headers: Vec::new(), + }, + } +} + +/// 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() { + JSON_CONTENT_TYPE + } else { + TEXT_CONTENT_TYPE + } +} + +// --- redaction -------------------------------------------------------------- + +/// 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() { + 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 { + 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 std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Instant, + }; + + 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() + } + + /// 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) { + 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); + 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 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"; + 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" + ); + assert_eq!(redact_url("not a url"), ""); + } + + /// 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", &[]); + 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] + 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"]); + } + + #[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 ------------------------------------------- + + /// Parity with the plugin's `DiscordClient`: the template renders the whole + /// 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"}]}"#; + let hook = + discord_hook("https://example.test/hook", DiscordMentionType::Everyone); + let shaped = shape_request(&hook, rendered); + assert_eq!(shaped.body, rendered, "the body must not be rewrapped"); + assert!( + content_type(&hook, rendered).starts_with("application/json"), + "Discord always takes JSON, whatever the body looks like" + ); + assert!( + shaped + .headers + .is_empty(), + "the plugin sends no custom headers to Discord" + ); + + 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") + ); + } + + // --- the actual wire request ------------------------------------------ + + /// 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; + 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; + } + + #[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; + } + + #[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 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); + 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)); + } + + /// 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 [ + "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. + #[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] + 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() + ); + } + + // --- 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. 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; + } + + /// 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() + } + } + + /// 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; + 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; + 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); + } + + /// 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; + 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 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; + 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)); + } + + #[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] + 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" + ); + } + + /// 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] { + 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" + ); + } + } + + #[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() + ); + } + + /// 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; + 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 + ); + 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 (url, calls) = sequenced_endpoint(&[500, 500, 200]).await; + + let hook = generic(&url, &[]); + deliver_with(&hook, "ping", &FAST) + .await + .expect("the third attempt succeeded, so the delivery must succeed"); + + assert_eq!( + calls.load(Ordering::SeqCst), + 3, + "the retry must stop at the first success, not spend the budget" + ); + } + + #[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" + ); + } + + /// 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"); + 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() + ); + } + + // --- delivery slots ---------------------------------------------------- + + /// 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" + ); + } + + #[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] + 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" + ); + 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" + ); + + 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" + ); + } + + /// [`LogThrottle`] is unit-tested in its own module; this proves it is + /// 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); + // 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 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; + 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" + ); + + eventually("the slot to come back", async || { + slots + .try_acquire(hook.id) + .is_some() + }) + .await; + } +} 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..f7f7cc0a0 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/template.rs @@ -0,0 +1,798 @@ +//! Handlebars rendering for webhook bodies. +//! +//! 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::{ + 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: `{{Var}}` almost always sits inside a JSON + // 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 +} + +/// 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 +/// 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 +} + +/// 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 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> { + let mut registry = fresh_registry(); + registry.register_template_string( + &hook + .id + .to_string(), + &hook.template, + )?; + Ok(registry) +} + +/// 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 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<'_>) { + 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)); +} + +/// 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> { + let data = super::payload::with_hook_fields(data, hook); + + let body = if hook.send_all_properties { + // The plugin's "show me every variable" mode: template ignored. + 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`. +/// +/// 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, + _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 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, + _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 = 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(()) +} + +/// 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 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_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] + 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" + ); + } + + /// 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!( + 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" + ); + + 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: `'` 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!( + 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" + ); + + 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. + #[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" + ); + } + + // --- 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 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"#; + 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 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( + 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] + 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 -------------------------------------------------- + + #[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, "12aaa"); + } + + #[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" + ); + } + + // --- 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/services/webhooks/throttle.rs b/crates/remux-server/src/services/webhooks/throttle.rs new file mode 100644 index 000000000..cb6f8a876 --- /dev/null +++ b/crates/remux-server/src/services/webhooks/throttle.rs @@ -0,0 +1,132 @@ +//! Rate-limiting for the two per-event webhook warnings. +//! +//! 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, + 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. +/// Entries are not pruned, at tens of bytes per hook 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) + } + + #[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. + #[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 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)); + 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)); + } +} diff --git a/crates/remux-server/src/tasks/catalog_import_shared.rs b/crates/remux-server/src/tasks/catalog_import_shared.rs index ac9e67036..76c6a9160 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. /// @@ -40,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 @@ -144,6 +152,24 @@ 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. + // + // 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() { + pacer + .emit(WebhookEvent::ItemAdded { item_id: item.id }) + .await; + } + } + 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, diff --git a/crates/remux-utils/src/lib.rs b/crates/remux-utils/src/lib.rs index 2382e0a5d..fc25f92ac 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; } }