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
7 changes: 7 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ internal static class AuthHelpers
catch { return null; }
}

/// <summary>True when the caller is a server admin, the same check the "Admin" role uses.</summary>
public static bool IsCurrentUserAdmin(IRequest request, IAuthorizationContext authContext)
{
try { return GetCurrentUser(request, authContext)?.Policy?.IsAdministrator == true; }
catch { return false; }
}

/// <summary>Returns all server user GUIDs, or null if the user manager is unavailable.</summary>
public static IReadOnlyCollection<Guid>? GetAllServerUserIds()
{
Expand Down
43 changes: 43 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Services;

namespace Emby.Plugins.Moonfin.Api
{
[Route("/Moonfin/Messages", "GET")]
[Authenticated]
public class GetMessagesRequest : IReturn<object> { }

[Route("/Moonfin/Admin/Messages", "GET")]
[Authenticated(Roles = "Admin")]
public class GetAdminMessagesRequest : IReturn<object> { }

[Route("/Moonfin/Admin/Messages", "POST")]
[Authenticated(Roles = "Admin")]
public class SaveMessageRequest : IReturn<object>, IRequiresRequestStream
{
public System.IO.Stream RequestStream { get; set; } = null!;
}

[Route("/Moonfin/Admin/Messages/Order", "POST")]
[Authenticated(Roles = "Admin")]
public class ReorderMessagesRequest : IReturn<object>, IRequiresRequestStream
{
public System.IO.Stream RequestStream { get; set; } = null!;
}

[Route("/Moonfin/Admin/Messages/{MessageId}", "DELETE")]
[Authenticated(Roles = "Admin")]
public class DeleteMessageRequest : IReturn<object>
{
public string? MessageId { get; set; }
}
}

namespace Emby.Plugins.Moonfin.Api
{
/// <summary>Body for the message reorder endpoint.</summary>
public class MessageOrderBody
{
public System.Collections.Generic.List<string>? Ids { get; set; }
}
}
188 changes: 188 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MediaBrowser.Common;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Services;

namespace Emby.Plugins.Moonfin.Api
{
public class MessagesService : IService, IRequiresRequest, IHasResultFactory
{
private readonly IAuthorizationContext _authContext;

public IRequest Request { get; set; } = null!;
public IHttpResultFactory ResultFactory { get; set; } = null!;

public MessagesService(IApplicationHost appHost)
{
_authContext = appHost.Resolve<IAuthorizationContext>();
ResultFactory = appHost.Resolve<IHttpResultFactory>();
}

private object Json(object? body) => MoonfinJson.Result(Request, ResultFactory, body);
private object Json(int statusCode, object? body) { Request.Response.StatusCode = statusCode; return Json(body); }

public object Get(GetMessagesRequest request)
{
var config = Plugin.Instance?.Configuration;
if (config?.EnableSettingsSync != true)
return Json(503, new { error = "Settings sync is disabled" });

var userId = AuthHelpers.GetCurrentUserId(Request, _authContext);
if (userId == null) return Json(401, new { error = "A valid user token is required" });

// Filtering happens here, not on the client. Sending everything and hiding it in
// the app would let any user read messages meant for someone else.
var isAdmin = AuthHelpers.IsCurrentUserAdmin(Request, _authContext);
var now = DateTime.UtcNow;
var items = config.Messages
.Where(m => m.IsVisibleTo(userId.Value, isAdmin, now))
.ToList();

return Json(new { items });
}

public object Get(GetAdminMessagesRequest request)
{
var messages = Plugin.Instance?.Configuration?.Messages ?? new List<ServerMessage>();

return Json(new
{
items = messages
.ToList()
});
}

public async Task<object> Post(SaveMessageRequest request)
{
var plugin = Plugin.Instance;
var config = plugin?.Configuration;
if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" });

var message = await MoonfinJson.ReadBodyAsync<ServerMessage>(request.RequestStream).ConfigureAwait(false);
if (message == null) return Json(400, new { error = "A message body is required" });

message.Sanitize();

if (message.Title.Length == 0 && message.Body.Length == 0)
return Json(400, new { error = "A title or a body is required" });

var messages = config.Messages;
var existing = messages.FirstOrDefault(m =>
string.Equals(m.Id, message.Id, StringComparison.OrdinalIgnoreCase));

if (existing != null)
{
message.CreatedUtc = existing.CreatedUtc;
message.CreatedByUserId = existing.CreatedByUserId;
// Replaced in place, so editing a message does not move it in the list.
messages[messages.IndexOf(existing)] = message;
}
else
{
message.Id = Guid.NewGuid().ToString("N");
message.CreatedUtc = DateTime.UtcNow;
message.CreatedByUserId = AuthHelpers.GetCurrentUserId(Request, _authContext)?.ToString("N");
// Newest first by default. The admin can move it with the arrows.
messages.Insert(0, message);
}

ServerMessage.Prune(messages);
plugin.UpdateConfiguration(config);

Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged");

// Only push for messages meant to interrupt. An inbox message can wait until the
// user opens the app.
if (existing == null && message.Delivery != ServerMessage.DeliveryInbox)
SendPush(message);

return Json(new { success = true, item = message });
}

/// <summary>
/// Reorders the stored messages to match the IDs given. The app shows them in this
/// order, so this is what the up and down arrows in the config page drive.
/// </summary>
public async Task<object> Post(ReorderMessagesRequest request)
{
var plugin = Plugin.Instance;
var config = plugin?.Configuration;
if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" });

var body = await MoonfinJson.ReadBodyAsync<MessageOrderBody>(request.RequestStream).ConfigureAwait(false);
var ids = body?.Ids;
if (ids == null || ids.Count == 0) return Json(400, new { error = "ids is required" });

var messages = config.Messages;
var reordered = new List<ServerMessage>(messages.Count);

foreach (var id in ids)
{
var match = messages.FirstOrDefault(m =>
string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
if (match != null && !reordered.Contains(match))
reordered.Add(match);
}

// Anything the caller left out keeps its place at the end, so a stale list from
// the config page cannot delete messages.
reordered.AddRange(messages.Where(m => !reordered.Contains(m)));

config.Messages = reordered;
plugin.UpdateConfiguration(config);
Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged");

return Json(new { success = true });
}

public object Delete(DeleteMessageRequest request)
{
var plugin = Plugin.Instance;
var config = plugin?.Configuration;
if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" });

var messageId = request.MessageId ?? string.Empty;
var removed = config.Messages
.RemoveAll(m => string.Equals(m.Id, messageId, StringComparison.OrdinalIgnoreCase));

if (removed == 0) return Json(404, new { error = "Message not found" });

plugin.UpdateConfiguration(config);
Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged");

return Json(new { success = true });
}

/// <summary>
/// Queues a push so the message also reaches users who do not have the app open.
/// The route tells the app to open the messages window on tap.
/// </summary>
private static void SendPush(ServerMessage message)
{
// Admin-only messages get no push, since the notification store does not know who
// is an admin. Admins still see them next time they open the app.
if (message.Audience == ServerMessage.AudienceAdmins) return;

var store = Plugin.Instance?.NotificationStore;
var pushDelivery = Plugin.Instance?.PushDelivery;
if (store == null || pushDelivery == null) return;

var title = message.Title.Length > 0 ? message.Title : "Message from your server";

foreach (var userId in store.GetUsersWithDevices())
{
if (message.Audience == ServerMessage.AudienceUsers &&
!message.TargetUserIds.Any(id =>
Guid.TryParse(id, out var target) && target == userId))
{
continue;
}

pushDelivery.QueueToUser(userId, title, message.Body, route: "messages");
}
}
}
}
29 changes: 26 additions & 3 deletions Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ public object Get(GetPingRequest request)
jellyseerrUrl = seerrUrl,
mdblistAvailable = !string.IsNullOrWhiteSpace(config?.MdblistApiKey),
tmdbAvailable = !string.IsNullOrWhiteSpace(config?.TmdbApiKey),
// Older plugins leave this out, which the app reads as false and hides the
// messages button.
messagesSupported = true,
defaultSettings = config?.DefaultUserSettings
});
}
Expand Down Expand Up @@ -341,17 +344,37 @@ public async Task<object> Post(BroadcastRequest request)

var deliveries = Settings.BroadcastMessage(message);

// Also save it, so users who were not connected still find it in the app. Older
// clients keep reading the "adminMessage" event above and ignore this.
var config = Plugin.Instance?.Configuration;
if (Plugin.Instance != null && config != null)
{
config.Messages.Add(new ServerMessage
{
Id = Guid.NewGuid().ToString("N"),
Body = message,
Color = ServerMessage.ColorWhite,
Delivery = ServerMessage.DeliveryPopup,
Audience = ServerMessage.AudienceAll,
CreatedUtc = DateTime.UtcNow,
CreatedByUserId = AuthHelpers.GetCurrentUserId(Request, _authContext)?.ToString("N")
});
ServerMessage.Prune(config.Messages);
Plugin.Instance.UpdateConfiguration(config);
Settings.BroadcastSystemEvent("messagesChanged");
}

// The websocket only reaches clients that are open right now, so push
// covers backgrounded and killed apps. The route is left blank because
// the client ignores blank routes and just opens the app on a tap.
// covers backgrounded and killed apps. The route opens the messages window
// on tap.
var pushTargets = 0;
var store = Plugin.Instance?.NotificationStore;
var pushDelivery = Plugin.Instance?.PushDelivery;
if (store != null && pushDelivery != null)
{
foreach (var userId in store.GetUsersWithDevices())
{
pushDelivery.QueueToUser(userId, "Message from your server", message, route: "");
pushDelivery.QueueToUser(userId, "Message from your server", message, route: "messages");
pushTargets++;
}
}
Expand Down
1 change: 1 addition & 0 deletions Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ public class MoonfinSettingsProfile
[JsonPropertyName("use24HourClock")] public bool? Use24HourClock { get; set; }
[JsonPropertyName("homeRowInfoOverlay")] public bool? HomeRowInfoOverlay { get; set; }
[JsonPropertyName("showSeerrButton")] public bool? ShowSeerrButton { get; set; }
[JsonPropertyName("showServerMessagesButton")] public bool? ShowServerMessagesButton { get; set; }
[JsonPropertyName("crashReportsEnabled")] public bool? CrashReportsEnabled { get; set; }
[JsonPropertyName("diagnosticLoggingEnabled")] public bool? DiagnosticLoggingEnabled { get; set; }
[JsonPropertyName("updateNotificationsEnabled")] public bool? UpdateNotificationsEnabled { get; set; }
Expand Down
Loading