diff --git a/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs b/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs index aa9dc5e..0dd1d68 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs @@ -23,6 +23,13 @@ internal static class AuthHelpers catch { return null; } } + /// True when the caller is a server admin, the same check the "Admin" role uses. + public static bool IsCurrentUserAdmin(IRequest request, IAuthorizationContext authContext) + { + try { return GetCurrentUser(request, authContext)?.Policy?.IsAdministrator == true; } + catch { return false; } + } + /// Returns all server user GUIDs, or null if the user manager is unavailable. public static IReadOnlyCollection? GetAllServerUserIds() { diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs new file mode 100644 index 0000000..e8d2f26 --- /dev/null +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs @@ -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 { } + + [Route("/Moonfin/Admin/Messages", "GET")] + [Authenticated(Roles = "Admin")] + public class GetAdminMessagesRequest : IReturn { } + + [Route("/Moonfin/Admin/Messages", "POST")] + [Authenticated(Roles = "Admin")] + public class SaveMessageRequest : IReturn, IRequiresRequestStream + { + public System.IO.Stream RequestStream { get; set; } = null!; + } + + [Route("/Moonfin/Admin/Messages/Order", "POST")] + [Authenticated(Roles = "Admin")] + public class ReorderMessagesRequest : IReturn, IRequiresRequestStream + { + public System.IO.Stream RequestStream { get; set; } = null!; + } + + [Route("/Moonfin/Admin/Messages/{MessageId}", "DELETE")] + [Authenticated(Roles = "Admin")] + public class DeleteMessageRequest : IReturn + { + public string? MessageId { get; set; } + } +} + +namespace Emby.Plugins.Moonfin.Api +{ + /// Body for the message reorder endpoint. + public class MessageOrderBody + { + public System.Collections.Generic.List? Ids { get; set; } + } +} diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs new file mode 100644 index 0000000..f8b2729 --- /dev/null +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs @@ -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(); + ResultFactory = appHost.Resolve(); + } + + 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(); + + return Json(new + { + items = messages + .ToList() + }); + } + + public async Task 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(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 }); + } + + /// + /// 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. + /// + public async Task 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(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(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 }); + } + + /// + /// 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. + /// + 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"); + } + } + } +} diff --git a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs index f966634..af3b4d7 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs @@ -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 }); } @@ -341,9 +344,29 @@ public async Task 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; @@ -351,7 +374,7 @@ public async Task Post(BroadcastRequest request) { 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++; } } diff --git a/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs b/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs index 8b95032..9da1a2d 100644 --- a/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs +++ b/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs @@ -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; } diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index c28c2ea..09596af 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -567,6 +567,10 @@

Moonfin Settings

ThemesUpload & catalog + + + + + + +
+

Saved Messages

+
+ Shown in this order in the app. Up to 50 are kept, then the oldest are dropped. +
+
+
+
@@ -1889,8 +2014,9 @@

Push Defaults To Existing Users

Broadcast Message

- Send a live message to all currently connected Moonfin clients. - This message is not saved and only appears for users with an active connection. + Send a message to all connected Moonfin clients right now. It is also saved to + the Messages tab, so users who were offline still see it later. + For anything you want to write properly, use the Messages tab instead.
diff --git a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js index 7da78e9..ca464c4 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js +++ b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js @@ -1702,6 +1702,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em setNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded', defaults.navbarAlwaysExpanded); setNullableBoolSelect(view, '#DefaultEnableFolderView', defaults.enableFolderView); setNullableBoolSelect(view, '#DefaultShowSeerrButton', defaults.showSeerrButton); + setNullableBoolSelect(view, '#DefaultShowServerMessagesButton', defaults.showServerMessagesButton); setSelectValue(view, '#DefaultMediaBarSourceType', defaults.mediaBarSourceType, 'Configured source'); loadAdminGenrePicker(view, defaults.mediaBarExcludedGenres || []); @@ -1868,6 +1869,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em d.navbarAlwaysExpanded = getNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded'); d.enableFolderView = getNullableBoolSelect(view, '#DefaultEnableFolderView'); d.showSeerrButton = getNullableBoolSelect(view, '#DefaultShowSeerrButton'); + d.showServerMessagesButton = getNullableBoolSelect(view, '#DefaultShowServerMessagesButton'); d.mediaBarSourceType = view.querySelector('#DefaultMediaBarSourceType').value || null; var genreIds = Array.prototype.slice.call(view.querySelectorAll('.adminGenreCb:checked')).map(function (cb) { return cb.dataset.id; }); @@ -2068,6 +2070,361 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em }); } + var moonfinMessageColors = { + white: '#e8eaed', + green: '#4caf6d', + blue: '#4a9ee0', + yellow: '#e0b040', + red: '#e05260' + }; + + var moonfinDeliveryLabels = { + inbox: 'Notify', + popup: 'Opens in app' + }; + + function setMessageResult(view, text, color) { + var result = view.querySelector('#MessageSaveResult'); + if (!result) return; + + if (!text) { + result.style.display = 'none'; + result.textContent = ''; + return; + } + + result.style.display = ''; + result.style.color = color || ''; + result.textContent = text; + } + + // The date inputs work in the admin's own time zone, but the server stores UTC. + function messageDateToInput(utcValue) { + if (!utcValue) return ''; + + var date = new Date(utcValue); + if (isNaN(date.getTime())) return ''; + + var pad = function (n) { return (n < 10 ? '0' : '') + n; }; + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + function messageDateFromInput(value) { + if (!value) return null; + var date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + function formatMessageDate(utcValue) { + if (!utcValue) return ''; + var date = new Date(utcValue); + return isNaN(date.getTime()) ? '' : date.toLocaleString(); + } + + function toggleMessageTargets(view) { + var audience = view.querySelector('#MessageAudience'); + var row = view.querySelector('#MessageTargetsRow'); + if (!audience || !row) return; + row.style.display = audience.value === 'users' ? '' : 'none'; + } + + function loadMessageTargetUsers(view) { + var select = view.querySelector('#MessageTargets'); + if (!select || !ApiClient.getUsers) return Promise.resolve(); + + return ApiClient.getUsers().then(function (users) { + select.innerHTML = ''; + (users || []).forEach(function (user) { + var option = document.createElement('option'); + option.value = user.Id; + option.textContent = user.Name; + select.appendChild(option); + }); + }).catch(function () {}); + } + + function resetMessageForm(view) { + view.__moonfinEditingMessageId = null; + + view.querySelector('#MessageTitle').value = ''; + view.querySelector('#MessageBody').value = ''; + view.querySelector('#MessageColor').value = 'white'; + view.querySelector('#MessageDelivery').value = 'inbox'; + view.querySelector('#MessageAudience').value = 'all'; + view.querySelector('#MessageStart').value = ''; + view.querySelector('#MessageEnd').value = ''; + view.querySelector('#MessageActionLabel').value = ''; + view.querySelector('#MessageActionUrl').value = ''; + + var targets = view.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function (option) { option.selected = false; }); + } + + var saveBtn = view.querySelector('#MessageSaveBtn'); + if (saveBtn) saveBtn.querySelector('span').textContent = 'Save Message'; + + var resetBtn = view.querySelector('#MessageResetBtn'); + if (resetBtn) resetBtn.style.display = 'none'; + + toggleMessageTargets(view); + setMessageResult(view, '', ''); + } + + function editMessage(view, item) { + view.__moonfinEditingMessageId = item.Id || item.id || null; + + view.querySelector('#MessageTitle').value = item.Title || item.title || ''; + view.querySelector('#MessageBody').value = item.Body || item.body || ''; + view.querySelector('#MessageColor').value = item.Color || item.color || 'white'; + view.querySelector('#MessageDelivery').value = item.Delivery || item.delivery || 'inbox'; + view.querySelector('#MessageAudience').value = item.Audience || item.audience || 'all'; + view.querySelector('#MessageStart').value = messageDateToInput(item.StartUtc || item.startUtc); + view.querySelector('#MessageEnd').value = messageDateToInput(item.EndUtc || item.endUtc); + view.querySelector('#MessageActionLabel').value = item.ActionLabel || item.actionLabel || ''; + view.querySelector('#MessageActionUrl').value = item.ActionUrl || item.actionUrl || ''; + + var selected = item.TargetUserIds || item.targetUserIds || []; + var targets = view.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function (option) { + option.selected = selected.some(function (id) { + return String(id).toLowerCase() === String(option.value).toLowerCase(); + }); + }); + } + + var saveBtn = view.querySelector('#MessageSaveBtn'); + if (saveBtn) saveBtn.querySelector('span').textContent = 'Update Message'; + + var resetBtn = view.querySelector('#MessageResetBtn'); + if (resetBtn) resetBtn.style.display = ''; + + toggleMessageTargets(view); + setMessageResult(view, '', ''); + + var titleInput = view.querySelector('#MessageTitle'); + if (titleInput) titleInput.focus(); + } + + function renderMessagesList(view, items) { + var container = view.querySelector('#MessagesList'); + if (!container) return; + + if (!items || items.length === 0) { + container.innerHTML = '
No messages yet.
'; + return; + } + + var now = new Date(); + var html = ''; + + items.forEach(function (item, index) { + var id = item.Id || item.id || ''; + var title = item.Title || item.title || ''; + var body = item.Body || item.body || ''; + var pick = item.Color || item.color || 'white'; + var delivery = item.Delivery || item.delivery || 'inbox'; + var audience = item.Audience || item.audience || 'all'; + var start = item.StartUtc || item.startUtc; + var end = item.EndUtc || item.endUtc; + var targets = item.TargetUserIds || item.targetUserIds || []; + var color = moonfinMessageColors[pick] || moonfinMessageColors.white; + + var state = 'Showing now'; + if (start && new Date(start) > now) { + state = 'Starts ' + formatMessageDate(start); + } else if (end && new Date(end) <= now) { + state = 'Expired'; + } else if (end) { + state = 'Until ' + formatMessageDate(end); + } + + var who = audience === 'admins' + ? 'Admins only' + : audience === 'users' + ? targets.length + (targets.length === 1 ? ' user' : ' users') + : 'Everyone'; + + html += '
' + + '
' + + '
' + + '' + esc(title || '(no title)') + '' + + '
' + + '
' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '
' + + '
' + + esc(state) + ' • ' + esc(who) + ' • ' + esc(moonfinDeliveryLabels[delivery] || delivery) + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
'; + }); + + container.innerHTML = html; + } + + function loadMessagesList(view) { + var container = view.querySelector('#MessagesList'); + if (!container) return; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + container.innerHTML = '
Loading messages...
'; + + fetch(serverUrl + '/Moonfin/Admin/Messages', { method: 'GET', headers: moonfinAuthHeaders() }) + .then(parseJsonResponse) + .then(function (payload) { + var items = payload.items || payload.Items || []; + view.__moonfinMessagesCache = items; + renderMessagesList(view, items); + }) + .catch(function (error) { + container.innerHTML = '
' + + esc((error && error.message) ? error.message : 'Failed to load messages.') + '
'; + }); + } + + // Moves one message up or down and saves the whole order, so the app shows + // them in the same sequence as this list. + function moveMessage(view, messageId, delta) { + var items = (view.__moonfinMessagesCache || []).slice(); + var from = -1; + for (var i = 0; i < items.length; i++) { + if ((items[i].Id || items[i].id) === messageId) { from = i; break; } + } + + var to = from + delta; + if (from < 0 || to < 0 || to >= items.length) return; + + var moved = items.splice(from, 1)[0]; + items.splice(to, 0, moved); + + // Redrawn straight away so the arrows feel instant, then saved. + view.__moonfinMessagesCache = items; + renderMessagesList(view, items); + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + fetch(serverUrl + '/Moonfin/Admin/Messages/Order', { + method: 'POST', + headers: moonfinAuthHeaders(), + body: JSON.stringify({ + Ids: items.map(function (item) { return item.Id || item.id; }) + }) + }) + .then(parseJsonResponse) + .catch(function (error) { + setMessageResult( + view, + (error && error.message) ? error.message : 'Could not save the new order.', + '#d9534f' + ); + loadMessagesList(view); + }); + } + + function saveMessage(view) { + var saveBtn = view.querySelector('#MessageSaveBtn'); + var title = (view.querySelector('#MessageTitle').value || '').trim(); + var body = (view.querySelector('#MessageBody').value || '').trim(); + + if (!title && !body) { + setMessageResult(view, 'Enter a title or a message.', '#d9534f'); + return; + } + + var audience = view.querySelector('#MessageAudience').value; + var targetSelect = view.querySelector('#MessageTargets'); + var targetUserIds = []; + if (audience === 'users' && targetSelect) { + targetUserIds = Array.prototype.filter.call(targetSelect.options, function (option) { + return option.selected; + }).map(function (option) { return option.value; }); + + if (targetUserIds.length === 0) { + setMessageResult(view, 'Pick at least one user, or change who sees it.', '#d9534f'); + return; + } + } + + var payload = { + Id: view.__moonfinEditingMessageId || '', + Title: title, + Body: body, + Color: view.querySelector('#MessageColor').value, + Delivery: view.querySelector('#MessageDelivery').value, + Audience: audience, + TargetUserIds: targetUserIds, + ActionLabel: (view.querySelector('#MessageActionLabel').value || '').trim(), + ActionUrl: (view.querySelector('#MessageActionUrl').value || '').trim(), + StartUtc: messageDateFromInput(view.querySelector('#MessageStart').value), + EndUtc: messageDateFromInput(view.querySelector('#MessageEnd').value) + }; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + var wasEditing = !!view.__moonfinEditingMessageId; + + if (saveBtn) saveBtn.disabled = true; + setMessageResult(view, '', ''); + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'POST', + headers: moonfinAuthHeaders(), + body: JSON.stringify(payload) + }) + .then(parseJsonResponse) + .then(function (response) { + var saved = response.item || response.Item || {}; + var warnings = []; + + if (payload.ActionUrl && !(saved.ActionUrl || saved.actionUrl)) { + warnings.push('the link was dropped, it must start with http:// or https://'); + } + if (payload.EndUtc && !(saved.EndUtc || saved.endUtc)) { + warnings.push('the end date was dropped, it was before the start date'); + } + + var message = wasEditing ? 'Message updated.' : 'Message saved.'; + if (warnings.length) message += ' Note: ' + warnings.join(', ') + '.'; + + // resetMessageForm clears the result line, so set it afterwards. + resetMessageForm(view); + setMessageResult(view, message, warnings.length ? '#f0ad4e' : '#52b54b'); + loadMessagesList(view); + }) + .catch(function (error) { + setMessageResult(view, (error && error.message) ? error.message : 'Save failed.', '#d9534f'); + }) + .finally(function () { + if (saveBtn) saveBtn.disabled = false; + }); + } + + function deleteMessage(view, messageId) { + if (!messageId) return; + if (!window.confirm('Delete this message?')) return; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + + fetch(serverUrl + '/Moonfin/Admin/Messages/' + encodeURIComponent(messageId), { + method: 'DELETE', + headers: moonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function () { + if (view.__moonfinEditingMessageId === messageId) resetMessageForm(view); + setMessageResult(view, 'Message deleted.', '#52b54b'); + loadMessagesList(view); + }) + .catch(function (error) { + setMessageResult(view, (error && error.message) ? error.message : 'Delete failed.', '#d9534f'); + }); + } + function initializeDefaultsSubtabs(view) { var subnav = view.querySelector('.defaultsSubnavBar'); if (!subnav || subnav.dataset.bound) return; @@ -2144,6 +2501,48 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em var broadcastBtn = view.querySelector('#BroadcastMessageBtn'); if (broadcastBtn) broadcastBtn.addEventListener('click', function () { broadcast(view); }); + var messageAudience = view.querySelector('#MessageAudience'); + if (messageAudience) { + messageAudience.addEventListener('change', function () { toggleMessageTargets(view); }); + } + + var messageSaveBtn = view.querySelector('#MessageSaveBtn'); + if (messageSaveBtn) messageSaveBtn.addEventListener('click', function () { saveMessage(view); }); + + var messageResetBtn = view.querySelector('#MessageResetBtn'); + if (messageResetBtn) messageResetBtn.addEventListener('click', function () { resetMessageForm(view); }); + + var messagesListContainer = view.querySelector('#MessagesList'); + if (messagesListContainer) { + messagesListContainer.addEventListener('click', function (event) { + var upButton = event.target.closest('.moonfinMessageUpBtn'); + if (upButton) { + moveMessage(view, upButton.getAttribute('data-message-id') || '', -1); + return; + } + + var downButton = event.target.closest('.moonfinMessageDownBtn'); + if (downButton) { + moveMessage(view, downButton.getAttribute('data-message-id') || '', 1); + return; + } + + var editButton = event.target.closest('.moonfinMessageEditBtn'); + if (editButton) { + var editId = editButton.getAttribute('data-message-id') || ''; + var cached = view.__moonfinMessagesCache || []; + var found = cached.filter(function (item) { + return (item.Id || item.id) === editId; + })[0]; + if (found) editMessage(view, found); + return; + } + + var deleteButton = event.target.closest('.moonfinMessageDeleteBtn'); + if (deleteButton) deleteMessage(view, deleteButton.getAttribute('data-message-id') || ''); + }); + } + var mdblistTestBtn = view.querySelector('#MdblistTestKeyBtn'); if (mdblistTestBtn) mdblistTestBtn.addEventListener('click', function () { testMdblistKey(view); }); @@ -2164,6 +2563,11 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em clearSelectedThemeFile(view); setThemeUploadResult(view, '', ''); loadAdminThemesList(view); + // The user list must be there before the form can preselect targets on edit. + loadMessageTargetUsers(view).then(function () { + resetMessageForm(view); + loadMessagesList(view); + }); loadConfig(view); }; diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs index 0a9efe7..e24c60e 100644 --- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs +++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs @@ -130,6 +130,12 @@ public bool EnsureWebhookSecret() /// Metadata index for uploaded custom themes stored in the plugin data folder. public List UploadedThemes { get; set; } = new List(); + /// + /// Admin messages shown to users in the app. They are only a few KB of text, so they + /// live in the config instead of the data folder. + /// + public List Messages { get; set; } = new List(); + // Retro games (EmulatorJS) configuration. public bool GamesEnabled { get; set; } = false; public List GameLibraryIds { get; set; } = new List(); @@ -224,4 +230,177 @@ public class UploadedThemeEntry public string? UploadedByUserId { get; set; } public string ChecksumSha256 { get; set; } = string.Empty; } + + /// + /// One message the admin wrote for users to read in the app. + /// + public class ServerMessage + { + /// Highest number of messages kept. Older ones are dropped on save. + public const int MaxStored = 50; + + /// Body length cap, so a huge paste cannot break the app layout. + public const int MaxBodyLength = 2000; + + /// Title length cap, the same limit the config page puts on its field. + public const int MaxTitleLength = 120; + + public const string ColorGreen = "green"; + public const string ColorRed = "red"; + public const string ColorYellow = "yellow"; + public const string ColorBlue = "blue"; + public const string ColorWhite = "white"; + + /// Only marks the menu button as unread. + public const string DeliveryInbox = "inbox"; + + /// Opens the message window once, until the user reads it. + public const string DeliveryPopup = "popup"; + + public const string AudienceAll = "all"; + public const string AudienceUsers = "users"; + public const string AudienceAdmins = "admins"; + + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string Color { get; set; } = ColorWhite; + public string Delivery { get; set; } = DeliveryInbox; + public string? ActionLabel { get; set; } + public string? ActionUrl { get; set; } + + /// When the message starts showing. Null means right away. + public DateTime? StartUtc { get; set; } + + /// When the message stops showing. Null means never. + public DateTime? EndUtc { get; set; } + + public string Audience { get; set; } = AudienceAll; + + /// User IDs to show this to. Only used when Audience is "users". + public List TargetUserIds { get; set; } = new List(); + + public DateTime CreatedUtc { get; set; } + public string? CreatedByUserId { get; set; } + + /// + /// True when this message should show right now, for this user. + /// + public bool IsVisibleTo(Guid userId, bool isAdmin, DateTime nowUtc) + { + if (StartUtc.HasValue && nowUtc < StartUtc.Value) + return false; + + if (EndUtc.HasValue && nowUtc >= EndUtc.Value) + return false; + + return Audience switch + { + AudienceAdmins => isAdmin, + AudienceUsers => TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId), + _ => true + }; + } + + /// + /// Cleans admin input before it is saved. Bad values fall back to the default instead + /// of being rejected, except the action URL which is dropped when it is not http or + /// https. + /// + public void Sanitize() + { + Title = (Title ?? string.Empty).Trim(); + Body = (Body ?? string.Empty).Trim(); + + if (Body.Length > MaxBodyLength) + Body = Body.Substring(0, MaxBodyLength); + + if (Title.Length > MaxTitleLength) + Title = Title.Substring(0, MaxTitleLength); + + Color = Color switch + { + ColorGreen => ColorGreen, + ColorRed => ColorRed, + ColorYellow => ColorYellow, + ColorBlue => ColorBlue, + _ => ColorWhite + }; + + Delivery = Delivery == DeliveryPopup ? DeliveryPopup : DeliveryInbox; + + Audience = Audience switch + { + AudienceUsers => AudienceUsers, + AudienceAdmins => AudienceAdmins, + _ => AudienceAll + }; + + if (Audience != AudienceUsers) + { + TargetUserIds = new List(); + } + else + { + TargetUserIds = TargetUserIds + .Where(id => Guid.TryParse(id, out _)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + ActionLabel = string.IsNullOrWhiteSpace(ActionLabel) ? null : ActionLabel.Trim(); + ActionUrl = SanitizeActionUrl(ActionUrl); + + // A link with no label is useless to the user, and a label with no link does nothing. + if (ActionUrl == null) + ActionLabel = null; + else if (ActionLabel == null) + ActionUrl = null; + + // An end date before the start date would hide the message forever. + if (StartUtc.HasValue && EndUtc.HasValue && EndUtc.Value <= StartUtc.Value) + EndUtc = null; + } + + /// + /// Keeps only real http and https links. Anything else is dropped, since this URL gets + /// opened on the user's device. + /// + private static string? SanitizeActionUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + return null; + + var value = rawUrl.Trim().Trim('"', '\'').Trim(); + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return null; + + // AbsoluteUri keeps the escaping the admin typed, where ToString would decode it. + return uri.AbsoluteUri; + } + + /// + /// Drops expired messages, then the oldest ones if the list is still too long. Keeps + /// the config file from growing forever without needing a scheduled task. + /// + public static void Prune(List messages) + { + var now = DateTime.UtcNow; + messages.RemoveAll(m => m.EndUtc.HasValue && m.EndUtc.Value < now); + + if (messages.Count <= MaxStored) + return; + + var extra = messages + .OrderBy(m => m.CreatedUtc) + .Take(messages.Count - MaxStored) + .ToList(); + + foreach (var message in extra) + messages.Remove(message); + } + } } diff --git a/Jellyfin/backend/Api/ControllerExtensions.cs b/Jellyfin/backend/Api/ControllerExtensions.cs index 8aeb043..e5305b8 100644 --- a/Jellyfin/backend/Api/ControllerExtensions.cs +++ b/Jellyfin/backend/Api/ControllerExtensions.cs @@ -13,6 +13,13 @@ public static class ControllerExtensions return Guid.TryParse(userIdClaim, out var userId) ? userId : null; } + /// + /// True when the caller is a server admin. This is the same role the "RequiresElevation" + /// policy checks, so it matches what Jellyfin itself allows. + /// + public static bool IsAdminFromClaims(this ControllerBase controller) => + controller.User.IsInRole("Administrator"); + public static string? GetDeviceIdFromClaims(this ControllerBase controller) { var deviceId = controller.User.FindFirst("Jellyfin-DeviceId")?.Value; diff --git a/Jellyfin/backend/Api/MoonfinController.cs b/Jellyfin/backend/Api/MoonfinController.cs index 3f6ce7b..8980378 100644 --- a/Jellyfin/backend/Api/MoonfinController.cs +++ b/Jellyfin/backend/Api/MoonfinController.cs @@ -83,6 +83,7 @@ public ActionResult Ping() : null, MdblistAvailable = !string.IsNullOrWhiteSpace(config?.MdblistApiKey), TmdbAvailable = !string.IsNullOrWhiteSpace(config?.TmdbApiKey), + MessagesSupported = true, DefaultSettings = config?.DefaultUserSettings }); } @@ -506,13 +507,30 @@ public ActionResult BroadcastMessage([FromBody] MoonfinBroadcastRequest request) var deliveries = _settingsService.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 stored = new ServerMessage + { + Id = Guid.NewGuid().ToString("N"), + Body = message, + Color = ServerMessage.ColorWhite, + Delivery = ServerMessage.DeliveryPopup, + Audience = ServerMessage.AudienceAll, + CreatedUtc = DateTime.UtcNow, + CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N") + }; + + config.Messages.Add(stored); + ServerMessage.Prune(config.Messages); + MoonfinPlugin.Instance?.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + // SSE 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. + // backgrounded and killed apps. The route opens the messages window on tap. var pushTargets = 0; foreach (var userId in _notificationStore.GetUsersWithDevices()) { - _pushDelivery.QueueToUser(userId, "Message from your server", message, route: ""); + _pushDelivery.QueueToUser(userId, "Message from your server", message, route: "messages"); pushTargets++; } @@ -1525,6 +1543,13 @@ public class MoonfinPingResponse [JsonPropertyName("tmdbAvailable")] public bool? TmdbAvailable { get; set; } + /// + /// True when this plugin has the admin messages endpoints. Older plugins leave it out, so + /// the app reads a missing value as false and hides the button. + /// + [JsonPropertyName("messagesSupported")] + public bool? MessagesSupported { get; set; } + [JsonPropertyName("defaultSettings")] public MoonfinSettingsProfile? DefaultSettings { get; set; } diff --git a/Jellyfin/backend/Api/MoonfinMessagesController.cs b/Jellyfin/backend/Api/MoonfinMessagesController.cs new file mode 100644 index 0000000..a218aa9 --- /dev/null +++ b/Jellyfin/backend/Api/MoonfinMessagesController.cs @@ -0,0 +1,255 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moonfin.Server.Services; + +namespace Moonfin.Server.Api; + +/// +/// API controller for admin messages shown to users in the Moonfin app. +/// +[ApiController] +[Route("Moonfin")] +[Produces(MediaTypeNames.Application.Json)] +public class MoonfinMessagesController : ControllerBase +{ + private readonly MoonfinSettingsService _settingsService; + private readonly NotificationStore _notificationStore; + private readonly PushDeliveryService _pushDelivery; + + public MoonfinMessagesController( + MoonfinSettingsService settingsService, + NotificationStore notificationStore, + PushDeliveryService pushDelivery) + { + _settingsService = settingsService; + _notificationStore = notificationStore; + _pushDelivery = pushDelivery; + } + + /// + /// Returns the messages the calling user should see right now. + /// + [HttpGet("Messages")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public ActionResult GetMessages() + { + var config = MoonfinPlugin.Instance?.Configuration; + if (config?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Settings sync is disabled" }); + } + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(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 = this.IsAdminFromClaims(); + var now = DateTime.UtcNow; + var items = config.Messages + .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now)) + .ToList(); + + return Ok(new { items }); + } + + /// + /// Returns every message, including scheduled and expired ones, for the admin panel. + /// + [HttpGet("Admin/Messages")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetAdminMessages() + { + var messages = MoonfinPlugin.Instance?.Configuration.Messages ?? new List(); + + return Ok(new + { + items = messages + .ToList() + }); + } + + /// + /// Creates a message, or replaces one when the ID already exists. + /// + [HttpPost("Admin/Messages")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult SaveMessage([FromBody] ServerMessage message) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + if (message == null) + { + return BadRequest(new { error = "A message body is required" }); + } + + message.Sanitize(); + + if (message.Title.Length == 0 && message.Body.Length == 0) + { + return BadRequest(new { error = "A title or a body is required" }); + } + + var messages = plugin.Configuration.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 = this.GetUserIdFromClaims()?.ToString("N"); + // Newest first by default. The admin can move it with the arrows. + messages.Insert(0, message); + } + + ServerMessage.Prune(messages); + plugin.SaveConfiguration(); + + _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 Ok(new { success = true, item = message }); + } + + /// + /// 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. + /// + [HttpPost("Admin/Messages/Order")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult ReorderMessages([FromBody] MoonfinMessageOrderRequest request) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + var ids = request?.Ids; + if (ids == null || ids.Count == 0) + { + return BadRequest(new { error = "ids is required" }); + } + + var messages = plugin.Configuration.Messages; + var reordered = new List(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))); + + plugin.Configuration.Messages = reordered; + plugin.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + + return Ok(new { success = true }); + } + + /// + /// Deletes one message by ID. + /// + [HttpDelete("Admin/Messages/{messageId}")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteMessage([FromRoute] string messageId) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + var removed = plugin.Configuration.Messages + .RemoveAll(m => string.Equals(m.Id, messageId, StringComparison.OrdinalIgnoreCase)); + + if (removed == 0) + { + return NotFound(new { error = "Message not found" }); + } + + plugin.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + + return Ok(new { success = true }); + } + + /// + /// 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. + /// + private void SendPush(ServerMessage message) + { + // Admin-only messages get no push: we cannot tell who is an admin without the user + // manager, and admins still see them next time they open the app. + if (message.Audience == ServerMessage.AudienceAdmins) + { + return; + } + + var title = message.Title.Length > 0 ? message.Title : "Message from your server"; + var body = message.Body; + + foreach (var userId in _notificationStore.GetUsersWithDevices()) + { + if (message.Audience == ServerMessage.AudienceUsers && + !message.TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId)) + { + continue; + } + + _pushDelivery.QueueToUser(userId, title, body, route: "messages"); + } + } +} + +/// +/// Body for the message reorder endpoint. +/// +public class MoonfinMessageOrderRequest +{ + /// Message IDs in the order they should be shown. + public List Ids { get; set; } = new(); +} diff --git a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs index b78ee09..10c3949 100644 --- a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs +++ b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs @@ -848,6 +848,9 @@ public class MoonfinSettingsProfile [JsonPropertyName("crashReportsEnabled")] public bool? CrashReportsEnabled { get; set; } + [JsonPropertyName("showServerMessagesButton")] + public bool? ShowServerMessagesButton { get; set; } + [JsonPropertyName("diagnosticLoggingEnabled")] public bool? DiagnosticLoggingEnabled { get; set; } diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index af1c50c..bc04f76 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -374,6 +374,10 @@

Moonfin Settings

ThemesUpload & catalog +
+
+ + +
@@ -1696,6 +1708,119 @@

Uploaded Themes

+
+
+
+

Messages

+
+ Write news, updates or warnings for your users. They read them from the messages + button in the app menu. Messages are saved, so users still see them if the app was + closed when you posted. +
+ +
+ + +
+ +
+ + +
+ Formatting uses Markdown. Only CommonMark is supported: + syntax reference. + Images are not shown, even though that page lists them. +
+
+ +
+ + +
Colours the message in the app. The menu button stays neutral.
+
+ +
+ + +
+ Notify adds the message to the unread count on the menu button. Open makes the + message window come up on its own, once. Open also sends a phone notification, if + this server has push set up. +
+
+ +
+ + +
+ + + +
+ + +
Leave empty to show it right away.
+
+ +
+ + +
+ Leave empty to keep it forever. Messages past this date are deleted on the next save. +
+
+ +
+ + +
+ +
+ + +
+ Must start with http:// or https://. On TV the app shows it as a QR code. + A link needs a button text, and a button text needs a link. +
+
+ + +
+ + +
+ +
+ +
+

Saved Messages

+
+ Shown in this order in the app. Up to 50 are kept, then the oldest are dropped. +
+
+
+
@@ -1744,8 +1869,9 @@

Apply Defaults To Users

Broadcast Message

- Send a live message to all currently connected Moonfin clients. - This message is not saved and only appears for users with an active connection. + Send a message to all connected Moonfin clients right now. It is also saved to + the Messages tab, so users who were offline still see it later. + For anything you want to write properly, use the Messages tab instead.
@@ -3582,6 +3708,411 @@

Active Downloads

}); } + var moonfinEditingMessageId = null; + + var moonfinMessageColors = { + white: '#e8eaed', + green: '#4caf6d', + blue: '#4a9ee0', + yellow: '#e0b040', + red: '#e05260' + }; + + var moonfinDeliveryLabels = { + inbox: 'Notify', + popup: 'Opens in app' + }; + + function setMessageResult(text, color) { + var result = document.querySelector('#MessageSaveResult'); + if (!result) { + return; + } + + if (!text) { + result.style.display = 'none'; + result.textContent = ''; + return; + } + + result.style.display = ''; + result.style.color = color || 'rgba(255,255,255,0.75)'; + result.textContent = text; + } + + // The date inputs work in the admin's own time zone, but the server stores UTC. + function messageDateToInput(utcValue) { + if (!utcValue) { + return ''; + } + + var date = new Date(utcValue); + if (isNaN(date.getTime())) { + return ''; + } + + var pad = function(n) { return (n < 10 ? '0' : '') + n; }; + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + function messageDateFromInput(value) { + if (!value) { + return null; + } + + var date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + function formatMessageDate(utcValue) { + if (!utcValue) { + return ''; + } + + var date = new Date(utcValue); + return isNaN(date.getTime()) ? '' : date.toLocaleString(); + } + + function toggleMessageTargets() { + var audience = document.querySelector('#MessageAudience'); + var row = document.querySelector('#MessageTargetsRow'); + if (!audience || !row) { + return; + } + + row.style.display = audience.value === 'users' ? '' : 'none'; + } + + function loadMessageTargetUsers() { + var select = document.querySelector('#MessageTargets'); + if (!select || !ApiClient.getUsers) { + return Promise.resolve(); + } + + return ApiClient.getUsers().then(function(users) { + select.innerHTML = ''; + (users || []).forEach(function(user) { + var option = document.createElement('option'); + option.value = user.Id; + option.textContent = user.Name; + select.appendChild(option); + }); + }); + } + + function resetMessageForm() { + moonfinEditingMessageId = null; + + document.querySelector('#MessageTitle').value = ''; + document.querySelector('#MessageBody').value = ''; + document.querySelector('#MessageColor').value = 'white'; + document.querySelector('#MessageDelivery').value = 'inbox'; + document.querySelector('#MessageAudience').value = 'all'; + document.querySelector('#MessageStart').value = ''; + document.querySelector('#MessageEnd').value = ''; + document.querySelector('#MessageActionLabel').value = ''; + document.querySelector('#MessageActionUrl').value = ''; + + var targets = document.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function(option) { + option.selected = false; + }); + } + + var saveBtn = document.querySelector('#MessageSaveBtn'); + if (saveBtn) { + saveBtn.querySelector('span').textContent = 'Save Message'; + } + + var resetBtn = document.querySelector('#MessageResetBtn'); + if (resetBtn) { + resetBtn.style.display = 'none'; + } + + toggleMessageTargets(); + setMessageResult('', ''); + } + + function editMessage(item) { + moonfinEditingMessageId = item.id || item.Id || null; + + document.querySelector('#MessageTitle').value = item.title || item.Title || ''; + document.querySelector('#MessageBody').value = item.body || item.Body || ''; + document.querySelector('#MessageColor').value = item.color || item.Color || 'white'; + document.querySelector('#MessageDelivery').value = item.delivery || item.Delivery || 'inbox'; + document.querySelector('#MessageAudience').value = item.audience || item.Audience || 'all'; + document.querySelector('#MessageStart').value = messageDateToInput(item.startUtc || item.StartUtc); + document.querySelector('#MessageEnd').value = messageDateToInput(item.endUtc || item.EndUtc); + document.querySelector('#MessageActionLabel').value = item.actionLabel || item.ActionLabel || ''; + document.querySelector('#MessageActionUrl').value = item.actionUrl || item.ActionUrl || ''; + + var selected = item.targetUserIds || item.TargetUserIds || []; + var targets = document.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function(option) { + option.selected = selected.some(function(id) { + return String(id).toLowerCase() === String(option.value).toLowerCase(); + }); + }); + } + + var saveBtn = document.querySelector('#MessageSaveBtn'); + if (saveBtn) { + saveBtn.querySelector('span').textContent = 'Update Message'; + } + + var resetBtn = document.querySelector('#MessageResetBtn'); + if (resetBtn) { + resetBtn.style.display = ''; + } + + toggleMessageTargets(); + setMessageResult('', ''); + + var titleInput = document.querySelector('#MessageTitle'); + if (titleInput) { + titleInput.focus(); + } + } + + function renderMessagesList(items) { + var container = document.querySelector('#MessagesList'); + if (!container) { + return; + } + + if (!items || items.length === 0) { + container.innerHTML = '
No messages yet.
'; + return; + } + + var now = new Date(); + var html = ''; + + items.forEach(function(item, index) { + var id = item.id || item.Id || ''; + var title = item.title || item.Title || ''; + var body = item.body || item.Body || ''; + var pick = item.color || item.Color || 'white'; + var delivery = item.delivery || item.Delivery || 'inbox'; + var audience = item.audience || item.Audience || 'all'; + var start = item.startUtc || item.StartUtc; + var end = item.endUtc || item.EndUtc; + var targets = item.targetUserIds || item.TargetUserIds || []; + var color = moonfinMessageColors[pick] || moonfinMessageColors.white; + + var state = 'Showing now'; + if (start && new Date(start) > now) { + state = 'Starts ' + formatMessageDate(start); + } else if (end && new Date(end) <= now) { + state = 'Expired'; + } else if (end) { + state = 'Until ' + formatMessageDate(end); + } + + var who = audience === 'admins' + ? 'Admins only' + : audience === 'users' + ? targets.length + (targets.length === 1 ? ' user' : ' users') + : 'Everyone'; + + html += '
' + + '
' + + '
' + + '' + esc(title || '(no title)') + '' + + '
' + + '
' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '
' + + '
' + + esc(state) + ' • ' + esc(who) + ' • ' + esc(moonfinDeliveryLabels[delivery] || delivery) + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
'; + }); + + container.innerHTML = html; + } + + function loadMessagesList() { + var container = document.querySelector('#MessagesList'); + if (!container) { + return; + } + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + container.innerHTML = '
Loading messages...
'; + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'GET', + headers: getMoonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function(payload) { + var items = payload.items || payload.Items || []; + window.moonfinMessagesCache = items; + renderMessagesList(items); + }) + .catch(function(error) { + container.innerHTML = '
' + + esc((error && error.message) ? error.message : 'Failed to load messages.') + '
'; + }); + } + + // Moves one message up or down and saves the whole order, so the app shows + // them in the same sequence as this list. + function moveMessage(messageId, delta) { + var items = (window.moonfinMessagesCache || []).slice(); + var from = -1; + for (var i = 0; i < items.length; i++) { + if ((items[i].id || items[i].Id) === messageId) { from = i; break; } + } + + var to = from + delta; + if (from < 0 || to < 0 || to >= items.length) return; + + var moved = items.splice(from, 1)[0]; + items.splice(to, 0, moved); + + // Redrawn straight away so the arrows feel instant, then saved. + window.moonfinMessagesCache = items; + renderMessagesList(items); + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + fetch(serverUrl + '/Moonfin/Admin/Messages/Order', { + method: 'POST', + headers: getMoonfinAuthHeaders(), + body: JSON.stringify({ + ids: items.map(function (item) { return item.id || item.Id; }) + }) + }) + .then(parseJsonResponse) + .catch(function (error) { + setMessageResult( + (error && error.message) ? error.message : 'Could not save the new order.', + '#d9534f' + ); + loadMessagesList(); + }); + } + + function saveMessage() { + var saveBtn = document.querySelector('#MessageSaveBtn'); + var title = (document.querySelector('#MessageTitle').value || '').trim(); + var body = (document.querySelector('#MessageBody').value || '').trim(); + + if (!title && !body) { + setMessageResult('Enter a title or a message.', '#d9534f'); + return; + } + + var audience = document.querySelector('#MessageAudience').value; + var targetSelect = document.querySelector('#MessageTargets'); + var targetUserIds = []; + if (audience === 'users' && targetSelect) { + targetUserIds = Array.prototype.filter.call(targetSelect.options, function(option) { + return option.selected; + }).map(function(option) { + return option.value; + }); + + if (targetUserIds.length === 0) { + setMessageResult('Pick at least one user, or change who sees it.', '#d9534f'); + return; + } + } + + var payload = { + id: moonfinEditingMessageId || '', + title: title, + body: body, + color: document.querySelector('#MessageColor').value, + delivery: document.querySelector('#MessageDelivery').value, + audience: audience, + targetUserIds: targetUserIds, + actionLabel: (document.querySelector('#MessageActionLabel').value || '').trim(), + actionUrl: (document.querySelector('#MessageActionUrl').value || '').trim(), + startUtc: messageDateFromInput(document.querySelector('#MessageStart').value), + endUtc: messageDateFromInput(document.querySelector('#MessageEnd').value) + }; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + var wasEditing = !!moonfinEditingMessageId; + + saveBtn.disabled = true; + setMessageResult('', ''); + Dashboard.showLoadingMsg(); + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'POST', + headers: getMoonfinAuthHeaders(), + body: JSON.stringify(payload) + }) + .then(parseJsonResponse) + .then(function(response) { + var saved = response.item || response.Item || {}; + var warnings = []; + + if (payload.actionUrl && !(saved.actionUrl || saved.ActionUrl)) { + warnings.push('the link was dropped, it must start with http:// or https://'); + } + if (payload.endUtc && !(saved.endUtc || saved.EndUtc)) { + warnings.push('the end date was dropped, it was before the start date'); + } + + var message = wasEditing ? 'Message updated.' : 'Message saved.'; + if (warnings.length) { + message += ' Note: ' + warnings.join(', ') + '.'; + } + + // resetMessageForm clears the result line, so set it afterwards. + resetMessageForm(); + setMessageResult(message, warnings.length ? '#f0ad4e' : '#52b54b'); + loadMessagesList(); + }) + .catch(function(error) { + setMessageResult((error && error.message) ? error.message : 'Save failed.', '#d9534f'); + }) + .finally(function() { + saveBtn.disabled = false; + Dashboard.hideLoadingMsg(); + }); + } + + function deleteMessage(messageId) { + if (!messageId) { + return; + } + + if (!window.confirm('Delete this message?')) { + return; + } + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + + fetch(serverUrl + '/Moonfin/Admin/Messages/' + encodeURIComponent(messageId), { + method: 'DELETE', + headers: getMoonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function() { + if (moonfinEditingMessageId === messageId) { + resetMessageForm(); + } + setMessageResult('Message deleted.', '#52b54b'); + loadMessagesList(); + }) + .catch(function(error) { + setMessageResult((error && error.message) ? error.message : 'Delete failed.', '#d9534f'); + }); + } + function ensureMoonfinAdminRuntimeStyles() { var source = document.getElementById('MoonfinAdminRuntimeStyleSource'); if (!source || !source.textContent) { @@ -3731,6 +4262,66 @@

Active Downloads

setThemeUploadResult('', ''); loadAdminThemesList(); + var messageAudience = document.querySelector('#MessageAudience'); + if (messageAudience && !messageAudience.dataset.bound) { + messageAudience.dataset.bound = 'true'; + messageAudience.addEventListener('change', toggleMessageTargets); + } + + var messageSaveBtn = document.querySelector('#MessageSaveBtn'); + if (messageSaveBtn && !messageSaveBtn.dataset.bound) { + messageSaveBtn.dataset.bound = 'true'; + messageSaveBtn.addEventListener('click', saveMessage); + } + + var messageResetBtn = document.querySelector('#MessageResetBtn'); + if (messageResetBtn && !messageResetBtn.dataset.bound) { + messageResetBtn.dataset.bound = 'true'; + messageResetBtn.addEventListener('click', resetMessageForm); + } + + var messagesListContainer = document.querySelector('#MessagesList'); + if (messagesListContainer && !messagesListContainer.dataset.bound) { + messagesListContainer.dataset.bound = 'true'; + messagesListContainer.addEventListener('click', function(event) { + var upButton = event.target.closest('.moonfinMessageUpBtn'); + if (upButton) { + moveMessage(upButton.getAttribute('data-message-id') || '', -1); + return; + } + + var downButton = event.target.closest('.moonfinMessageDownBtn'); + if (downButton) { + moveMessage(downButton.getAttribute('data-message-id') || '', 1); + return; + } + + var editButton = event.target.closest('.moonfinMessageEditBtn'); + if (editButton) { + var editId = editButton.getAttribute('data-message-id') || ''; + var cached = window.moonfinMessagesCache || []; + var found = cached.filter(function(item) { + return (item.id || item.Id) === editId; + })[0]; + if (found) { + editMessage(found); + } + return; + } + + var deleteButton = event.target.closest('.moonfinMessageDeleteBtn'); + if (deleteButton) { + deleteMessage(deleteButton.getAttribute('data-message-id') || ''); + } + }); + } + + // The user list must be there before the form can preselect targets on edit. + loadMessageTargetUsers().then(function() { + resetMessageForm(); + loadMessagesList(); + }); + Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function(config) { document.querySelector('#EnableSettingsSync').checked = config.EnableSettingsSync; @@ -3794,6 +4385,7 @@

Active Downloads

setNullableBoolSelect('#DefaultNavbarAlwaysExpanded', defaults.navbarAlwaysExpanded); setNullableBoolSelect('#DefaultEnableFolderView', defaults.enableFolderView); setNullableBoolSelect('#DefaultShowSeerrButton', defaults.showSeerrButton); + setNullableBoolSelect('#DefaultShowServerMessagesButton', defaults.showServerMessagesButton); setSelectValue('#DefaultMediaBarSourceType', defaults.mediaBarSourceType, 'Configured source'); loadAdminGenrePicker(defaults.mediaBarExcludedGenres || []); @@ -4175,6 +4767,7 @@

Active Downloads

config.DefaultUserSettings.navbarAlwaysExpanded = getNullableBoolSelect('#DefaultNavbarAlwaysExpanded'); config.DefaultUserSettings.enableFolderView = getNullableBoolSelect('#DefaultEnableFolderView'); config.DefaultUserSettings.showSeerrButton = getNullableBoolSelect('#DefaultShowSeerrButton'); + config.DefaultUserSettings.showServerMessagesButton = getNullableBoolSelect('#DefaultShowServerMessagesButton'); config.DefaultUserSettings.mediaBarSourceType = document.querySelector('#DefaultMediaBarSourceType').value || null; var genreIds = []; diff --git a/Jellyfin/backend/PluginConfiguration.cs b/Jellyfin/backend/PluginConfiguration.cs index 4d88d1e..2c07b94 100644 --- a/Jellyfin/backend/PluginConfiguration.cs +++ b/Jellyfin/backend/PluginConfiguration.cs @@ -205,6 +205,12 @@ public class PluginConfiguration : BasePluginConfiguration /// public List UploadedThemes { get; set; } = new(); + /// + /// Admin messages shown to users in the app. They are only a few KB of text, so they live + /// in the config instead of the data folder. + /// + public List Messages { get; set; } = new(); + // --------------------------------------------------------------------- // Retro games (EmulatorJS) configuration // --------------------------------------------------------------------- @@ -371,3 +377,197 @@ public class UploadedThemeEntry public string? UploadedByUserId { get; set; } public string ChecksumSha256 { get; set; } = string.Empty; } + +/// +/// One message the admin wrote for users to read in the app. +/// +public class ServerMessage +{ + /// Highest number of messages kept. Older ones are dropped on save. + public const int MaxStored = 50; + + /// Body length cap, so a huge paste cannot break the app layout. + public const int MaxBodyLength = 2000; + + /// Title length cap, the same limit the config page puts on its field. + public const int MaxTitleLength = 120; + + public const string ColorGreen = "green"; + public const string ColorRed = "red"; + public const string ColorYellow = "yellow"; + public const string ColorBlue = "blue"; + public const string ColorWhite = "white"; + + /// Only marks the menu button as unread. + public const string DeliveryInbox = "inbox"; + + /// Opens the message window once, until the user reads it. + public const string DeliveryPopup = "popup"; + + public const string AudienceAll = "all"; + public const string AudienceUsers = "users"; + public const string AudienceAdmins = "admins"; + + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string Color { get; set; } = ColorWhite; + public string Delivery { get; set; } = DeliveryInbox; + public string? ActionLabel { get; set; } + public string? ActionUrl { get; set; } + + /// When the message starts showing. Null means right away. + public DateTime? StartUtc { get; set; } + + /// When the message stops showing. Null means never. + public DateTime? EndUtc { get; set; } + + public string Audience { get; set; } = AudienceAll; + + /// User IDs to show this to. Only used when is "users". + public List TargetUserIds { get; set; } = new(); + + public DateTime CreatedUtc { get; set; } + public string? CreatedByUserId { get; set; } + + /// + /// True when this message should show right now, for this user. + /// + public bool IsVisibleTo(Guid userId, bool isAdmin, DateTime nowUtc) + { + if (StartUtc.HasValue && nowUtc < StartUtc.Value) + { + return false; + } + + if (EndUtc.HasValue && nowUtc >= EndUtc.Value) + { + return false; + } + + return Audience switch + { + AudienceAdmins => isAdmin, + AudienceUsers => TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId), + _ => true + }; + } + + /// + /// Cleans admin input before it is saved. Bad values fall back to the default instead of + /// being rejected, except the action URL which is dropped when it is not http or https. + /// + public void Sanitize() + { + Title = (Title ?? string.Empty).Trim(); + Body = (Body ?? string.Empty).Trim(); + + if (Body.Length > MaxBodyLength) + { + Body = Body.Substring(0, MaxBodyLength); + } + + if (Title.Length > MaxTitleLength) + { + Title = Title.Substring(0, MaxTitleLength); + } + + Color = Color switch + { + ColorGreen => ColorGreen, + ColorRed => ColorRed, + ColorYellow => ColorYellow, + ColorBlue => ColorBlue, + _ => ColorWhite + }; + + Delivery = Delivery == DeliveryPopup ? DeliveryPopup : DeliveryInbox; + + Audience = Audience switch + { + AudienceUsers => AudienceUsers, + AudienceAdmins => AudienceAdmins, + _ => AudienceAll + }; + + if (Audience != AudienceUsers) + { + TargetUserIds = new List(); + } + else + { + TargetUserIds = TargetUserIds + .Where(id => Guid.TryParse(id, out _)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + ActionLabel = string.IsNullOrWhiteSpace(ActionLabel) ? null : ActionLabel.Trim(); + ActionUrl = SanitizeActionUrl(ActionUrl); + + // A link with no label is useless to the user, and a label with no link does nothing. + if (ActionUrl == null) + { + ActionLabel = null; + } + else if (ActionLabel == null) + { + ActionUrl = null; + } + + // An end date before the start date would hide the message forever. + if (StartUtc.HasValue && EndUtc.HasValue && EndUtc.Value <= StartUtc.Value) + { + EndUtc = null; + } + } + + /// + /// Keeps only real http and https links. Anything else is dropped, since this URL gets + /// opened on the user's device. + /// + private static string? SanitizeActionUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var value = rawUrl.Trim().Trim('"', '\'').Trim(); + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + + // AbsoluteUri keeps the escaping the admin typed, where ToString would decode it. + return uri.AbsoluteUri; + } + + /// + /// Drops expired messages, then the oldest ones if the list is still too long. Keeps the + /// config file from growing forever without needing a scheduled task. + /// + public static void Prune(List messages) + { + var now = DateTime.UtcNow; + messages.RemoveAll(m => m.EndUtc.HasValue && m.EndUtc.Value < now); + + if (messages.Count <= MaxStored) + { + return; + } + + var extra = messages + .OrderBy(m => m.CreatedUtc) + .Take(messages.Count - MaxStored) + .ToList(); + + foreach (var message in extra) + { + messages.Remove(message); + } + } +} diff --git a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs new file mode 100644 index 0000000..29b3022 --- /dev/null +++ b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs @@ -0,0 +1,254 @@ +using Moonfin.Server; +using Moonfin.Server.Api; +using Xunit; + +namespace Moonfin.Server.Tests; + +public class ServerMessageTests +{ + private static readonly DateTime Now = new(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc); + private static readonly Guid Alice = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid Bob = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + private static ServerMessage Message() => new() + { + Id = "m1", + Title = "Hello", + Body = "Body", + CreatedUtc = Now + }; + + [Fact] + public void IsVisibleTo_ShowsPlainMessageToEveryone() + { + var message = Message(); + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Bob, isAdmin: true, Now)); + } + + [Fact] + public void IsVisibleTo_HidesMessageBeforeItStarts() + { + var message = Message(); + message.StartUtc = Now.AddHours(1); + + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(2))); + } + + [Fact] + public void IsVisibleTo_HidesMessageOnceItEnds() + { + var message = Message(); + message.EndUtc = Now.AddHours(1); + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(1))); + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(2))); + } + + [Fact] + public void IsVisibleTo_AdminsOnlyMessageStaysHiddenFromUsers() + { + var message = Message(); + message.Audience = ServerMessage.AudienceAdmins; + + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Alice, isAdmin: true, Now)); + } + + [Fact] + public void IsVisibleTo_TargetedMessageReachesOnlyItsTargets() + { + var message = Message(); + message.Audience = ServerMessage.AudienceUsers; + message.TargetUserIds = new List { Alice.ToString() }; + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.False(message.IsVisibleTo(Bob, isAdmin: false, Now)); + + // Being an admin does not grant access to someone else's message. + Assert.False(message.IsVisibleTo(Bob, isAdmin: true, Now)); + } + + [Fact] + public void Sanitize_FallsBackToDefaultsOnUnknownValues() + { + var message = Message(); + message.Color = "chartreuse"; + message.Delivery = "carrier-pigeon"; + message.Audience = "nobody"; + + message.Sanitize(); + + Assert.Equal(ServerMessage.ColorWhite, message.Color); + Assert.Equal(ServerMessage.DeliveryInbox, message.Delivery); + Assert.Equal(ServerMessage.AudienceAll, message.Audience); + } + + [Theory] + [InlineData(ServerMessage.ColorGreen)] + [InlineData(ServerMessage.ColorRed)] + [InlineData(ServerMessage.ColorYellow)] + [InlineData(ServerMessage.ColorBlue)] + [InlineData(ServerMessage.ColorWhite)] + public void Sanitize_KeepsEveryColourTheAdminCanPick(string colour) + { + var message = Message(); + message.Color = colour; + + message.Sanitize(); + + Assert.Equal(colour, message.Color); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("file:///etc/passwd")] + [InlineData("moonfin://open")] + [InlineData("not a url")] + public void Sanitize_DropsLinksThatAreNotHttp(string url) + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = url; + + message.Sanitize(); + + Assert.Null(message.ActionUrl); + Assert.Null(message.ActionLabel); + } + + [Fact] + public void Sanitize_KeepsHttpAndHttpsLinks() + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = "https://example.com/news"; + + message.Sanitize(); + + Assert.Equal("https://example.com/news", message.ActionUrl); + Assert.Equal("Open", message.ActionLabel); + } + + [Fact] + public void Sanitize_DropsLabelWithoutLinkAndLinkWithoutLabel() + { + var labelOnly = Message(); + labelOnly.ActionLabel = "Open"; + labelOnly.Sanitize(); + Assert.Null(labelOnly.ActionLabel); + + var linkOnly = Message(); + linkOnly.ActionUrl = "https://example.com"; + linkOnly.Sanitize(); + Assert.Null(linkOnly.ActionUrl); + } + + [Fact] + public void Sanitize_CutsBodyToTheCap() + { + var message = Message(); + message.Body = new string('x', ServerMessage.MaxBodyLength + 500); + + message.Sanitize(); + + Assert.Equal(ServerMessage.MaxBodyLength, message.Body.Length); + } + + [Fact] + public void Sanitize_CutsTitleToTheCap() + { + var message = Message(); + message.Title = new string('x', ServerMessage.MaxTitleLength + 50); + + message.Sanitize(); + + Assert.Equal(ServerMessage.MaxTitleLength, message.Title.Length); + } + + [Fact] + public void Sanitize_KeepsTheEscapingInALink() + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = "https://example.com/a%20b?q=caf%C3%A9"; + + message.Sanitize(); + + Assert.Equal("https://example.com/a%20b?q=caf%C3%A9", message.ActionUrl); + } + + [Fact] + public void Sanitize_ClearsTargetsWhenAudienceIsNotUsers() + { + var message = Message(); + message.Audience = ServerMessage.AudienceAll; + message.TargetUserIds = new List { Alice.ToString() }; + + message.Sanitize(); + + Assert.Empty(message.TargetUserIds); + } + + [Fact] + public void Sanitize_DropsTargetsThatAreNotUserIds() + { + var message = Message(); + message.Audience = ServerMessage.AudienceUsers; + message.TargetUserIds = new List { Alice.ToString(), "everyone" }; + + message.Sanitize(); + + Assert.Equal(new[] { Alice.ToString() }, message.TargetUserIds); + } + + [Fact] + public void Sanitize_ClearsEndDateThatWouldHideTheMessageForever() + { + var message = Message(); + message.StartUtc = Now; + message.EndUtc = Now.AddHours(-1); + + message.Sanitize(); + + Assert.Null(message.EndUtc); + } + + [Fact] + public void PruneMessages_RemovesExpiredMessages() + { + var fresh = Message(); + var expired = Message(); + expired.Id = "old"; + expired.EndUtc = DateTime.UtcNow.AddDays(-1); + + var messages = new List { fresh, expired }; + ServerMessage.Prune(messages); + + Assert.Equal(new[] { "m1" }, messages.Select(m => m.Id)); + } + + [Fact] + public void PruneMessages_DropsTheOldestFirst() + { + var messages = new List(); + + for (var i = 0; i < ServerMessage.MaxStored + 5; i++) + { + var message = Message(); + message.Id = $"m{i}"; + message.CreatedUtc = Now.AddMinutes(i); + messages.Add(message); + } + + ServerMessage.Prune(messages); + + Assert.Equal(ServerMessage.MaxStored, messages.Count); + Assert.DoesNotContain(messages, m => m.Id == "m0"); + Assert.DoesNotContain(messages, m => m.Id == "m4"); + Assert.Contains(messages, m => m.Id == "m5"); + } +}