diff --git a/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs b/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs index 9e6510d..57fa17c 100644 --- a/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs +++ b/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs @@ -12,6 +12,26 @@ namespace Jellyfin.Plugin.Webhook.Destinations; /// public abstract class BaseOption { + /// + /// The data fields that have historically been manually escaped in . + /// More fields may be added as needed. + /// + private static readonly string[] FieldsToEscape = + [ + "ServerName", + "Name", + "Overview", + "Tagline", + "ItemType", + "SeriesName", + "NotificationUsername", + "Client", + "DeviceName", + "PluginName", + "PluginChangelog", + "ExceptionMessage" + ]; + private HandlebarsTemplate? _compiledTemplate; /// @@ -97,6 +117,11 @@ public abstract class BaseOption /// public Guid[] UserFilter { get; set; } = Array.Empty(); + /// + /// Gets or sets the Media-Content type of the webhook body. Should not be set by User except for Generic Client. + /// + public WebhookMediaContentType MediaContentType { get; set; } = WebhookMediaContentType.Json; + /// /// Gets the compiled handlebars template. /// @@ -113,10 +138,45 @@ public HandlebarsTemplate GetCompiledTemplate() /// The string message body. public string GetMessageBody(Dictionary data) { + Dictionary cloneData = new(data); // Quickly clone the dictionary to avoid "over escaping" if we use the same data object for multiple services in a row. + foreach (var field in fieldsToEscape) + { + if (cloneData.TryGetValue(field, out object? value)) + { + cloneData[field] = MediaContentType switch + { + WebhookMediaContentType.Json => JsonEncode(value.ToString() ?? string.Empty), + WebhookMediaContentType.PlainText => DefaultEscape(value.ToString() ?? string.Empty), + WebhookMediaContentType.Xml => DefaultEscape(value.ToString() ?? string.Empty), + _ => DefaultEscape(value.ToString() ?? string.Empty) + }; + } + } + var body = SendAllProperties - ? JsonSerializer.Serialize(data, JsonDefaults.Options) - : GetCompiledTemplate()(data); + ? JsonSerializer.Serialize(cloneData, JsonDefaults.Options) + : GetCompiledTemplate()(cloneData); return TrimWhitespace ? body.Trim() : body; } + + /// + /// Escape a text string using the default Json Encoder. + /// + /// A plain-text string that needs escaped. + /// The escaped string. + private static string JsonEncode(string value) + { + return JsonEncodedText.Encode(value).Value; + } + + /// + /// Escapes all backslashes in a string. This is the previous escape method used for all strings. + /// + /// A plain-text string that needs escaped. + /// The escaped string. + private static string DefaultEscape(string value) + { + return value.Replace("\"", "\\\"", StringComparison.Ordinal); + } } diff --git a/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericClient.cs b/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericClient.cs index a3af9c0..de3cf9e 100644 --- a/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericClient.cs +++ b/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericClient.cs @@ -52,13 +52,6 @@ public async Task SendAsync(GenericOption option, Dictionary dat data[field.Key] = field.Value; } - var body = option.GetMessageBody(data); - if (!SendMessageBody(_logger, option, body)) - { - return; - } - - _logger.LogDebug("SendAsync Body: {@Body}", body); using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, option.WebhookUri); var contentType = MediaTypeNames.Text.Plain; foreach (var header in option.Headers) @@ -73,6 +66,16 @@ public async Task SendAsync(GenericOption option, Dictionary dat && !string.IsNullOrEmpty(header.Value)) { contentType = header.Value; + + // Set the MediaType for the text escape/encode. + if (string.Equals(MediaTypeNames.Application.Json, header.Value, StringComparison.OrdinalIgnoreCase)) + { + option.MediaContentType = WebhookMediaContentType.Json; + } + else if (string.Equals(MediaTypeNames.Application.Xml, header.Value, StringComparison.OrdinalIgnoreCase) || string.Equals(MediaTypeNames.Text.Xml, header.Value, StringComparison.OrdinalIgnoreCase)) + { + option.MediaContentType = WebhookMediaContentType.Xml; + } } else { @@ -80,6 +83,14 @@ public async Task SendAsync(GenericOption option, Dictionary dat } } + var body = option.GetMessageBody(data); + if (!SendMessageBody(_logger, option, body)) + { + return; + } + + _logger.LogDebug("SendAsync Body: {@Body}", body); + httpRequestMessage.Content = new StringContent(body, Encoding.UTF8, contentType); using var response = await _httpClientFactory .CreateClient(NamedClient.Default) diff --git a/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericOption.cs b/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericOption.cs index acac3ef..67d75cb 100644 --- a/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericOption.cs +++ b/Jellyfin.Plugin.Webhook/Destinations/Generic/GenericOption.cs @@ -14,6 +14,7 @@ public GenericOption() { Headers = Array.Empty(); Fields = Array.Empty(); + MediaContentType = WebhookMediaContentType.PlainText; } /// diff --git a/Jellyfin.Plugin.Webhook/Destinations/GenericForm/GenericFormOption.cs b/Jellyfin.Plugin.Webhook/Destinations/GenericForm/GenericFormOption.cs index cf74098..b4518ac 100644 --- a/Jellyfin.Plugin.Webhook/Destinations/GenericForm/GenericFormOption.cs +++ b/Jellyfin.Plugin.Webhook/Destinations/GenericForm/GenericFormOption.cs @@ -14,6 +14,7 @@ public GenericFormOption() { Headers = Array.Empty(); Fields = Array.Empty(); + // The MediaType is left as JSON because that is what is assumed to be used in GenericFormClient.cs } /// diff --git a/Jellyfin.Plugin.Webhook/Destinations/WebhookMediaContentType.cs b/Jellyfin.Plugin.Webhook/Destinations/WebhookMediaContentType.cs new file mode 100644 index 0000000..871015f --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/WebhookMediaContentType.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Plugin.Webhook.Destinations; + +/// +/// The type of notification. +/// +public enum WebhookMediaContentType +{ + /// + /// Plaintext/Generic Encoding. + /// + PlainText = 0, + + /// + /// JSON Encoded webhook payload. + /// + Json = 1, + + /// + /// XML Encoded Webhook Payload. + /// + Xml = 2, +} diff --git a/Jellyfin.Plugin.Webhook/Helpers/DataObjectHelpers.cs b/Jellyfin.Plugin.Webhook/Helpers/DataObjectHelpers.cs index c420feb..9654967 100644 --- a/Jellyfin.Plugin.Webhook/Helpers/DataObjectHelpers.cs +++ b/Jellyfin.Plugin.Webhook/Helpers/DataObjectHelpers.cs @@ -30,7 +30,7 @@ public static Dictionary GetBaseDataObject(IServerApplicationHos { var dataObject = new Dictionary(StringComparer.OrdinalIgnoreCase); dataObject["ServerId"] = applicationHost.SystemId; - dataObject["ServerName"] = applicationHost.FriendlyName.Escape(); + dataObject["ServerName"] = applicationHost.FriendlyName; dataObject["ServerVersion"] = applicationHost.ApplicationVersionString; dataObject["ServerUrl"] = WebhookPlugin.Instance?.Configuration.ServerUrl ?? "localhost:8096"; dataObject[nameof(NotificationType)] = notificationType.ToString(); @@ -53,11 +53,11 @@ public static Dictionary AddBaseItemData(this Dictionary AddBaseItemData(this Dictionary AddBaseItemData(this Dictionary AddPlaybackProgressData(this Dictionary /// The modified data object. public static Dictionary AddUserData(this Dictionary dataObject, UserDto user) { - dataObject["NotificationUsername"] = user.Name.Escape(); + dataObject["NotificationUsername"] = user.Name; dataObject["UserId"] = user.Id; dataObject[nameof(user.LastLoginDate)] = user.LastLoginDate ?? DateTime.UtcNow; dataObject[nameof(user.LastActivityDate)] = user.LastActivityDate ?? DateTime.MinValue; @@ -310,7 +310,7 @@ public static Dictionary AddUserData(this DictionaryThe modified data object. public static Dictionary AddUserData(this Dictionary dataObject, User user) { - dataObject["NotificationUsername"] = user.Username.Escape(); + dataObject["NotificationUsername"] = user.Username; dataObject["UserId"] = user.Id; dataObject[nameof(user.LastLoginDate)] = user.LastLoginDate ?? DateTime.UtcNow; dataObject[nameof(user.LastActivityDate)] = user.LastActivityDate ?? DateTime.MinValue; @@ -332,11 +332,11 @@ public static Dictionary AddSessionInfoData(this Dictionary AddSessionInfoData(this Dictionary AddSessionInfoData(this Dictionary AddPluginInstallationInfo(this Dictionary dataObject, InstallationInfo installationInfo) { dataObject["PluginId"] = installationInfo.Id; - dataObject["PluginName"] = installationInfo.Name.Escape(); + dataObject["PluginName"] = installationInfo.Name; dataObject["PluginVersion"] = installationInfo.Version; - dataObject["PluginChangelog"] = installationInfo.Changelog.Escape(); + dataObject["PluginChangelog"] = installationInfo.Changelog; dataObject["PluginChecksum"] = installationInfo.Checksum; dataObject["PluginSourceUrl"] = installationInfo.SourceUrl; @@ -410,7 +410,7 @@ public static Dictionary AddPluginInstallationInfo(this Dictiona /// The modified data object. public static Dictionary AddExceptionInfo(this Dictionary dataObject, Exception exception) { - dataObject["ExceptionMessage"] = exception.Message.Escape(); + dataObject["ExceptionMessage"] = exception.Message; dataObject["ExceptionMessageInner"] = exception.InnerException?.Message ?? string.Empty; return dataObject; @@ -440,12 +440,4 @@ public static Dictionary AddUserItemData(this Dictionary - /// Escape quotes for proper json. - /// - /// Input string. - /// Escaped string. - private static string Escape(this string? input) - => input?.Replace("\"", "\\\"", StringComparison.Ordinal) ?? string.Empty; }