using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Mail; using System.Text; using System.Threading; using System.Web; using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.Repositories; using Umbraco.Core.Scoping; using Umbraco.Core.Strings; namespace Umbraco.Core.Services.Implement { public class NotificationService : INotificationService { private readonly IScopeProvider _uowProvider; private readonly IUserService _userService; private readonly IContentService _contentService; private readonly INotificationsRepository _notificationsRepository; private readonly IGlobalSettings _globalSettings; private readonly ILogger _logger; public NotificationService(IScopeProvider provider, IUserService userService, IContentService contentService, ILogger logger, INotificationsRepository notificationsRepository, IGlobalSettings globalSettings) { _notificationsRepository = notificationsRepository; _globalSettings = globalSettings; _uowProvider = provider ?? throw new ArgumentNullException(nameof(provider)); _userService = userService ?? throw new ArgumentNullException(nameof(userService)); _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Sends the notifications for the specified user regarding the specified node and action. /// /// /// /// /// /// /// /// /// /// Currently this will only work for Content entities! /// public void SendNotifications(IUser operatingUser, IUmbracoEntity entity, string action, string actionName, HttpContextBase http, Func createSubject, Func createBody) { if (entity is IContent == false) throw new NotSupportedException(); var content = (IContent) entity; // lazily get previous version IContentBase prevVersion = null; // do not load *all* users in memory at once // do not load notifications *per user* (N+1 select) // cannot load users & notifications in 1 query (combination btw User2AppDto and User2NodeNotifyDto) // => get batches of users, get all their notifications in 1 query // re. users: // users being (dis)approved = not an issue, filtered in memory not in SQL // users being modified or created = not an issue, ordering by ID, as long as we don't *insert* low IDs // users being deleted = not an issue for GetNextUsers var id = 0; var nodeIds = content.Path.Split(',').Select(int.Parse).ToArray(); const int pagesz = 400; // load batches of 400 users do { // users are returned ordered by id, notifications are returned ordered by user id var users = ((UserService) _userService).GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList(); var notifications = GetUsersNotifications(users.Select(x => x.Id), action, nodeIds, Constants.ObjectTypes.Document).ToList(); if (notifications.Count == 0) break; var i = 0; foreach (var user in users) { // continue if there's no notification for this user if (notifications[i].UserId != user.Id) continue; // next user // lazy load prev version if (prevVersion == null) { prevVersion = GetPreviousVersion(entity.Id); } // queue notification var req = CreateNotificationRequest(operatingUser, user, content, prevVersion, actionName, http, createSubject, createBody); Enqueue(req); // skip other notifications for this user while (i < notifications.Count && notifications[i++].UserId == user.Id) ; if (i >= notifications.Count) break; // break if no more notifications } // load more users if any id = users.Count == pagesz ? users.Last().Id + 1 : -1; } while (id > 0); } /// /// Gets the previous version to the latest version of the content item if there is one /// /// /// private IContentBase GetPreviousVersion(int contentId) { // Regarding this: http://issues.umbraco.org/issue/U4-5180 // we know they are descending from the service so we know that newest is first // we are only selecting the top 2 rows since that is all we need var allVersions = _contentService.GetVersionIds(contentId, 2).ToList(); var prevVersionIndex = allVersions.Count > 1 ? 1 : 0; return _contentService.GetVersion(allVersions[prevVersionIndex]); } /// /// Sends the notifications for the specified user regarding the specified node and action. /// /// /// /// /// /// /// /// /// /// Currently this will only work for Content entities! /// public void SendNotifications(IUser operatingUser, IEnumerable entities, string action, string actionName, HttpContextBase http, Func createSubject, Func createBody) { if (entities is IEnumerable == false) throw new NotSupportedException(); var entitiesL = entities as List ?? entities.Cast().ToList(); //exit if there are no entities if (entitiesL.Count == 0) return; //put all entity's paths into a list with the same indicies var paths = entitiesL.Select(x => x.Path.Split(',').Select(int.Parse).ToArray()).ToArray(); // lazily get versions var prevVersionDictionary = new Dictionary(); // see notes above var id = 0; const int pagesz = 400; // load batches of 400 users do { // users are returned ordered by id, notifications are returned ordered by user id var users = ((UserService)_userService).GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList(); var notifications = GetUsersNotifications(users.Select(x => x.Id), action, Enumerable.Empty(), Constants.ObjectTypes.Document).ToList(); if (notifications.Count == 0) break; var i = 0; foreach (var user in users) { // continue if there's no notification for this user if (notifications[i].UserId != user.Id) continue; // next user for (var j = 0; j < entitiesL.Count; j++) { var content = entitiesL[j]; var path = paths[j]; // test if the notification applies to the path ie to this entity if (path.Contains(notifications[i].EntityId) == false) continue; // next entity if (prevVersionDictionary.ContainsKey(content.Id) == false) { prevVersionDictionary[content.Id] = GetPreviousVersion(content.Id); } // queue notification var req = CreateNotificationRequest(operatingUser, user, content, prevVersionDictionary[content.Id], actionName, http, createSubject, createBody); Enqueue(req); } // skip other notifications for this user, essentially this means moving i to the next index of notifications // for the next user. do { i++; } while (i < notifications.Count && notifications[i].UserId == user.Id); if (i >= notifications.Count) break; // break if no more notifications } // load more users if any id = users.Count == pagesz ? users.Last().Id + 1 : -1; } while (id > 0); } private IEnumerable GetUsersNotifications(IEnumerable userIds, string action, IEnumerable nodeIds, Guid objectType) { using (var scope = _uowProvider.CreateScope(autoComplete: true)) { return _notificationsRepository.GetUsersNotifications(userIds, action, nodeIds, objectType); } } /// /// Gets the notifications for the user /// /// /// public IEnumerable GetUserNotifications(IUser user) { using (var scope = _uowProvider.CreateScope(autoComplete: true)) { return _notificationsRepository.GetUserNotifications(user); } } /// /// Gets the notifications for the user based on the specified node path /// /// /// /// /// /// Notifications are inherited from the parent so any child node will also have notifications assigned based on it's parent (ancestors) /// public IEnumerable GetUserNotifications(IUser user, string path) { var userNotifications = GetUserNotifications(user); return FilterUserNotificationsByPath(userNotifications, path); } /// /// Filters a userNotifications collection by a path /// /// /// /// public IEnumerable FilterUserNotificationsByPath(IEnumerable userNotifications, string path) { var pathParts = path.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); return userNotifications.Where(r => pathParts.InvariantContains(r.EntityId.ToString(CultureInfo.InvariantCulture))).ToList(); } /// /// Deletes notifications by entity /// /// public IEnumerable GetEntityNotifications(IEntity entity) { using (var scope = _uowProvider.CreateScope(autoComplete: true)) { return _notificationsRepository.GetEntityNotifications(entity); } } /// /// Deletes notifications by entity /// /// public void DeleteNotifications(IEntity entity) { using (var scope = _uowProvider.CreateScope()) { _notificationsRepository.DeleteNotifications(entity); scope.Complete(); } } /// /// Deletes notifications by user /// /// public void DeleteNotifications(IUser user) { using (var scope = _uowProvider.CreateScope()) { _notificationsRepository.DeleteNotifications(user); scope.Complete(); } } /// /// Delete notifications by user and entity /// /// /// public void DeleteNotifications(IUser user, IEntity entity) { using (var scope = _uowProvider.CreateScope()) { _notificationsRepository.DeleteNotifications(user, entity); scope.Complete(); } } /// /// Sets the specific notifications for the user and entity /// /// /// /// /// /// This performs a full replace /// public IEnumerable SetNotifications(IUser user, IEntity entity, string[] actions) { using (var scope = _uowProvider.CreateScope()) { var notifications = _notificationsRepository.SetNotifications(user, entity, actions); scope.Complete(); return notifications; } } /// /// Creates a new notification /// /// /// /// The action letter - note: this is a string for future compatibility /// public Notification CreateNotification(IUser user, IEntity entity, string action) { using (var scope = _uowProvider.CreateScope()) { var notification = _notificationsRepository.CreateNotification(user, entity, action); scope.Complete(); return notification; } } #region private methods /// /// Sends the notification /// /// /// /// /// /// The action readable name - currently an action is just a single letter, this is the name associated with the letter /// /// Callback to create the mail subject /// Callback to create the mail body private NotificationRequest CreateNotificationRequest(IUser performingUser, IUser mailingUser, IContentBase content, IContentBase oldDoc, string actionName, HttpContextBase http, Func createSubject, Func createBody) { if (performingUser == null) throw new ArgumentNullException("performingUser"); if (mailingUser == null) throw new ArgumentNullException("mailingUser"); if (content == null) throw new ArgumentNullException("content"); if (http == null) throw new ArgumentNullException("http"); if (createSubject == null) throw new ArgumentNullException("createSubject"); if (createBody == null) throw new ArgumentNullException("createBody"); // build summary var summary = new StringBuilder(); var props = content.Properties.ToArray(); foreach (var p in props) { //fixme doesn't take into account variants var newText = p.GetValue() != null ? p.GetValue().ToString() : ""; var oldText = newText; // check if something was changed and display the changes otherwise display the fields if (oldDoc.Properties.Contains(p.PropertyType.Alias)) { var oldProperty = oldDoc.Properties[p.PropertyType.Alias]; oldText = oldProperty.GetValue() != null ? oldProperty.GetValue().ToString() : ""; // replace html with char equivalent ReplaceHtmlSymbols(ref oldText); ReplaceHtmlSymbols(ref newText); } // make sure to only highlight changes done using TinyMCE editor... other changes will be displayed using default summary // TODO: We should probably allow more than just tinymce?? if ((p.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.TinyMce) && string.CompareOrdinal(oldText, newText) != 0) { summary.Append(""); summary.Append(" Note: "); summary.Append( " Red for deleted characters Yellow for inserted characters"); summary.Append(""); summary.Append(""); summary.Append(" New "); summary.Append(p.PropertyType.Name); summary.Append(""); summary.Append(""); summary.Append(ReplaceLinks(CompareText(oldText, newText, true, false, "", string.Empty), http.Request)); summary.Append(""); summary.Append(""); summary.Append(""); summary.Append(" Old "); summary.Append(p.PropertyType.Name); summary.Append(""); summary.Append(""); summary.Append(ReplaceLinks(CompareText(newText, oldText, true, false, "", string.Empty), http.Request)); summary.Append(""); summary.Append(""); } else { summary.Append(""); summary.Append(""); summary.Append(p.PropertyType.Name); summary.Append(""); summary.Append(""); summary.Append(newText); summary.Append(""); summary.Append(""); } summary.Append( " "); } string protocol = _globalSettings.UseHttps ? "https" : "http"; string[] subjectVars = { string.Concat(http.Request.ServerVariables["SERVER_NAME"], ":", http.Request.Url.Port, IOHelper.ResolveUrl(SystemDirectories.Umbraco)), actionName, content.Name }; string[] bodyVars = { mailingUser.Name, actionName, content.Name, performingUser.Name, string.Concat(http.Request.ServerVariables["SERVER_NAME"], ":", http.Request.Url.Port, IOHelper.ResolveUrl(SystemDirectories.Umbraco)), content.Id.ToString(CultureInfo.InvariantCulture), summary.ToString(), string.Format("{2}://{0}/{1}", string.Concat(http.Request.ServerVariables["SERVER_NAME"], ":", http.Request.Url.Port), //TODO: RE-enable this so we can have a nice url /*umbraco.library.NiceUrl(documentObject.Id))*/ string.Concat(content.Id, ".aspx"), protocol) }; // create the mail message var mail = new MailMessage(UmbracoConfig.For.UmbracoSettings().Content.NotificationEmailAddress, mailingUser.Email); // populate the message mail.Subject = createSubject(mailingUser, subjectVars); if (UmbracoConfig.For.UmbracoSettings().Content.DisableHtmlEmail) { mail.IsBodyHtml = false; mail.Body = createBody(mailingUser, bodyVars); } else { mail.IsBodyHtml = true; mail.Body = string.Concat(@" ", createBody(mailingUser, bodyVars)); } // nh, issue 30724. Due to hardcoded http strings in resource files, we need to check for https replacements here // adding the server name to make sure we don't replace external links if (_globalSettings.UseHttps && string.IsNullOrEmpty(mail.Body) == false) { string serverName = http.Request.ServerVariables["SERVER_NAME"]; mail.Body = mail.Body.Replace( string.Format("http://{0}", serverName), string.Format("https://{0}", serverName)); } return new NotificationRequest(mail, actionName, mailingUser.Name, mailingUser.Email); } private string ReplaceLinks(string text, HttpRequestBase request) { var sb = new StringBuilder(_globalSettings.UseHttps ? "https://" : "http://"); sb.Append(request.ServerVariables["SERVER_NAME"]); sb.Append(":"); sb.Append(request.Url.Port); sb.Append("/"); var domain = sb.ToString(); text = text.Replace("href=\"/", "href=\"" + domain); text = text.Replace("src=\"/", "src=\"" + domain); return text; } /// /// Replaces the HTML symbols with the character equivalent. /// /// The old string. private static void ReplaceHtmlSymbols(ref string oldString) { oldString = oldString.Replace(" ", " "); oldString = oldString.Replace("’", "'"); oldString = oldString.Replace("&", "&"); oldString = oldString.Replace("“", "“"); oldString = oldString.Replace("”", "”"); oldString = oldString.Replace(""", "\""); } /// /// Compares the text. /// /// The old text. /// The new text. /// if set to true [display inserted text]. /// if set to true [display deleted text]. /// The inserted style. /// The deleted style. /// private static string CompareText(string oldText, string newText, bool displayInsertedText, bool displayDeletedText, string insertedStyle, string deletedStyle) { var sb = new StringBuilder(); var diffs = Diff.DiffText1(oldText, newText); int pos = 0; for (var n = 0; n < diffs.Length; n++) { var it = diffs[n]; // write unchanged chars while ((pos < it.StartB) && (pos < newText.Length)) { sb.Append(newText[pos]); pos++; } // while // write deleted chars if (displayDeletedText && it.DeletedA > 0) { sb.Append(deletedStyle); for (var m = 0; m < it.DeletedA; m++) { sb.Append(oldText[it.StartA + m]); } // for sb.Append(""); } // write inserted chars if (displayInsertedText && pos < it.StartB + it.InsertedB) { sb.Append(insertedStyle); while (pos < it.StartB + it.InsertedB) { sb.Append(newText[pos]); pos++; } // while sb.Append(""); } // if } // while // write rest of unchanged chars while (pos < newText.Length) { sb.Append(newText[pos]); pos++; } // while return sb.ToString(); } // manage notifications // ideally, would need to use IBackgroundTasks - but they are not part of Core! private static readonly object Locker = new object(); private static readonly BlockingCollection Queue = new BlockingCollection(); private static volatile bool _running; private void Enqueue(NotificationRequest notification) { Queue.Add(notification); if (_running) return; lock (Locker) { if (_running) return; Process(Queue); _running = true; } } private class NotificationRequest { public NotificationRequest(MailMessage mail, string action, string userName, string email) { Mail = mail; Action = action; UserName = userName; Email = email; } public MailMessage Mail { get; private set; } public string Action { get; private set; } public string UserName { get; private set; } public string Email { get; private set; } } private void Process(BlockingCollection notificationRequests) { ThreadPool.QueueUserWorkItem(state => { var s = new SmtpClient(); try { _logger.Debug("Begin processing notifications."); while (true) { NotificationRequest request; while (notificationRequests.TryTake(out request, 8 * 1000)) // stay on for 8s { try { if (Sendmail != null) Sendmail(s, request.Mail, _logger); else s.Send(request.Mail); _logger.Debug(() => string.Format("Notification \"{0}\" sent to {1} ({2})", request.Action, request.UserName, request.Email)); } catch (Exception ex) { _logger.Error("An error occurred sending notification", ex); s.Dispose(); s = new SmtpClient(); } finally { request.Mail.Dispose(); } } lock (Locker) { if (notificationRequests.Count > 0) continue; // last chance _running = false; // going down break; } } } finally { s.Dispose(); } _logger.Debug("Done processing notifications."); }); } // for tests internal static Action Sendmail; //= (_, msg, logger) => logger.Debug("Email " + msg.To.ToString()); #endregion } }