Valdis Iljuconoks previously helped me understand how to effectively implement AllowedTypes restrictions with interfaces, something like [AllowedTypes(typeof(INestedContent))]
– which is a beautiful solution for building a block library. This makes our blocks and their Content Areas only concern themselves with specific interfaces. In our case, we usually have layers such as IPageContent
(for stripes, grid structures, etc), INestedContent
(for heroes, teasers, forms, maps, videos, and the like), and ICallToAction
(for buttons, image buttons, etc). This is incredibly useful if you don’t want all of your blocks to know about all of your other blocks, or if your blocks live across independent feature projects.
This solution, however, starts to show some weaknesses when you move to a multisite implementation with a shared block layer. In a lot of cases, we will ultimately build blocks that are specific to a given website. In a SCORE solution, for instance, these blocks would implement IPageContent
or INestedContent
interface so that they plug-and-play nicely with the common block layer. The problem arises, however, when you want to restrict blocks to SiteA and not allow them to be created on SiteB and SiteC.
To set this scenario up, Valdis again has a very nice blog series on MVC Area support in Episerver. If this concept is new to you, I highly recommend you read up here and here.
To extend this set up, what I needed was the ability to decorate my globally shared blocks with a new attribute: [RestrictTo(new [] { "SiteA", "SiteX" })]
. If the editor is on a page for SiteA or SiteX, then they should be able to insert a block of this type. If the editor is on SiteB or SiteC, then this block should not be an available option. From everything I can tell, the only out-of-the-box mechanism to support this in Episerver is to restrict blocks by user role. This wouldn’t work effectively in my case (and in a lot of cases), because our user roles don’t always correspond 1:1 with websites. So how do we do it?
A little code
First, let’s define the attribute in question. It’s quite simple:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public class RestrictToAttribute : Attribute { public string[] Sites { get; set; } public RestrictToAttribute(string[] sites) { Sites = sites; } }
Secondly, to use this attribute, we should do something like this for our blocks (this also works for PageData if you need it):
[ContentType(...)] [RestrictTo(new[] { "SiteA" })] // move this to const somewhere in your solution public class SiteSpecificBlock : BlockData, INestedContentBlock { // some specific impl here }
From a consumer standpoint and as a developer entering into a project- this looks like a very friendly way to achieve site based restrictions. This is really easy to follow and easy to leverage. To actually make it work, what we need to do is shim in our own implementation of the EPiServer.DataAbstraction.Internal.DefaultContentTypeAvailablilityService
. Unfortunately this is part of Episerver’s internal API, so if you know of a better way to handle this, please leave a comment down below!
To shim in our own implementation, add this to your container configuration:
services.RemoveAll(typeof(ContentTypeAvailabilityService)); services.AddTransient<ContentTypeAvailabilityService, RestrictedContentTypeAvailabilityService>();
This will ensure that anyone who asks for ContentTypeAvailabilityService from the container will instead receive the RestrictedContentTypeAvailabilityService (even the Episerver runtime as it’s drawing the editing interface). From here, we just need to manipulate the list of types that Episerver’s service is returning back out, taking into consideration the RestrictTo
attribute. We’ll inherit Epi’s service and call their base method, with some special filtering layered overtop:
public class RestrictedContentTypeAvailabilityService : DefaultContentTypeAvailablilityService { private readonly IContentLoader _contentLoader; private readonly ISiteDefinitionResolver _siteDefinitionResolver; public RestrictedContentTypeAvailabilityService( ServiceAccessor<IContentTypeRepository> contentTypeRepositoryAccessor, IAvailableModelSettingsRepository modelRepository, IAvailableSettingsRepository typeSettingsRepository, GroupDefinitionRepository groupDefinitionRepository, IContentLoader contentLoader, ISynchronizedObjectInstanceCache cache, ISiteDefinitionResolver siteDefinitionResolver) : base(contentTypeRepositoryAccessor, modelRepository, typeSettingsRepository, groupDefinitionRepository, contentLoader, cache) { if (contentLoader == null) throw new ArgumentNullException("contentLoader"); if (siteDefinitionResolver == null) throw new ArgumentNullException("siteDefinitionResolver"); _contentLoader = contentLoader; _siteDefinitionResolver = siteDefinitionResolver; } // this method is called everytime "Add a new block" or "Add a new page" is called- it fetches the types available public override IList<ContentType> ListAvailable(IContent content, bool contentFolder, IPrincipal user) { var baseList = base.ListAvailable(content, contentFolder, user); return Filter(baseList, content).ToList(); } // to filter, simply look at each model type being returned and inspect if it has the RestrictTo attribute // if it does have the attribute, ensure that the SiteDefinition.Current is contained within the list of // allowed websites. If it is, allow the model to be returned, otherwise do not protected virtual IEnumerable<ContentType> Filter(IList<ContentType> contentTypes, IContent content) { var siteDefinition = content != null ? _siteDefinitionResolver.GetByContent(content.ContentLink, false, false) : SiteDefinition.Current; foreach (var targetType in contentTypes) { if (siteDefinition == null) yield return targetType; var modelType = targetType.ModelType; if (modelType != null) { // attempt to fetch an instance of RestrictTo from the model var attributeVal = (RestrictToAttribute) Attribute.GetCustomAttribute(modelType, typeof(RestrictToAttribute)); if (attributeVal != null) { var currentSite = siteDefinition.Name; // compare current site context name against the list of sites in the attribute if (attributeVal.Sites.Any(x => x.Equals(currentSite, StringComparison.InvariantCultureIgnoreCase))) { yield return targetType; } } else { yield return targetType; } } else { yield return targetType; } } } }
Again, please let me know if there is a better way to handle this- but for now, this is the best solution I could find to the problem. Enjoy!
If I understand correct what you want to achieve, it can be solved using virtual roles. A virtual role can be evaluated at runtime. So, when an editor is editing site X, the editor can be in the group “isEditingSiteX”. But as soon as the editor is editing another site, the editor no longer belongs to that group.
Let me know if you’d like to see some sample code.