Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ namespace Jellyfin.Plugin.Webhook.Destinations;
/// </summary>
public abstract class BaseOption
{
/// <summary>
/// The data fields that have historically been manually escaped in <see cref="DataObjectHelpers"/>.
/// More fields may be added as needed.
/// </summary>
private static readonly string[] FieldsToEscape =
[
"ServerName",
"Name",
"Overview",
"Tagline",
"ItemType",
"SeriesName",
"NotificationUsername",
"Client",
"DeviceName",
"PluginName",
"PluginChangelog",
"ExceptionMessage"
];

private HandlebarsTemplate<object, string>? _compiledTemplate;

/// <summary>
Expand Down Expand Up @@ -97,6 +117,11 @@ public abstract class BaseOption
/// </summary>
public Guid[] UserFilter { get; set; } = Array.Empty<Guid>();

/// <summary>
/// Gets or sets the Media-Content type of the webhook body. Should not be set by User except for Generic Client.
/// </summary>
public WebhookMediaContentType MediaContentType { get; set; } = WebhookMediaContentType.Json;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably possible to set in the webui

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do have it available to set in the Generic Client via a header, though at the moment it only supports json, xml, and generic/plaintext. For the individual pre-built clients though I think it makes sense to keep it hidden, or at least on a per-client basis. Discord webhooks for example only allow for JSON, so it makes no sense to expose an option that invariably breaks functionality if changed.


/// <summary>
/// Gets the compiled handlebars template.
/// </summary>
Expand All @@ -113,10 +138,45 @@ public HandlebarsTemplate<object, string> GetCompiledTemplate()
/// <returns>The string message body.</returns>
public string GetMessageBody(Dictionary<string, object> data)
{
Dictionary<string, object> 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;
}

/// <summary>
/// Escape a text string using the default Json Encoder.
/// </summary>
/// <param name="value">A plain-text string that needs escaped.</param>
/// <returns>The escaped string.</returns>
private static string JsonEncode(string value)
{
return JsonEncodedText.Encode(value).Value;
}

/// <summary>
/// Escapes all backslashes in a string. This is the previous escape method used for all strings.
/// </summary>
/// <param name="value">A plain-text string that needs escaped.</param>
/// <returns>The escaped string.</returns>
private static string DefaultEscape(string value)
{
return value.Replace("\"", "\\\"", StringComparison.Ordinal);
}
}
25 changes: 18 additions & 7 deletions Jellyfin.Plugin.Webhook/Destinations/Generic/GenericClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,6 @@ public async Task SendAsync(GenericOption option, Dictionary<string, object> 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)
Expand All @@ -73,13 +66,31 @@ public async Task SendAsync(GenericOption option, Dictionary<string, object> 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
{
httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public GenericOption()
{
Headers = Array.Empty<GenericOptionValue>();
Fields = Array.Empty<GenericOptionValue>();
MediaContentType = WebhookMediaContentType.PlainText;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public GenericFormOption()
{
Headers = Array.Empty<GenericFormOptionValue>();
Fields = Array.Empty<GenericFormOptionValue>();
// The MediaType is left as JSON because that is what is assumed to be used in GenericFormClient.cs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Json is not assuimed in GenericFormClient, the content type should be application/x-www-form-urlencoded

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may not be intended to be, but the body is currently treated solely as JSON per current master: https://github.com/jellyfin/jellyfin-plugin-webhook/blob/c507d/Jellyfin.Plugin.Webhook/Destinations/GenericForm/GenericFormClient.cs#L52-L58
Once in the processing itself the Generic Client does use FormURLContent (line 77 of same file, unchanged in PR)

}

/// <summary>
Expand Down
22 changes: 22 additions & 0 deletions Jellyfin.Plugin.Webhook/Destinations/WebhookMediaContentType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Jellyfin.Plugin.Webhook.Destinations;

/// <summary>
/// The type of notification.
/// </summary>
public enum WebhookMediaContentType
{
/// <summary>
/// Plaintext/Generic Encoding.
/// </summary>
PlainText = 0,

/// <summary>
/// JSON Encoded webhook payload.
/// </summary>
Json = 1,

/// <summary>
/// XML Encoded Webhook Payload.
/// </summary>
Xml = 2,
}
44 changes: 18 additions & 26 deletions Jellyfin.Plugin.Webhook/Helpers/DataObjectHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public static Dictionary<string, object> GetBaseDataObject(IServerApplicationHos
{
var dataObject = new Dictionary<string, object>(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();
Expand All @@ -53,11 +53,11 @@ public static Dictionary<string, object> AddBaseItemData(this Dictionary<string,

dataObject["Timestamp"] = DateTime.Now;
dataObject["UtcTimestamp"] = DateTime.UtcNow;
dataObject["Name"] = item.Name.Escape();
dataObject["Overview"] = item.Overview.Escape();
dataObject["Tagline"] = item.Tagline.Escape();
dataObject["Name"] = item.Name;
dataObject["Overview"] = item.Overview;
dataObject["Tagline"] = item.Tagline;
dataObject["ItemId"] = item.Id;
dataObject["ItemType"] = item.GetType().Name.Escape();
dataObject["ItemType"] = item.GetType().Name;
dataObject["RunTimeTicks"] = item.RunTimeTicks ?? 0;
dataObject["RunTime"] = TimeSpan.FromTicks(item.RunTimeTicks ?? 0).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);

Expand Down Expand Up @@ -86,7 +86,7 @@ public static Dictionary<string, object> AddBaseItemData(this Dictionary<string,
case Season season:
if (!string.IsNullOrEmpty(season.Series?.Name))
{
dataObject["SeriesName"] = season.Series.Name.Escape();
dataObject["SeriesName"] = season.Series.Name;
}

if (season.Series?.ProductionYear is not null)
Expand All @@ -110,7 +110,7 @@ public static Dictionary<string, object> AddBaseItemData(this Dictionary<string,
case Episode episode:
if (!string.IsNullOrEmpty(episode.Series?.Name))
{
dataObject["SeriesName"] = episode.Series.Name.Escape();
dataObject["SeriesName"] = episode.Series.Name;
}

if (episode.Series?.Id is not null)
Expand Down Expand Up @@ -294,7 +294,7 @@ public static Dictionary<string, object> AddPlaybackProgressData(this Dictionary
/// <returns>The modified data object.</returns>
public static Dictionary<string, object> AddUserData(this Dictionary<string, object> 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;
Expand All @@ -310,7 +310,7 @@ public static Dictionary<string, object> AddUserData(this Dictionary<string, obj
/// <returns>The modified data object.</returns>
public static Dictionary<string, object> AddUserData(this Dictionary<string, object> 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;
Expand All @@ -332,11 +332,11 @@ public static Dictionary<string, object> AddSessionInfoData(this Dictionary<stri
}

dataObject[nameof(sessionInfo.UserId)] = sessionInfo.UserId;
dataObject["NotificationUsername"] = sessionInfo.UserName.Escape();
dataObject[nameof(sessionInfo.Client)] = sessionInfo.Client.Escape();
dataObject["NotificationUsername"] = sessionInfo.UserName;
dataObject[nameof(sessionInfo.Client)] = sessionInfo.Client;
dataObject[nameof(sessionInfo.LastActivityDate)] = sessionInfo.LastActivityDate;
dataObject[nameof(sessionInfo.LastPlaybackCheckIn)] = sessionInfo.LastPlaybackCheckIn;
dataObject[nameof(sessionInfo.DeviceName)] = sessionInfo.DeviceName.Escape();
dataObject[nameof(sessionInfo.DeviceName)] = sessionInfo.DeviceName;

if (!string.IsNullOrEmpty(sessionInfo.DeviceId))
{
Expand Down Expand Up @@ -365,11 +365,11 @@ public static Dictionary<string, object> AddSessionInfoData(this Dictionary<stri
}

dataObject[nameof(sessionInfo.UserId)] = sessionInfo.UserId;
dataObject["NotificationUsername"] = sessionInfo.UserName.Escape();
dataObject[nameof(sessionInfo.Client)] = sessionInfo.Client.Escape();
dataObject["NotificationUsername"] = sessionInfo.UserName ?? string.Empty;
dataObject[nameof(sessionInfo.Client)] = sessionInfo.Client ?? string.Empty;
dataObject[nameof(sessionInfo.LastActivityDate)] = sessionInfo.LastActivityDate;
dataObject[nameof(sessionInfo.LastPlaybackCheckIn)] = sessionInfo.LastPlaybackCheckIn;
dataObject[nameof(sessionInfo.DeviceName)] = sessionInfo.DeviceName.Escape();
dataObject[nameof(sessionInfo.DeviceName)] = sessionInfo.DeviceName ?? string.Empty;

if (!string.IsNullOrEmpty(sessionInfo.DeviceId))
{
Expand All @@ -393,9 +393,9 @@ public static Dictionary<string, object> AddSessionInfoData(this Dictionary<stri
public static Dictionary<string, object> AddPluginInstallationInfo(this Dictionary<string, object> 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;

Expand All @@ -410,7 +410,7 @@ public static Dictionary<string, object> AddPluginInstallationInfo(this Dictiona
/// <returns>The modified data object.</returns>
public static Dictionary<string, object> AddExceptionInfo(this Dictionary<string, object> dataObject, Exception exception)
{
dataObject["ExceptionMessage"] = exception.Message.Escape();
dataObject["ExceptionMessage"] = exception.Message;
dataObject["ExceptionMessageInner"] = exception.InnerException?.Message ?? string.Empty;

return dataObject;
Expand Down Expand Up @@ -440,12 +440,4 @@ public static Dictionary<string, object> AddUserItemData(this Dictionary<string,

return dataObject;
}

/// <summary>
/// Escape quotes for proper json.
/// </summary>
/// <param name="input">Input string.</param>
/// <returns>Escaped string.</returns>
private static string Escape(this string? input)
=> input?.Replace("\"", "\\\"", StringComparison.Ordinal) ?? string.Empty;
}