Multi-site Episerver Solutions using MVC Areas: Restricting Content Types

on February 23, 2021 in Episerver, Multi-site, Architecture, Development

Depending on your architecture and requirements for a multi-site solution in Episerver, you may need to completely separate the content types associated for each site within the editor experience. This was one of the considerations I mentioned when I wrote "Architecting Multi-site Episerver Solutions". Separating content types not only cleans up the editing experience, but also helps to streamline the page and block creation process, while also preventing editors from selecting the wrong content type (i.e. template) for the site.

To make this happen, there's multiple areas of the editing experience which we have to address differently to limit what content types are available to the editor.

This post is the fifth in a series of posts related to architecting and developing multi-site Episerver solutions using MVC Areas. In this post, I'll cover how we handle restricting content types.

This post builds off the previous posts in this series. If you haven't read those post yet, you should do so before continuing with this one.

In the remainder of this post, you'll see the same namespaces and class names defined in those previous posts.

Restricting Page Types in "New Page"

Likely the most visual area where editors will see content types is within the "New Page" screen. With no configuration in place, an editor will see all registered page types associated with the solution, which could include page types that should neither be available nor created multiple times. If you're working on a multi-site solution, the number of pages types can grow quickly with each site, and show those page types which are not meant for the current site.

AvailableInEditMode

The most basic way to restrict a content type is to use the AvailableInEditMode parameter on the ContentType attribute. While this works for both pages and blocks, we primarily use it only for pages.

namespace MySolution.Areas.SiteA.Models.Pages
{
    [ContentType(DisplayName = "Site A Start Page", GUID = "00000000-0000-0000-0000-000000000000",
        GroupName = SiteAConstants.AreaName,
        AvailableInEditMode = false)]
    public class StartPage : SiteABasePageData, IAreaStartPage
    {
        ...
    }
}

We commonly use this for page types that are meant to only have one page instance. For a CMS site, this may be something like the home page (StartPage), a login/register page, or a site settings page. For a Commerce site, it may be the cart or checkout page. The only minor drawback with this is we need to actually create the page before we set the parameter equal to false, otherwise we wouldn't be able to create the page at all! So maybe setting this parameter is something that is done closer to a production launch.

What this parameter doesn't do, however, is allow you to dynamically make content types available for a specific site. To do this, we'll need to use the AvailableContentTypes attribute.

AvailableContentTypes

We can restrict page types by adding the AvailableContentTypes attribute on the content type model. This is commonly used to support general site functionality; for example, a news list page may only allow article pages as its children, so it can accurately create the news listing.

For a multi-site solution, we can actually add the AvailableContentTypes attribute on the site's base class, and set it to only allow pages inherited from the site's base class:

namespace MySolution.Areas.SiteA.Models.Pages.Base
{
    [AvailableContentTypes(Include = new[] { typeof(SiteABasePageData) })]
    public abstract class SiteABasePageData : GlobalBasePageData
    {
        ...
    }
}

With this in place, the "New Page" screen will be significantly cleaned up and show only pages within the context of the current site being edited.

Restricting Block Types in "New Block"

For restricting block types, it's not as simple as decorating the base class. You would think you could also use AvailableContentTypes attribute here as well, but the issue is a block doesn't have a "root" page like the site's StartPage, and you can't add the content type restriction to the "For This Site" folder in the Assets pane. So we have to approach this from a different way.

Full credit for this approach goes to Dylan McCurry, who recently wrote a post called "Restricting available types based on site context in Episerver". Dylan's approach is to extend the service found at EPiServer.DataAbstraction.Internal.DefaultContentTypeAvailablilityService (notice the misspelling) to do an additional filter based on the current SiteDefinition.

One slight drawback with this approach is the service is part of Episerver's internal API, which means it's not part of the supported public API, and therefore can change between minor releases without prior warning. To me, this is a risk I'm willing to take.

With Dylan's approach, I made some changes to his example code to build off our multi-site architecture which we already have in place. I also slightly renamed it to RestrictContentType.

First, we need the attribute:

namespace MySolution.Business.RestrictContentType
{
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
    public class RestrictContentTypeAttribute : Attribute
    {
        public RestrictContentTypeAttribute(string[] areaNames)
        {
            AreaNames = areaNames;
        }

        public string[] AreaNames { get; set; }
    }
}

Then, we'll need the extended service implementation:

namespace MySolution.Business.RestrictContentType
{
    public class RestrictContentTypeAvailabilityService : DefaultContentTypeAvailablilityService
    {
        private readonly IContentLoader _contentLoader;
        private readonly ISiteDefinitionResolver _siteDefinitionResolver;

        public RestrictContentTypeAvailabilityService(
            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;
        }

        private Type[] _modelTypeExceptions = new Type[]
        {
            typeof(FormContainerBlock)
        };

        // This method is called every time "Add a new block" or "Add a new page" is called.
        // It fetches the content 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 RestrictContentType attribute
        // If it does have the attribute, ensure that the current site's SiteName 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)
        {
            foreach (var targetType in contentTypes)
            {
                var modelType = targetType.ModelType;

                if (modelType != null)
                {
                    if (_modelTypeExceptions.Contains(modelType))
                    {
                        yield return targetType;
                    }

                    // Attempt to fetch an instance of RestrictContentTypeAttribute from the model
                    var attribute = GetAttribute(modelType);
                    
                    if (attribute != null)
                    {
                        var areaName = _contentLoader.Get<ISiteHomePage>(SiteDefinition.Current.StartPage).AreaName;

                        // Compare current area name against the list of areaNames in the attribute
                        if (attribute.AreaNames.Any(x => x.Equals(areaName, StringComparison.InvariantCultureIgnoreCase)))
                        {
                            yield return targetType;
                        }
                    }
                    else
                    {
                        yield return targetType;
                    }
                } 
                else
                {
                    yield return targetType;
                }
            }
        }

        private RestrictContentTypeAttribute GetAttribute(Type currentType)
        {
            // Check if the currentType has the attribute
            var attribute = (RestrictContentTypeAttribute)Attribute.GetCustomAttribute(currentType, typeof(RestrictContentTypeAttribute));
        
            // If we have the attribute, return it.
            if (attribute != null)
            {
                return attribute;
            }

            // If not, check the BaseType of the currentType
            if (currentType.BaseType == null)
            {
                return null;
            }

            return GetAttribute(currentType.BaseType);
        }
    }
}

Two things to note about this extended service implementation:

  • Instead of using the site name as defined in the SiteDefinition, which could be modified by an administrator and break this functionality, we are using the AreaName as defined in the site's constants file, and fed through the StartPage via the IAreaStartPage interface (see this post).
  • I also added in the _modelTypeExceptions Type array, which allows us to add exceptions to the filter. In the example code above, I'm always going to allow the FormContainerBlock. You might want to use this if you have content types that should be available globally.

Finally, we'll need to shim in our extended service implementation with an IConfigurableModule:

namespace MySolution.Business.RestrictContentType
{
    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class RestrictContentTypeInitialization : IConfigurableModule
    {
        public void ConfigureContainer(ServiceConfigurationContext context)
        {
            context.ConfigurationComplete += (o, e) =>
            {
                context.Services.RemoveAll<ContentTypeAvailabilityService>();
                context.Services.AddTransient<ContentTypeAvailabilityService, RestrictContentTypeAvailabilityService>();
            };
        }

        public void Initialize(InitializationEngine context)
        {
        }

        public void Uninitialize(InitializationEngine context)
        {
        }
    }
}

Once you have those in place, just add the RestrictContentType attribute to your base block class, making sure to use the AreaName as defined in the site's constants file:

namespace MySolution.Areas.SiteA.Models.Blocks.Base
{
    [RestrictContentType(new[] { SiteAConstants.AreaName })]
    public abstract class SiteABaseBlockData : GlobalBaseBlockData
    {
    }
}

Restricting Content Types in Properties

The last bit to cover is the content type properties. Luckily, this is pretty straight-forward, and utilizes the already documented AllowedTypes attribute. This attribute can only be used on our ContentArea, ContentReference, or ContentReferenceList properties:

namespace MySolution.Areas.SiteA.Models.Pages
{
    [ContentType(DisplayName = "Standard Page", GUID = "00000000-0000-0000-0000-000000000000",
        GroupName = SiteAConstants.AreaName)]
    public class StandardPage : SiteABasePageData
    {
        [AllowedTypes(new[] { typeof(SiteBasePageData) }, RestrictedTypes = new[] { typeof(SiteSettingsPage) })]
        public virtual ContentReference Link { get; set; }
    }
}

This works fine... but... the edit UI loses some friendliness, as I noted in this forum thread.

To fix this, we need to add or update our language files with the associated content types:

<?xml version="1.0" encoding="utf-8" ?>
<languages>
    <language name="English" id="en">
        <contenttypes>

            <!-- General Page Types -->
            <sitesettingspage>
                <name>Site Settings Page</name>
            </sitesettingspage>
            
            <!-- SiteA -->
            <siteabasepagedata>
                <name>Any "Site A" Page</name>
            </siteabasepagedata>
            
        </contenttypes>
    </language>
</languages>

More information on this can be found in the documentation for globalization.

What's Next?

This is the final post in the series related to architecting and developing multi-site Episerver solutions using MVC Areas. While there's more that I could discuss, I feel I've covered a majority of topics that you'll usually come across when creating a multi-site solution. I hope you've found these posts useful, and I wish you luck in your next multi-site Episerver project!

More posts in this series: