Periodically Unlock Items of Idle Users in Sitecore
In my last post I showed a way to unlock locked items of a user when he/she logs out of Sitecore.
I wrote that article to help out the poster of this thread in one of the forums on SDN.
In response to my reply in that thread — I had linked to my previous post in that reply — John West, Chief Technology Officer of Sitecore, had asked whether we would also want to unlock items of users whose sessions had expired, and another SDN user had alluded to the fact that my solution would not unlock items for users who had closed their browser sessions instead of explicitly logging out of Sitecore.
Immediate after reading these responses, I began thinking about a supplemental solution to unlock items for “idle” users — users who have not triggered any sort of request in Sitecore after a certain amount of time.
I first began tinkering with the idea of using the last activity date/time of the logged in user — this is available as a DateTime in the user’s MembershipUser instance via the LastActivityDate property.
However — after reading this article — I learned this date and time does not mean what I thought it had meant, and decided to search for another way to ascertain whether a user is idle in Sitecore.
After some digging around, I discovered Sitecore.Web.Authentication.DomainAccessGuard.Sessions in Sitecore.Kernel.dll — this appears to be a collection of sessions in Sitecore — and immediately felt elated as if I had just won the lottery. I decided to put it to use in the following class (code in this class will be invoked via a scheduled task in Sitecore):
using System; using System.Collections.Generic; using System.Web.Security; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Security.Accounts; using Sitecore.Tasks; using Sitecore.Web.Authentication; namespace Sitecore.Sandbox.Tasks { public class UnlockItemsTask { private static readonly TimeSpan ElapsedTimeWhenIdle = GetElapsedTimeWhenIdle(); public void UnlockIdleUserItems(Item[] items, CommandItem command, ScheduleItem schedule) { if (ElapsedTimeWhenIdle == TimeSpan.Zero) { return; } IEnumerable<Item> lockedItems = GetLockedItems(schedule.Database); foreach (Item lockedItem in lockedItems) { UnlockIfApplicable(lockedItem); } } private static IEnumerable<Item> GetLockedItems(Database database) { Assert.ArgumentNotNull(database, "database"); return database.SelectItems("fast://*[@__lock='%owner=%']"); } private void UnlockIfApplicable(Item item) { Assert.ArgumentNotNull(item, "item"); if (!ShouldUnlockItem(item)) { return; } Unlock(item); } private static bool ShouldUnlockItem(Item item) { Assert.ArgumentNotNull(item, "item"); if(!item.Locking.IsLocked()) { return false; } string owner = item.Locking.GetOwner(); return !IsUserAdmin(owner) && IsUserIdle(owner); } private static bool IsUserAdmin(string username) { Assert.ArgumentNotNullOrEmpty(username, "username"); User user = User.FromName(username, false); Assert.IsNotNull(user, "User must be null due to a wrinkle in the interwebs :-/"); return user.IsAdministrator; } private static bool IsUserIdle(string username) { Assert.ArgumentNotNullOrEmpty(username, "username"); DomainAccessGuard.Session userSession = DomainAccessGuard.Sessions.Find(session => session.UserName == username); if(userSession == null) { return true; } return userSession.LastRequest.Add(ElapsedTimeWhenIdle) <= DateTime.Now; } private void Unlock(Item item) { Assert.ArgumentNotNull(item, "item"); try { string owner = item.Locking.GetOwner(); item.Editing.BeginEdit(); item.Locking.Unlock(); item.Editing.EndEdit(); Log.Info(string.Format("Unlocked {0} - was locked by {1}", item.Paths.Path, owner), this); } catch (Exception ex) { Log.Error(this.ToString(), ex, this); } } private static TimeSpan GetElapsedTimeWhenIdle() { TimeSpan elapsedTimeWhenIdle; if (TimeSpan.TryParse(Settings.GetSetting("UnlockItems.ElapsedTimeWhenIdle"), out elapsedTimeWhenIdle)) { return elapsedTimeWhenIdle; } return TimeSpan.Zero; } } }
Methods in the class above grab all locked items in Sitecore via a fast query, and unlock them if the users of each are not administrators, and are idle — I determine this from an idle threshold value that is stored in a custom setting (see the patch configuration file below) and the last time the user had made any sort of request in Sitecore via his/her DomainAccessGuard.Session instance from Sitecore.Web.Authentication.DomainAccessGuard.Sessions.
If a DomainAccessGuard.Session instance does not exist for the user — it’s null — this means the user’s session had expired, so we should also unlock the item.
I’ve also included code to log which items have been unlocked by the Unlock method — for auditing purposes — and of course log exceptions if any are encountered — we must do all we can to support our support teams by capturing information in log files :).
I then created a patch configuration file to store our idle threshold value — I’ve used one minute here for testing (I can’t sit around all day waiting for items to unlock ;)):
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <settings> <setting name="UnlockItems.ElapsedTimeWhenIdle" value="00:00:01:00" /> </settings> </sitecore> </configuration>
I then created a task command for the class above in Sitecore:
I then mapped the task command to a scheduled task (to learn about scheduled tasks, see John West’s blog post where he discusses them):
Let’s light the fuse on this, and see what it does.
I logged into Sitecore using one of my test accounts, and locked some items:
I then logged into Sitecore using a different account in a different browser session, and navigated to one of the locked items:
I then walked away, made a cup of coffee, returned, and saw this:
I opened up my latest Sitecore log, and saw the following:
I do want to caution you from running off with this code, and putting it into your Sitecore instance(s) — it is an all or nothing solution (it will unlock items for all non-adminstrators which might invoke some anger in users, and also defeat the purpose of locking items in the first place), so it’s quite important that a business decision is made before using this solution, or one that is similar in nature.
If you have any thoughts on this, please leave a comment.
Unlock Sitecore Users’ Items During Logout
The other day I saw a post in one of the SDN forums asking how one could go about building a solution to unlock items locked by a user when he/she logs out of Sitecore.
What immediately came to mind was building a new processor for the logout pipeline — this pipeline can be found at /configuration/sitecore/processors/logout in your Sitecore instance’s Web.config — but had to research how one would programmatically get all Sitecore items locked by the current user.
After a bit of fishing in Sitecore.Kernel.dll and Sitecore.Client.dll, I found a query in Sitecore.Client.dll that will give me all locked items for the current user:
Now all we need to do is add it into a custom logout pipeline processor:
using System; using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Pipelines.Logout; namespace Sitecore.Sandbox.Pipelines.Logout { public class UnlockMyItems { public void Process(LogoutArgs args) { Assert.ArgumentNotNull(args, "args"); UnlockMyItemsIfAny(); } private void UnlockMyItemsIfAny() { IEnumerable<Item> lockedItems = GetMyLockedItems(); if (!CanProcess(lockedItems)) { return; } foreach (Item lockedItem in lockedItems) { Unlock(lockedItem); } } private static IEnumerable<Item> GetMyLockedItems() { return Context.ContentDatabase.SelectItems(GetMyLockedItemsQuery()); } private static string GetMyLockedItemsQuery() { return string.Format("fast://*[@__lock='%\"{0}\"%']", Context.User.Name); } private static bool CanProcess(IEnumerable<Item> lockedItems) { return lockedItems != null && lockedItems.Any() && lockedItems.Select(item => item.Locking.HasLock()).Any(); } private void Unlock(Item item) { Assert.ArgumentNotNull(item, "item"); if (!item.Locking.HasLock()) { return; } try { item.Editing.BeginEdit(); item.Locking.Unlock(); item.Editing.EndEdit(); } catch (Exception ex) { Log.Error(this.ToString(), ex, this); } } } }
The class above grabs all items locked by the current user in the context content database. If none are found, we don’t move forward on processing.
When there are locked items for the current user, the code checks to see if each item is locked before unlocking, just in case some other account unlocks the item before we unlock it — I don’t know what would happen if we try to unlock an item that isn’t locked. If you know, please share in a comment.
I then injected the above pipeline processor into the logout pipeline using the following patch configuration file:
<?xml version="1.0" encoding="utf-8" ?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <processors> <logout> <processor patch:after="*[@type='Sitecore.Pipelines.Logout.CheckModified, Sitecore.Kernel']" type="Sitecore.Sandbox.Pipelines.Logout.UnlockMyItems, Sitecore.Sandbox"/> </logout> </processors> </sitecore> </configuration>
Let’s test-drive this.
I first logged into Sitecore using my ‘mike’ account, and chose the Home item to lock:
It is now locked:
In another session, I logged in using another account, and saw that ‘mike’ had locked the Home item:
I switched back to the other session under the ‘mike’ user, and logged out:
When I logged back in, I saw that the Home item was no longer locked:
If you have any thoughts or suggestions on making this better, please share in a comment below.
Expand Tokens on Sitecore Items Using a Custom Command in Sitecore PowerShell Extensions
During my Sitecore from the Command Line presentation at the Sitecore User Group – New England, I had shown attendees how they could go about adding a custom command into the Sitecore PowerShell Extensions module.
This blog post shows what I had presented — although the code in this post is an improved version over what I had presented at my talk. Many thanks to Sitecore MVP Adam Najmanowicz for helping me make this code better!
The following command will expand “out of the box” tokens in all fields of a supplied Sitecore item — check out Expand Tokens on Sitecore Items Using a Custom Command in Revolver where I discuss the problem commands like this address, and this article by Sitecore MVP Jens Mikkelsen which lists “out of the box” tokens available in Sitecore:
using System; using System.Management.Automation; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Items; using Cognifide.PowerShell.PowerShellIntegrations.Commandlets; namespace CommandLineExtensions.PowerShell.Commandlets { [Cmdlet("Expand", "Token")] [OutputType(new[] { typeof(Item) })] public class ExpandTokenCommand : BaseCommand { private static readonly MasterVariablesReplacer TokenReplacer = Factory.GetMasterVariablesReplacer(); [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] public Item Item { get; set; } protected override void ProcessRecord() { Item.Editing.BeginEdit(); try { TokenReplacer.ReplaceItem(Item); Item.Editing.EndEdit(); } catch (Exception ex) { Item.Editing.CancelEdit(); throw ex; } WriteItem(Item); } } }
The command above subclasses Cognifide.PowerShell.PowerShellIntegrations.Commandlets.BaseCommand — the base class for most (if not all) commands in Sitecore PowerShell Extensions.
An item is passed to the command via a parameter, and is magically set on the Item property of the command class instance.
The ValueFromPipeline parameter being set to “true” on the Item property’s Parameter attribute will allow for chaining of this command with others so that items can be fed into it via a pipe bridging the commands together in PowerShell.
An instance of the Sitecore.Data.MasterVariablesReplacer class — which is created by the GetMasterVariablesReplacer() method of the Sitecore.Configuration.Factory class based on the “MasterVariablesReplacer” setting of your Sitecore instance’s Web.config — is used to expand tokens on the supplied Sitecore item after the item was flagged for editing.
Once tokens have been expanded on the item — or not in the event an exception is encountered — the item is written to the Results window via the WriteItem method which is defined in the BaseCommand class.
I then had to wire up the custom command via a patch configuration file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <powershell> <commandlets> <add Name="Custom Commandlets" type="*, CommandLineExtensions" /> </commandlets> </powershell> </sitecore> </configuration>
Let’s take this custom command for a spin.
I created a bunch of test items, and set tokens in their fields. I then selected the following page at random for testing:
I opened up the Integrated Scripting Environment of Sitecore PowerShell Extensions, typed in the following PowerShell code, and executed by pressing Ctrl-E:
As you can see tokens were expanded on the Page One item:
How about expanding tokens on all descendants of the Home item? Let’s see an example of how we can do that.
I chose the following content item — a grandchild of the Home item — for testing:
I switched back over to the Integrated Scripting Environment, wrote the following code for testing — the Get-ChildItem command with the -r parameter (this means do this recursively) will grab all descendants of the Home item, and pipe each item in the result set into the Expand-Token command — and clicked the Execute button:
I then went back to the grandchild item of the Home page in the content tree, and saw that tokens were expanded in its fields:
If you have any thoughts or comments on this, or ideas for new commands in Sitecore PowerShell Extensions, please share in a comment.
Until next time, have a scriptolicious day!
Expand Tokens on Sitecore Items Using a Custom Command in Revolver
On September 18, 2013, I presented Sitecore from the Command Line at the Sitecore User Group – New England.
During my presentation, I gave an example of creating a custom command in Revolver — the first scripting platform for Sitecore built by Alistair Deneys — and thought I would write something up for those who had missed the presentation, or wanted to revisit what I had shown.
One thing that plagues some Sitecore developers — if you disagree please leave a comment — is not having a nice way to expand tokens on items when tokens are added to Standard Values after items had been created previously.
Newly added tokens “bleed” into preexisting items’ fields, and I’ve seen developers perform crazy feats of acrobatic gymnastics to expand them — writing a standalone web form to recursive crawl the content tree to expand these is such an example (take a look at Empower Your Content Authors to Expand Standard Values Tokens in the Sitecore Client where I offer an alternative way to expand tokens on content items).
The following custom Revolver command will expand tokens on a supplied Sitecore item, and help out on the front of expanding newly added tokens on preexisting items:
using System; using Sitecore.Configuration; using System.Linq; using Sitecore.Data; using Sitecore.Data.Items; using Revolver.Core; using Revolver.Core.Commands; namespace CommandLineExtensions.Revolver.Commands { public class ExpandTokensCommand : BaseCommand { private static readonly MasterVariablesReplacer TokenReplacer = Factory.GetMasterVariablesReplacer(); public override string Description() { return "Expand tokens on an item"; } public override HelpDetails Help() { HelpDetails details = new HelpDetails { Description = Description(), Usage = "<cmd> [path]" }; details.AddExample("<cmd>"); details.AddExample("<cmd> /item1/item2"); return details; } public override CommandResult Run(string[] args) { string path = string.Empty; if (args.Any()) { path = args.FirstOrDefault(); } using (new ContextSwitcher(Context, path)) { if (!Context.LastGoodPath.EndsWith(path, StringComparison.CurrentCultureIgnoreCase)) { return new CommandResult ( CommandStatus.Failure, string.Format("Failed to expand tokens on item {0}\nReason:\n\n An item does not exist at that location!", path) ); } CommandResult result; Item item = Context.CurrentItem; item.Editing.BeginEdit(); try { TokenReplacer.ReplaceItem(item); result = new CommandResult(CommandStatus.Success, string.Concat("Expanded tokens on item ", Context.LastGoodPath)); item.Editing.EndEdit(); } catch (Exception ex) { item.Editing.CancelEdit(); result = new CommandResult(CommandStatus.Failure, string.Format("Failed to expand tokens on item {0}\nReason:\n\n{1}", path, ex)); } return result; } } } }
Tokens are expanded using an instance of the Sitecore.Data.MasterVariablesReplacer class — you can roll your own, and wire it up in the “MasterVariablesReplacer” setting of your Sitecore instance’s Web.config — which is provided by Sitecore.Configuration.Factory.GetMasterVariablesReplacer().
All custom commands in Revolver must implement the Revolver.Core.ICommand interface. I subclassed Revolver.Core.Commands.BaseCommand — which does implement this interface — since it seemed like the right thing to do given that all “out of the box” commands I saw in Revolver were subclassing it, and then implemented the Description(), Help() and Run() abstract methods.
I then had to bind the custom command to a new name — I chose “et” for “Expand Tokens”:
@echooff @stoponerror bind CommandLineExtensions.Revolver.Commands.ExpandTokensCommand,CommandLineExtensions et @echoon
Since it wouldn’t be efficient to type and run this bind script every time I want to use the “et” command, I added it into a startup script in the core database:
I then had to create a user script for the startup script to run. I chose the Everyone role here for demonstration purposes:
The above startup script will be invoked when Revolver is opened, and our custom command will be bound.
Let’s see all of the above in action.
I added some tokens in my home item:
I then opened up Revolver, navigated to /sitecore/content, and ran the custom command on the home item:
As you can see the tokens were expanded:
You might be thinking “that’s wonderful Mike — except now I have to navigate to every item in my content tree using Revolver, and then run this custom command on it”.
Well, I do have a solution for this: a custom script that grabs an item and all of its descendants using a Sitecore query, and passes them to the custom command to expand tokens:
@echooff @stoponerror if ($1$ = \$1\$) (exit (Missing required parameter path)) @echoon query -ns $1$/descendant-or-self::* et
I put this script in the core database, and named it “etr” for “Expand Tokens Recursively”:
I navigated to a descendant of /sitecore/content/home, and see that it has some unexpanded tokens on it:
I went back to Revolver, and ran the “etr” command on the home item:
As you can see tokens were expanded on the descendant item:
If you have any thoughts on this, or have ideas for other custom commands in Revolver, please share in a comment.
Shortcodes in Sitecore: A Proof of Concept
Today I stumbled upon a post in one of the SDN forums asking whether anyone had ever implemented shortcodes in Sitecore.
I have not seen an implementation of this for Sitecore — if you know of one, please drop a comment — but am quite familiar with these in WordPress — I use them to format code in my blog posts using the [code language=”csharp”]//code goes in here[/code] shortcode — and felt I should take on the challenge of implementing a “proof of concept” for this in Sitecore.
I first created a POCO that will hold shortcode data: the shortcode itself and the content (or markup) that the shortcode represents after being expanded:
namespace Sitecore.Sandbox.Pipelines.ExpandShortcodes { public class Shortcode { public string Unexpanded { get; set; } public string Expanded { get; set; } } }
I thought it would be best to put the logic that expands shortcodes into a new pipeline, and defined a pipeline arguments class for it:
using Sitecore.Pipelines; namespace Sitecore.Sandbox.Pipelines.ExpandShortcodes { public class ExpandShortcodesArgs : PipelineArgs { public string Content { get; set; } } }
There really isn’t much to this arguments class — we will only be passing around a string of content that will contain shortcodes to be expanded.
Before moving forward on building pipeline processors for the new pipeline, I saw that I could leverage the template method pattern to help me process collections of Shortcode instances in an abstract base class:
using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Pipelines.ExpandShortcodes { public abstract class ExpandShortcodesProcessor { public virtual void Process(ExpandShortcodesArgs args) { if (string.IsNullOrWhiteSpace(args.Content)) { return; } IEnumerable<Shortcode> shortcodes = GetShortcodes(args.Content); if (shortcodes == null || !shortcodes.Any()) { return; } args.Content = ExpandShortcodes(shortcodes, args.Content); } protected abstract IEnumerable<Shortcode> GetShortcodes(string content); protected virtual string ExpandShortcodes(IEnumerable<Shortcode> shortcodes, string content) { Assert.ArgumentNotNull(shortcodes, "shortcodes"); Assert.ArgumentNotNull(content, "content"); string contentExpanded = content; foreach (Shortcode shortcode in shortcodes) { contentExpanded = contentExpanded.Replace(shortcode.Unexpanded, shortcode.Expanded); } return contentExpanded; } } }
The above class iterates over all Shortcode instances, and replaces shortcodes with their expanded content.
Each subclass processor of ExpandShortcodesProcessor are to “fill in the blanks” of the algorithm defined in the base class by implementing the GetShortcodes method only — this is where the heavy lifting of grabbing the shortcodes from the passed string of content, and the expansion of these shortcodes are done. Both are then set in new Shortcode instances.
Once the base class was built, I developed an example ExpandShortcodesProcessor subclass to expand [BigBlueText]content goes in here[/BigBlueText] shortcodes (in case you’re wondering, I completely fabricated this shortcode — it does not exist in the real world):
using System.Collections.Generic; using System.Text.RegularExpressions; namespace Sitecore.Sandbox.Pipelines.ExpandShortcodes { public class ExpandBigBlueTextShortcodes : ExpandShortcodesProcessor { protected override IEnumerable<Shortcode> GetShortcodes(string content) { if(string.IsNullOrWhiteSpace(content)) { return new List<Shortcode>(); } IList<Shortcode> shortcodes = new List<Shortcode>(); MatchCollection matches = Regex.Matches(content, @"\[BigBlueText\](.*?)\[/BigBlueText\]", RegexOptions.IgnoreCase); foreach (Match match in matches) { string innerText = match.Groups[1].Value.Trim(); if (!string.IsNullOrWhiteSpace(innerText)) { shortcodes.Add ( new Shortcode { Unexpanded = match.Value, Expanded = string.Format(@"<span style=""font-size:56px;color:blue;"">{0}</span>", innerText) } ); } } return shortcodes; } } }
I followed the above example processor with another — a new one to expand [YouTube id=”video id goes in here”] shortcodes (this one is made up as well, although YouTube shortcodes do exist out in the wild):
using System.Collections.Generic; using System.Text.RegularExpressions; namespace Sitecore.Sandbox.Pipelines.ExpandShortcodes { public class ExpandYouTubeShortcodes : ExpandShortcodesProcessor { protected override IEnumerable<Shortcode> GetShortcodes(string content) { if(string.IsNullOrWhiteSpace(content)) { return new List<Shortcode>(); } IList<Shortcode> shortcodes = new List<Shortcode>(); MatchCollection matches = Regex.Matches(content, @"\[youtube\s+id=""(.*?)""\]", RegexOptions.IgnoreCase); foreach (Match match in matches) { string id = match.Groups[1].Value.Trim(); if (!string.IsNullOrWhiteSpace(id)) { shortcodes.Add ( new Shortcode { Unexpanded = match.Value, Expanded = string.Format(@"", id) } ); } } return shortcodes; } } }
Next I built a renderField pipeline processor to invoke our new pipeline when the field is a text field of some sort — yes, all fields in Sitecore are fundamentally strings behind the scenes but I’m referring to Single-Line Text, Multi-Line Text, Rich Text, and the deprecated text fields — to expand our shortcodes:
using Sitecore.Diagnostics; using Sitecore.Pipelines; using Sitecore.Pipelines.RenderField; using Sitecore.Sandbox.Pipelines.ExpandShortcodes; namespace Sitecore.Sandbox.Pipelines.RenderField { public class ExpandShortcodes { public void Process(RenderFieldArgs args) { if (!ShouldFieldBeProcessed(args)) { return; } args.Result.FirstPart = GetExpandedShortcodes(args.Result.FirstPart); } private static bool ShouldFieldBeProcessed(RenderFieldArgs args) { Assert.ArgumentNotNull(args, "args"); Assert.ArgumentNotNull(args.FieldTypeKey, "args.FieldTypeKey"); string fieldTypeKey = args.FieldTypeKey.ToLower(); return fieldTypeKey == "text" || fieldTypeKey == "rich text" || fieldTypeKey == "single-line text" || fieldTypeKey == "multi-line text"; } private static string GetExpandedShortcodes(string content) { Assert.ArgumentNotNull(content, "content"); ExpandShortcodesArgs args = new ExpandShortcodesArgs { Content = content }; CorePipeline.Run("expandShortcodes", args); return args.Content; } } }
I cemented all the pieces together using a Sitecore configuration file — this should go in your /App_Config/Include/ folder:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <expandShortcodes> <processor type="Sitecore.Sandbox.Pipelines.ExpandShortcodes.ExpandYouTubeShortcodes, Sitecore.Sandbox" /> <processor type="Sitecore.Sandbox.Pipelines.ExpandShortcodes.ExpandBigBlueTextShortcodes, Sitecore.Sandbox" /> </expandShortcodes> <renderField> <processor type="Sitecore.Sandbox.Pipelines.RenderField.ExpandShortcodes, Sitecore.Sandbox" patch:after="processor[@type='Sitecore.Pipelines.RenderField.GetTextFieldValue, Sitecore.Kernel']" /> </renderField> </pipelines> </sitecore> </configuration>
Let’s see the above code in action.
I created a test item, and added BigBlueText and YouTube shortcodes into two different text fields:
I saved, published, and then navigated to the test item:
As you can see, our shortcodes were expanded.
If you have any thoughts on this, or ideas around a better shortcode framework for Sitecore, please share in a comment.