Blog

Categorised by 'Kentico'.

  • The Kentico Kontent ASP.NET Core boilerplate contains a CustomContentLinkUrlResolver class that allows all links within your content to be transformed into a custom URL path based on the content-type a link is referencing. The out-of-the-box boilerplate solution works for most scenarios. But there will be times when links cannot be resolved in such a simplistic fashion, especially if your project is using dynamic page routing.

    What we need to do is make a small tweak to the CustomContentLinkUrlResolver class so we can use Kontent’s DeliveryClient object, which in turn allows us to query the API and carry out a complex ruleset for resolving URL’s.

    To give a frame of reference, the out-of-the-box CustomContentLinkUrlResolver class contains the following code:

    public class CustomContentLinkUrlResolver : IContentLinkUrlResolver
    {
        /// <summary>
        /// Resolves the link URL.
        /// </summary>
        /// <param name="link">The link.</param>
        /// <returns>A relative URL to the page where the content is displayed</returns>
        public string ResolveLinkUrl(ContentLink link)
        {
            return $"/{link.UrlSlug}";
        }
    
        /// <summary>
        /// Resolves the broken link URL.
        /// </summary>
        /// <returns>A relative URL to the site's 404 page</returns>
        public string ResolveBrokenLinkUrl()
        {
            // Resolves URLs to unavailable content items
            return "/404";
        }
    }
    

    This will be changed to:

    public class CustomContentLinkUrlResolver : IContentLinkUrlResolver
    {
        IDeliveryClient deliveryClient;
        public CustomContentLinkUrlResolver(DeliveryOptions deliveryOptions)
        {
            deliveryClient = DeliveryClientBuilder.WithProjectId(deliveryOptions.ProjectId).Build();
        }
    
        /// <summary>
        /// Resolves the link URL.
        /// </summary>
        /// <param name="link">The link.</param>
        /// <returns>A relative URL to the page where the content is displayed</returns>
        public string ResolveLinkUrl(ContentLink link)
        {                
            switch (link.ContentTypeCodename)
            {
                case Home.Codename:
                    return "/";
                case BlogListing.Codename:
                    return "/Blog";
                case BlogPost.Codename:
                    return $"/Blog/{link.UrlSlug}";
                case NewsArticle.Codename:
                    // A simplistic example of the Delivery Client in use to resolve a link...
                    NewsArticle newsArticle = Task.Run(async () => await deliveryClient.GetItemsAsync<NewsArticle>(
                                                                                new EqualsFilter("system.id", link.Id),
                                                                                new ElementsParameter("url"),
                                                                                new LimitParameter(1)
                                                                            )).Result?.Items.FirstOrDefault();
    
                    if (!string.IsNullOrEmpty(newsArticle?.Url))
                        return newsArticle.Url;
                    else
                        return ResolveBrokenLinkUrl();
                default:
                    return $"/{link.UrlSlug}"; 
            }
        }
    
        /// <summary>
        /// Resolves the broken link URL.
        /// </summary>
        /// <returns>A relative URL to the site's 404 page</returns>
        public string ResolveBrokenLinkUrl()
        {
            // Resolves URLs to unavailable content items
            return "/404";
        }
    }
    

    In the updated code, we are using DeliveryClientBuilder.WithProjectId() method to create a new instance of the DeliveryClient object, which can then be used if a link needs to resolve a News Article content type. You have may have also noticed the class is now accepting a DeliveryOptions object as its parameter. This object is populated on startup with Kontent’s core settings from the appsettings.json file. All we’re interested in is retrieving the Project ID.

    A small update to the Startup.cs file will also need to be carried out where the CustomContentLinkUrlResolver class is referenced.

    public void ConfigureServices(IServiceCollection services)
    {
        ...
    
        var deliveryOptions = new DeliveryOptions();
        Configuration.GetSection(nameof(DeliveryOptions)).Bind(deliveryOptions);
    
        IDeliveryClient BuildBaseClient(IServiceProvider sp) => DeliveryClientBuilder
            .WithOptions(_ => deliveryOptions)
            .WithTypeProvider(new CustomTypeProvider())
            .WithContentLinkUrlResolver(new CustomContentLinkUrlResolver(deliveryOptions)) // Line to update.
            .Build();
    
        ...
    }
    

    I should highlight at this point the changes that have been illustrated above have been made on an older version of the Kentico Kontent boilerplate. But the same approach applies. The only thing I’ve noticed that normally changes between boilerplate revisions is the Startup.cs file. The DeliveryOptions class is still in use, but you may have to make a small tweak to ascertain its values.

  • Yesterday, I was frantically trying to debug why some documents weren’t getting returned when using the DocumentHelper.GetDocuments() method. Normally when this happens, I need delve deeper to see what SQL Kentico is generating via the API in order to get a little more information on where the querying could be going wrong. To do this, I perform a little “hacky” approach (purely for debugging) whereby I break the SQL within the API by insert a random character within the OrderBy or Where condition parameters.

    Voila! The can see the SQL in the error page.

    But it was only yesterday where I was shown a much more elegant solution by simply adding a GetFullQueryText() to your GetDocuments(), which then returns the SQL with all the parameters populated for you:

    string debugQuery = DocumentHelper.GetDocuments()
                                      .OnSite(SiteContext.CurrentSiteName)
                                      .Types(DocumentTypeHelper.GetClassNames(TreeProvider.ALL_CLASSNAMES))
                                      .Path("/", PathTypeEnum.Children)
                                      .Culture(LocalizationContext.PreferredCultureCode)
                                      .OrderBy("NodeLevel ASC", "NodeOrder ASC")
                                      .GetFullQueryText();
    

    I can’t believe I did not know this after so many years working on Kentico! How embarrassing...

  • The first thing that came into my head when testing the waters to start the process of moving over to Gatsby was my blog post content. If I could get my content in a form a Gatsby site accepts then that's half the battle won right there, the theory being it will simplify the build process.

    I opted to go down the local storage route where Gatsby would serve markdown files for my blog post content. Everything else such as the homepage, archive, about and contact pages can be static. I am hoping this isn’t something I will live to regret but I like the idea my content being nicely preserved in source control where I have full ownership without relying on a third-party platform.

    My site is currently built on the .NET framework using Kentico CMS. Exporting data is relatively straight-forward, but as I transition to a somewhat content-less managed approach, I need to ensure all fields used within my blog posts are transformed appropriately into the core building blocks of my markdown files.

    A markdown file can carry additional field information about my post that can be declared at the start of the file, wrapped by triple dashes at the start and end of the block. This is called frontmatter.

    Here is a snippet of one of my blog posts exported to a markdown file:

    ---
    title: "Maldives and Vilamendhoo Island Resort"
    summary: "At Vilamendhoo Island Resort you are surrounded by serene beauty wherever you look. Judging by the serendipitous chain of events where the stars aligned, going to the Maldives has been a long time in the coming - I just didn’t know it."
    date: "2019-09-21T14:51:37Z"
    draft: false
    slug: "/Maldives-and-Vilamendhoo-Island-Resort"
    disqusId: "b08afeae-a825-446f-b448-8a9cae16f37a"
    teaserImage: "/media/Blog/Travel/VilamendhooSunset.jpg"
    socialImage: "/media/Blog/Travel/VilamendhooShoreline.jpg"
    categories: ["Surinders-Log"]
    tags: ["holiday", "maldives"]
    ---
    
    Writing about my holiday has started to become a bit of a tradition (for those that are worthy of such time and effort!) which seem to start when I went to [Bali last year](/Blog/2018/07/06/My-Time-At-Melia-Bali-Hotel). 
    I find it's a way to pass the time in airports and flights when making the return journey home. So here's another one...
    

    Everything looks well structured and from the way I have formatted the date, category and tags fields, it will lend itself to be quite accommodating for the needs of future posts. I made the decision to keep the slug value void of any directory structure to give me the flexibility on dynamically creating a URL structure.

    Kentico Blog Posts to Markdown Exporter

    The quickest way to get the content out was to create a console app to carry out the following:

    1. Loop through all blog posts in post date descending.
    2. Update all images paths used as a teaser and within the content.
    3. Convert rich text into markdown.
    4. Construct frontmatter key-value fields.
    5. Output to a text file in the following naming convention: “yyyy-MM-dd---Post-Title.md”.

    Tasks 2 and 3 will require the most effort…

    When I first started using Kentico, all references to images were made directly via the file path and as I got more familiar with Kentico, this was changed to use permanent URLs. Using permanent URL’s caused the link to an image to change from "/Surinder/media/Surinder/myimage.jpg", to “/getmedia/27b68146-9f25-49c4-aced-ba378f33b4df /myimage.jpg?width=500”. I need to create additional checks to find these URL’s and transform into a new path.

    Finding a good .NET markdown converter is imperative. Without this, there is a high chance the rich text content would not be translated to a satisfactorily standard, resulting in some form of manual intervention to carry out corrections. Combing through 250 posts manually isn’t my idea of fun! :-)

    I found the ReverseMarkdown .NET library allowed for enough options to deal with Rich Text to Markdown conversion. I could set in the conversion process to ignore HTML that couldn’t be transformed thus preserving content.

    Code

    using CMS.DataEngine;
    using CMS.DocumentEngine;
    using CMS.Helpers;
    using CMS.MediaLibrary;
    using Export.BlogPosts.Models;
    using ReverseMarkdown;
    using System;
    using System.Collections.Generic;
    using System.Configuration;
    using System.IO;
    using System.Linq;
    using System.Text;
    using System.Text.RegularExpressions;
    
    namespace Export.BlogPosts
    {
        class Program
        {
            public const string SiteName = "SurinderBhomra";
            public const string MarkdownFilesOutputPath = @"C:\Temp\BlogPosts\";
            public const string NewMediaBaseFolder = "/media";
            public const string CloudImageServiceUrl = "https://xxxx.cloudimg.io";
    
            static void Main(string[] args)
            {
                CMSApplication.Init();
    
                List<BlogPost> blogPosts = GetBlogPosts();
    
                if (blogPosts.Any())
                {
                    foreach (BlogPost bp in blogPosts)
                    {
                        bool isMDFileGenerated = CreateMDFile(bp);
    
                        Console.WriteLine($"{bp.PostDate:yyyy-MM-dd} - {bp.Title} - {(isMDFileGenerated ? "EXPORTED" : "FAILED")}");
                    }
    
                    Console.ReadLine();
                }
            }
    
            /// <summary>
            /// Retrieve all blog posts from Kentico.
            /// </summary>
            /// <returns></returns>
            private static List<BlogPost> GetBlogPosts()
            {
                List<BlogPost> posts = new List<BlogPost>();
    
                InfoDataSet<TreeNode> query = DocumentHelper.GetDocuments()
                                                   .OnSite(SiteName)
                                                   .Types("SurinderBhomra.BlogPost")
                                                   .Path("/Blog", PathTypeEnum.Children)
                                                   .Culture("en-GB")
                                                   .CombineWithDefaultCulture()
                                                   .NestingLevel(-1)
                                                   .Published()
                                                   .OrderBy("BlogPostDate DESC")
                                                   .TypedResult;
    
                if (!DataHelper.DataSourceIsEmpty(query))
                {
                    foreach (TreeNode blogPost in query)
                    {
                        posts.Add(new BlogPost
                        {
                            Guid = blogPost.NodeGUID.ToString(),
                            Title = blogPost.GetStringValue("BlogPostTitle", string.Empty),
                            Summary = blogPost.GetStringValue("BlogPostSummary", string.Empty),
                            Body = RichTextToMarkdown(blogPost.GetStringValue("BlogPostBody", string.Empty)),
                            PostDate = blogPost.GetDateTimeValue("BlogPostDate", DateTime.MinValue),
                            Slug = blogPost.NodeAlias,
                            DisqusId = blogPost.NodeGUID.ToString(),
                            Categories = blogPost.Categories.DisplayNames.Select(c => c.Value.ToString()).ToList(),
                            Tags = blogPost.DocumentTags.Replace("\"", string.Empty).Split(',').Select(t => t.Trim(' ')).Where(t => !string.IsNullOrEmpty(t)).ToList(),
                            SocialImage = GetMediaFilePath(blogPost.GetStringValue("ShareImageUrl", string.Empty)),
                            TeaserImage = GetMediaFilePath(blogPost.GetStringValue("BlogPostTeaser", string.Empty))
                        });
                    }
                }
    
                return posts;
            }
    
            /// <summary>
            /// Creates the markdown content based on Blog Post data.
            /// </summary>
            /// <param name="bp"></param>
            /// <returns></returns>
            private static string GenerateMDContent(BlogPost bp)
            {
                StringBuilder mdBuilder = new StringBuilder();
    
                #region Post Attributes
    
                mdBuilder.Append($"---{Environment.NewLine}");
                mdBuilder.Append($"title: \"{bp.Title.Replace("\"", "\\\"")}\"{Environment.NewLine}");
                mdBuilder.Append($"summary: \"{HTMLHelper.HTMLDecode(bp.Summary).Replace("\"", "\\\"")}\"{Environment.NewLine}");
                mdBuilder.Append($"date: \"{bp.PostDate.ToString("yyyy-MM-ddTHH:mm:ssZ")}\"{Environment.NewLine}");
                mdBuilder.Append($"draft: {bp.IsDraft.ToString().ToLower()}{Environment.NewLine}");
                mdBuilder.Append($"slug: \"/{bp.Slug}\"{Environment.NewLine}");
                mdBuilder.Append($"disqusId: \"{bp.DisqusId}\"{Environment.NewLine}");
                mdBuilder.Append($"teaserImage: \"{bp.TeaserImage}\"{Environment.NewLine}");
                mdBuilder.Append($"socialImage: \"{bp.SocialImage}\"{Environment.NewLine}");
    
                #region Categories
    
                if (bp.Categories?.Count > 0)
                {
                    CommaDelimitedStringCollection categoriesCommaDelimited = new CommaDelimitedStringCollection();
    
                    foreach (string categoryName in bp.Categories)
                        categoriesCommaDelimited.Add($"\"{categoryName}\"");
    
                    mdBuilder.Append($"categories: [{categoriesCommaDelimited.ToString()}]{Environment.NewLine}");
                }
    
                #endregion
    
                #region Tags
    
                if (bp.Tags?.Count > 0)
                {
                    CommaDelimitedStringCollection tagsCommaDelimited = new CommaDelimitedStringCollection();
    
                    foreach (string tagName in bp.Tags)
                        tagsCommaDelimited.Add($"\"{tagName}\"");
    
                    mdBuilder.Append($"tags: [{tagsCommaDelimited.ToString()}]{Environment.NewLine}");
                }
    
                #endregion
    
                mdBuilder.Append($"---{Environment.NewLine}{Environment.NewLine}");
    
                #endregion
    
                // Add blog post body content.
                mdBuilder.Append(bp.Body);
    
                return mdBuilder.ToString();
            }
    
            /// <summary>
            /// Creates files with a .md extension.
            /// </summary>
            /// <param name="bp"></param>
            /// <returns></returns>
            private static bool CreateMDFile(BlogPost bp)
            {
                string markdownContents = GenerateMDContent(bp);
    
                if (string.IsNullOrEmpty(markdownContents))
                    return false;
    
                string fileName = $"{bp.PostDate:yyyy-MM-dd}---{bp.Slug}.md";
                File.WriteAllText($@"{MarkdownFilesOutputPath}{fileName}", markdownContents);
    
                if (File.Exists($@"{MarkdownFilesOutputPath}{fileName}"))
                    return true;
    
                return false;
            }
    
            /// <summary>
            /// Gets the full relative path of an file based on its Permanent URL ID. 
            /// </summary>
            /// <param name="filePath"></param>
            /// <returns></returns>
            private static string GetMediaFilePath(string filePath)
            {
                if (filePath.Contains("getmedia"))
                {
                    // Get GUID from file path.
                    Match regexFileMatch = Regex.Match(filePath, @"(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}");
    
                    if (regexFileMatch.Success)
                    {
                        MediaFileInfo mediaFile = MediaFileInfoProvider.GetMediaFileInfo(Guid.Parse(regexFileMatch.Value), SiteName);
    
                        if (mediaFile != null)
                            return $"{NewMediaBaseFolder}/{mediaFile.FilePath}";
                    }
                }
    
                // Return the file path and remove the base file path.
                return filePath.Replace("/SurinderBhomra/media/Surinder", NewMediaBaseFolder);
            }
    
            /// <summary>
            /// Convert parsed rich text value to markdown.
            /// </summary>
            /// <param name="richText"></param>
            /// <returns></returns>
            public static string RichTextToMarkdown(string richText)
            {
                if (!string.IsNullOrEmpty(richText))
                {
                    #region Loop through all images and correct the path
    
                    // Clean up tilda's.
                    richText = richText.Replace("~/", "/");
    
                    #region Transform Image Url's Using Width Parameter
    
                    Regex regexFileUrlWidth = new Regex(@"\/getmedia\/(\{{0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}\}{0,1})\/([\w,\s-]+\.[A-Za-z]{3})(\?width=([0-9]*))", RegexOptions.Multiline | RegexOptions.IgnoreCase);
    
                    foreach (Match fileUrl in regexFileUrlWidth.Matches(richText))
                    {
                        string width = fileUrl.Groups[4] != null ? fileUrl.Groups[4].Value : string.Empty;
                        string newMediaUrl = $"{CloudImageServiceUrl}/width/{width}/n/https://www.surinderbhomra.com{GetMediaFilePath(ClearQueryStrings(fileUrl.Value))}";
    
                        if (newMediaUrl != string.Empty)
                            richText = richText.Replace(fileUrl.Value, newMediaUrl);
                    }
    
                    #endregion
    
                    #region Transform Generic File Url's
    
                    Regex regexGenericFileUrl = new Regex(@"\/getmedia\/(\{{0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}\}{0,1})\/([\w,\s-]+\.[A-Za-z]{3})", RegexOptions.Multiline | RegexOptions.IgnoreCase);
    
                    foreach (Match fileUrl in regexGenericFileUrl.Matches(richText))
                    {
                        // Construct media URL required by image hosting company - CloudImage. 
                        string newMediaUrl = $"{CloudImageServiceUrl}/cdno/n/n/https://www.surinderbhomra.com{GetMediaFilePath(ClearQueryStrings(fileUrl.Value))}";
    
                        if (newMediaUrl != string.Empty)
                            richText = richText.Replace(fileUrl.Value, newMediaUrl);
                    }
    
                    #endregion
    
                    #endregion
    
                    Config config = new Config
                    {
                        UnknownTags = Config.UnknownTagsOption.PassThrough, // Include the unknown tag completely in the result (default as well)
                        GithubFlavored = true, // generate GitHub flavoured markdown, supported for BR, PRE and table tags
                        RemoveComments = true, // will ignore all comments
                        SmartHrefHandling = true // remove markdown output for links where appropriate
                    };
    
                    Converter markdownConverter = new Converter(config);
    
                    return markdownConverter.Convert(richText).Replace(@"[!\", @"[!").Replace(@"\]", @"]");
                }
    
                return string.Empty;
            }
    
            /// <summary>
            /// Returns media url without query string values.
            /// </summary>
            /// <param name="mediaUrl"></param>
            /// <returns></returns>
            private static string ClearQueryStrings(string mediaUrl)
            {
                if (mediaUrl == null)
                    return string.Empty;
    
                if (mediaUrl.Contains("?"))
                    mediaUrl = mediaUrl.Split('?').ToList()[0];
    
                return mediaUrl.Replace("~", string.Empty);
            }
        }
    }
    

    There is a lot going on here, so let's do a quick breakdown:

    1. GetBlogPosts(): Get all blog posts from Kentico and parse them to a “BlogPost” class object containing all the fields we want to export.
    2. GetMediaFilePath(): Take the image path and carry out all the transformation required to change to a new file path. This method is used in GetBlogPosts() and RichTextToMarkdown() methods.
    3. RichTextToMarkdown(): Takes rich text and goes through a transformation process to relink images in a format that will be accepted by my image hosting provider - Cloud Image. In addition, this is where ReverseMarkdown is used to finally convert to markdown.
    4. CreateMDFile(): Creates the .md file based on the blog posts found in Kentico.
  • Published on
    -
    5 min read

    Generate Code Name For Tags In Kentico

    With every Kentico release that goes by, I am always hopeful that they will somehow add code name support to Tags where a unique text-based identifier is created, just like Categories (via CategoryName field). I find the inclusion of code names very useful when used in URL as wildcards to filter a list of records, such as blog posts.

    In a blog listing page, you'll normally have the ability to filter by both category or tag and to make things nice for SEO, we include them in our URLs, for example:

    • /Blog/Category/Kentico
    • /Blog/Tag/Kentico-Cloud

    This is easy to carry out when dealing with categories as every category you create has "CategoryName" field, which strips out any special characters and is unique, fit to use in slug form within a URL! We're not so lucky when it comes to dealing with Tags. In the past, to allow the user to filter my blog posts by tag, the URL was formatted to look something like this: /Blog/Tag/185-Kentico-Cloud, where the number denotes the Tag ID to be parsed into my code for querying.

    Not the nicest form.

    The only way to get around this was to customise how Kentico stores its tags on creation and update, without impacting its out-of-the-box functionality. This could be done by creating a new table that would store newly created tags in code name form and link back to Kentico's CMS_Tag table.

    Tag Code Name Table

    The approach on how you'd create your table is up to you. It could be something created directly in the database, a custom table or module. I opted to create a new class name under one of my existing custom modules that groups all site-wide functionality. I called the table: SurinderBhomra_SiteTag.

    The SurinderBhomra_SiteTag consists of the following columns:

    • SiteTagID (int)
    • SiteTagGuid (uniqueidentifier)
    • SiteTagLastModified (datetime)
    • TagID (int)
    • TagCodeName (nvarchar(200))

    If you create your table through Kentico, the first four columns will automatically be generated. The "TagID" column is our link back to the CMS_Tag table.

    Object and Document Events

    Whenever a tag is inserted or updated, we want to populate our new SiteTag table with this information. This can be done through ObjectEvents.

    public class ObjectGlobalEvents : Module
    {
        // Module class constructor, the system registers the module under the name "ObjectGlobalEvents"
        public ObjectGlobalEvents() : base("ObjectGlobalEvents")
        {
        }
    
        // Contains initialization code that is executed when the application starts
        protected override void OnInit()
        {
          base.OnInit();
    
          // Assigns custom handlers to events
          ObjectEvents.Insert.After += ObjectEvents_Insert_After;
          ObjectEvents.Update.After += ObjectEvents_Update_After;
        }
    
        private void ObjectEvents_Insert_After(object sender, ObjectEventArgs e)
        {
          if (e.Object.TypeInfo.ObjectClassName.ClassNameEqualTo("cms.tag"))
          {
            SetSiteTag(e.Object.GetIntegerValue("TagID", 0), e.Object.GetStringValue("TagName", string.Empty));
          }
        }
    
        private void ObjectEvents_Update_After(object sender, ObjectEventArgs e)
        {
          if (e.Object.TypeInfo.ObjectClassName.ClassNameEqualTo("cms.tag"))
          {
            SetSiteTag(e.Object.GetIntegerValue("TagID", 0), e.Object.GetStringValue("TagName", string.Empty));
          }
        }
    
        /// <summary>
        /// Adds a new site tag, if it doesn't exist already.
        /// </summary>
        /// <param name="tagId"></param>
        /// <param name="tagName"></param>
        private static void SetSiteTag(int tagId, string tagName)
        {
          SiteTagInfo siteTag = SiteTagInfoProvider.GetSiteTags()
                                .WhereEquals("TagID", tagId)
                                .TopN(1)
                                .FirstOrDefault();
    
          if (siteTag == null)
          {
            siteTag = new SiteTagInfo
            {
              TagID = tagId,
              TagCodeName = tagName.ToSlug(), // The .ToSlug() is an extenstion method that strips out all special characters via regex.
            };
    
            SiteTagInfoProvider.SetSiteTagInfo(siteTag);
          }
        }
    }
    

    We also need to take into consideration when a document is deleted and carry out some cleanup to ensure tags no longer assigned to any document are deleted from our new table:

    public class DocumentGlobalEvents : Module
    {
        // Module class constructor, the system registers the module under the name "DocumentGlobalEvents"
        public DocumentGlobalEvents() : base("DocumentGlobalEvents")
        {
        }
    
        // Contains initialization code that is executed when the application starts
        protected override void OnInit()
        {
          base.OnInit();
    
          // Assigns custom handlers to events
          DocumentEvents.Delete.After += Document_Delete_After;
        }
    
        private void Document_Delete_After(object sender, DocumentEventArgs e)
        {
          TreeNode doc = e.Node;
          TreeProvider tp = e.TreeProvider;
    
          GlobalEventFunctions.DeleteSiteTags(doc);
        }
    
        /// <summary>
        /// Deletes Site Tags linked to CMS_Tag.
        /// </summary>
        /// <param name="tnDoc"></param>
        private static void DeleteSiteTags(TreeNode tnDoc)
        {
          string docTag = tnDoc.GetStringValue("DocumentTags", string.Empty);
    
          if (!string.IsNullOrEmpty(docTag))
          {
            foreach (string tag in docTag.Split(','))
            {
              TagInfo cmsTag = TagInfoProvider.GetTags()
                               .WhereEquals("TagName", tag)
                               .Column("TagCount")
                               .FirstOrDefault();
    
              // If the the tag is no longer stored, we can delete from SiteTag table.
              if (cmsTag?.TagCount == null)
              {
                List<SiteTagInfo> siteTags = SiteTagInfoProvider.GetSiteTags()
                                     .WhereEquals("TagCodeName", tag.ToSlug())
                                     .TypedResult
                                     .ToList();
                if (siteTags?.Count > 0)
                {
                  foreach (SiteTagInfo siteTag in siteTags)
                    SiteTagInfoProvider.DeleteSiteTagInfo(siteTag);
                }
              }
            }
          }
        }
    }
    

    Displaying Tags In Page

    To return all tags linked to a page by its "DocumentID", a few of SQL joins need to be used to start our journey across the following tables:

    1. CMS_DocumentTag
    2. CMS_Tag
    3. SurinderBhomra_SiteTag

    Nothing Kentico's Object Query API can't handle.

    /// <summary>
    /// Gets all tags for a document.
    /// </summary>
    /// <param name="documentId"></param>
    /// <returns></returns>
    public static DataSet GetDocumentTags(int documentId)
    {
      DataSet tags = DocumentTagInfoProvider.GetDocumentTags()
                        .WhereID("DocumentID", documentId)
                        .Source(src => src.Join<TagInfo>("CMS_Tag.TagID", "CMS_DocumentTag.TagID"))
                        .Source(src => src.Join<SiteTagInfo>("SurinderBhomra_SiteTag.TagID", "CMS_DocumentTag.TagID"))
                        .Columns("TagName", "TagCodeName")
                        .Result;
    
      if (!DataHelper.DataSourceIsEmpty(tags))
        return tags;
    
      return null;
    }
    

    Conclusion

    We now have our tags working much like categories, where we have a display name field (CMS_Tag.TagName) and a code name (SurinderBhomra_SiteTag.TagCodeName). Going forward, any new tags that contain spaces or special characters will be sanitised and nicely presented when used in a URL. My blog demonstrates the use of this functionality.

  • There are times when you need to know what widgets are being used on a page. In my case, I needed to know this information to render JavaScript code at the bottom of the page that each of my widgets depends on.

    Why don't I just place all the JavaScript code my site and widgets use in one file? Loading one large JavaScript file isn't the best approach for page performance. Instead, I use LabJS to dynamically load scripts in specific execution order without blocking other resources. So if I created a Carousel widget in Kentico, I would only load the JavaScript plugin if added to the page.

    I'll use my JavaScript scenario as a basis for demonstrating the way to list out widgets used in a page.

    If we delve into the CMS_Document table, Kentico uses the "DocumentPageBuilderWidgets" field that stores a JSON structure consisting of a list of all the widgets and their property values. All we are interested in is the type property.

    Let's get to the code.

    Controller - SharedController

    I created a SharedController class containing a GenerateWidgetJavascript() PartialViewResult. This will convert the JSON from the "DocumentPageBuilderWidgets" field into a JSON Object to then be queried (using SelectTokens) to select every iteration of the type field in the hierarchy.

    /// <summary>
    /// Get widget used on a page to render any required JavaScript.
    /// </summary>
    /// <returns></returns>
    public PartialViewResult GenerateWidgetJavascript()
    {
        List<string> widgetTypes = new List<string>();
    
        if (Page.GetStringValue("DocumentPageBuilderWidgets", string.Empty) != string.Empty)
        {
            JObject pageWidgetJson = JObject.Parse(Page.GetStringValue("DocumentPageBuilderWidgets", string.Empty));
    
            if (pageWidgetJson != null)
                widgetTypes = pageWidgetJson.SelectTokens("$.editableAreas[*].sections[*].zones[*].widgets[*].type").Select(jt => jt.ToString().Substring(jt.ToString().LastIndexOf('.') + 1)).Distinct().ToList();
        }
    
        return PartialView("Widgets/_PageWidgetJs", widgetTypes);
    }
    

    Additional manipulation is carried out on the type field using LINQ to return a distinct set of results, as there might be a case where the same widget is used multiple times on a page. Since I name all my widgets in the following format - <SiteName>.<WidgetGroup>.<WidgetName>, I am only interested in the <WidgetName>. For example, my widget would be called "SurinderSite.Layout.Carousel". The controller will simply output "Carousel".

    To avoid confusion in my code snippet, it's worth noting I use a Page variable. This contains information about the current page and is populated in my base controller. It has a type of TreeNode. You can see my approach to getting the current page information in this post.

    Partial View - _PageWidgets

    The most suitable place to add my widget dependent JavaScript is in the /View/Shared/Widgets directory - part of the recommended Kentico project structure.

    All we need to do in the view is iterate through the string collection of widget types and have a bunch of if-conditions to render the necessary JavaScript.

    @model List<string>
    
    @if (Model.Any())
    {
        <script>
            @foreach (string widgetType in Model)
            {
                if (widgetType == "Carousel")
                {
                    <text>
                        $LAB
                            .script("/resources/js/plugins/slick.min.js")
                            .script("/resources/js/carousel.min.js").wait(function () {
                                {
                                    FECarousel.Init();
                                }
                            });
                    </text>
                }
            }
        </script>
    }
    

    Layout View

    The Layout view will be the best place to add the reference to our controller in the following way:

    @{ Html.RenderAction("GenerateWidgetJavascript", "Shared"); }
    
  • Over the Bank Holiday weekend, I had some time to kill one evening and decided to have a go at completing the Kentico Cloud exam to become a certified developer. Taking the exam is a natural progression to warrant oneself as an expert on the platform, especially as I have been using Kentico Cloud since it was first released. Time to put my experience to the test!

    Unlike traditional Kentico CMS Developer exams, the Kentico Cloud exam consists of 40 questions to complete over a duration of 40 mins. The pass rate is still the same at 70%.

    Even though I have been using Kentico Cloud for many years, I highly recommend developers to get yourself certified providing you are familiar with the interface, built a few applications already and have exposure to the API endpoint. The exam itself is platform-agnostic and you won't be tested on any language-specific knowledge.

    The surprising thing I found after completing the exam is a higher awareness of what Kentico Cloud does not only as a platform but also touched upon areas you wouldn't have necessarily been familiar with. There certainly more to Kentico Cloud than meets the eye!

  • As great as it is building sites using the MVC framework in Kentico, I do miss some of the page features we’re spoilt with that previous editions of Kentico has to offer.

    Like anything, there are always workarounds to implement the features we have become accustomed to but ensuring the correct approach is key, especially when moving existing Kentico clients onto Kentico 12 MVC edition. Familiarity is ever so important for the longer tenure clients who already have experience using previous versions of Kentico.

    In my Syndicut Medium post, I show how to use Kentico's existing fields from the CMS_Document table to work alongside the new MVC approach.

    Take a read here: https://medium.com/syndicut/adding-navigation-properties-to-pages-in-kentico-12-mvc-8f054f804af2

  • This month I've been writing some blog posts on why I decided to start using Cloudflare service for my website and utilising its API to allow me to purge cached files from the Cloudflare CDN on demand. Before reading further, I highly suggest perusing those posts just to put everything into context for my reasoning into using Cloudflare as well as the C# code that interacts with the API, which I will be referencing later on within this very post.

    My intial Cloudflare integration evolves around serving media files more efficiently through a CDN and having the ability to refresh these files automatically as updates are made within the Kentico CMS. Cloudflare's CDN services can help cache your content across their large global network, moving static files closer to your visitor.

    Based on the Page Rules I configured within the Cloudflare dashboard, I am caching all media library files served through the /getmedia/ URL path into the Cloudflare CDN. The same file will be served through the CDN until the set cache limit has expired. We need to implement functionality that will add some automation to the Kentico platform to purge the cache of a specific media library file when updated.

    Add A Global Event

    I created an event handler for the updating of Media library files as I wanted to get details of the file being updated by leveraging the MediaFileInfo class to access the Update.After event.

    protected override void OnInit()
    {
        base.OnInit();
    
        MediaFileInfo.TYPEINFO.Events.Update.After += Update_After;
    }
    
    private void Update_After(object sender, ObjectEventArgs e)
    {
        MediaFileInfo fileInfo = e.Object as MediaFileInfo;
    
        GlobalEventFunctions.PurgeMediaCache(fileInfo);
    }
    

    PurgeMediaCache() Method

    The event above calls a GlobalEventFunctions.PurgeMediaCache() method that will pass the information about the changed file ready for purging. The file URL parsed to the Cloudflare.PurgeSelectedFiles() method needs to be exact and take into consideration how your instance of Kentico is serving media files. If Permanent URL's are being used the /getmedia/ URL needs to be constructed consisting of:

    • Current domain
    • File GUID
    • File Name
    • File Extension

    Otherwise, we can just use get the file path as normal to where the media file resides.

    public class GlobalEventFunctions
    {
        /// <summary>
        /// Purges a file from the Cloudflare cache.
        /// </summary>
        /// <param name="fileInfo"></param>
        public static void PurgeMediaCache(MediaFileInfo fileInfo)
        {
            bool permanentURLEnabled = SettingsKeyInfoProvider.GetBoolValue($"{SiteContext.CurrentSiteName}.CMSMediaUsePermanentURLs");
            string filePath = string.Empty;
                
            if (permanentURLEnabled)
                filePath = $"{GetCurrentDomain()}/getmedia/{fileInfo.FileGUID.ToString()}/{fileInfo.FileName}{fileInfo.FileExtension}";
            else
                filePath = $"{GetCurrentDomain()}/{fileInfo.FilePath}";
    
            try
            {
                // Get code from: https://www.surinderbhomra.com/Blog/Post/2018/11/11/Cloudflare-API-Purge-Files-By-URL-In-C
                CloudflareCacheHelper cloudflareHelper = new CloudflareCacheHelper();
    
                cloudflareHelper.PurgeSelectedFiles(new List<string> { filePath });
            }
            catch (Exception ex)
            {
                EventLogProvider.LogException("Cloudflare Purge File Cache", "CLOUDFLARE_PURGE", ex, SiteContext.CurrentSiteID, $"Purge File: {filePath}");
            }
        }
    
        /// <summary>
        /// Get domain from current http context.
        /// </summary>
        /// <returns></returns>
        private static string GetCurrentDomain()
        {
            return $"{HttpContext.Current.Request.Url.Scheme}{Uri.SchemeDelimiter}{HttpContext.Current.Request.Url.Host}{(!HttpContext.Current.Request.Url.IsDefaultPort ? $":{HttpContext.Current.Request.Url.Port}" : null)}";
        }
    }
    

    We need not consider any other scenarios, such as insert or deletion. If a file is inserted, there is nothing to purge as it's a new file that will be cached directly into in the CDN on first request and when it comes to deletion we can just wait for the cache to expire.

    What's Next?

    The integration I have detailed so far is just scratching the surface of what Cloudflare has to offer and will investigate further on pushing more content over to the CDN. One area, in particular, I am looking into is carrying out full page caching. You might be thinking why even bother as Kentico has pretty good caching mechanisms already in place?

    Well Cloudflare has a really neat feature called "Always Online", where a cached version of a page is served if on the off chance it happens to go down or requires a reboot to install key security updates. But implementing this feature requires strict Page Rules to be setup within the Cloudflare dashboard to ensure the general workings of Kentico are not effected.

  • A couple day ago my website got absolutely hammered by a wave of constant SQL injection attacks by the same IP over a time period of a couple hours.

    I only managed to notice this whilst perusing the Event Log within the Kentico Administration interface. I don't normally check my own error logs as regularly as I should do, but since my site has recently gone through a bit of a revamp (which I'm still yet to post about), I wanted to ensure I haven't broken anything.

    To be honest, I am flattered that someone would think this site is worth the time and energy in trying to hack my site. Trust me, it ain't worth it.

    Even though Kentico has handled these attacks well, as a precaution I wanted to implement an additional layer of security before any further untoward activity reaches to my site. Being on a shared hosting platform my options are limited and my hands are tied to put in an infrastructure that doesn't cost the world.

    Enter Cloudflare

    I have always had some form of awareness of the Cloudflare content delivery network, just never put the service into practice. At one point I was looking into utilising Cloudflare to manage all my site media files over their CDN. But Cloudflare isn't just a CDN, it's able to offer much more:

    • Analytics - monitor traffic as well as caching ratio and more!
    • Firewall- manage access by IP, country, or query rules.
    • Rate limiting- protect your site or API from malicious traffic by blocking client IP addresses that hit a URL pattern and exceed a threshold you define.
    • Page rules - to allow caching to be triggered by a number of rules targeting specific areas of a site.

    The great thing is that these options are part of the free plan... even though there are restrictions to the number of settings you are able to put in place. The security options alone was enough of a reason to try out Cloudflare. But for my needs, the free plan seemed to tick all the boxes. I am just scratching the surface to what Cloudflare has to offer and have already got a few of the features working alongside Kentico.

    How I'm Using Cloudflare With Kentico

    As security was a main concern for me, one of the first things I did was to add in some rules through the firewall and block suspicious traffic. Naturally the next was to take advantage of the CDN capabilities. I wouldn't recommend full site caching unless you have some pretty strict page rules in place as this has a chance to cause issues with the Kentico Admin interface. By default, Cloudflare doesn't cache HTML pages, which is a good thing as it gives us a plain canvas to target the areas we want to cache.

    Being on the free plan, I only had three page rules at my disposal and made the decision to cache the following parts of my site.

    Area Regex Rule Settings
    Media Library Files (using Permanent URL's) *surinderbhomra.com/
    	getmedia/\* | <br>**Cache Level:** Caches Everything<br>**Edge Cache TTL:** A month<br>**Browser Cache TTL:** 16 days<br> |
    

    | Site CSS, JavaScript and Images | *surinderbhomra.com/
    resources/* | Cache Level: Caches Everything
    Edge Cache TTL: A month
    Browser Cache TTL: 16 days | | ScriptResource.axd/WebResource.axd | *surinderbhomra.com/*.axd | Cache Level: Caches Everything
    Edge Cache TTL: 7 days
    Browser Cache TTL: 7 days |

    Two cache types are used:

    1. Edge Cache TTL - is the setting that controls how long CloudFlare's edge servers will cache a resource before requesting a fresh copy from your server.
    2. Browser Cache TTL - is the time that CloudFlare instructs a visitor's browser to cache a resource. Until this time expires, the browser will load the resource from its local cache and speeding up the request.

    I am caching my assets for a considerable amount of time, which begs the question how will changes to files purge the cache in Cloudflare? Luckily, Cloudflare has quite a nice API, where I have the ability to purge everything or individual files (maximum of 30 in one request).

    Purge Media Library File Cache

    I am in the middle of testing a GlobalEvent handler that will carry out the following steps when files are inserted or updated:

    • Get path of the file.
    • Check if site is using Permanent URL's. As the URL to the file will be constructured differently if enabled.
    • Convert the relative path to an absolute path.
    • Pass the absolute file path to Cloudflare using the following Purge Files by URL API endpoint.

    Once I have carried out some further tests, I will be posting this code in a follow-up post.

    Purge Site File Cache

    Now attempting to purge the cache for site files such as JavaScript, CSS and images are a little more tricky as I need to keep an eye on the files changed. The easiest thing to do is I could write some code that will iterate through all the files in the /resources folder and purge everything from the CDN. Not the most elegant solution. Still, need to ponder on the correct implementation. If anyone has got any better suggestions, let me know.

    How Do I Know Cloudflare Is Caching My Site Files?

    It does take a little time in Cloudflare to cache all files on a site. It all depends on pages being viewed. A page needs to be loaded in order to submit all its contents to cache. On first load, the response header CF-Cache-Status will return a "MISS", which means the content has not been served from Cloudflare.

    Cloudflare Response - MISS

    However, when you go back to the page and re-check the page headers, the CF-Cache-Status should return "HIT". If this is not the case, check your Page Rules within the Cloudflare dashboard.

    Cloudflare Response - HIT

    Is Cloudflare Worth It?

    Quick answer - Yes!

    Setup is very straight-forward. All that is required is to carry out a change to your domain and point your DNS to the DNS Cloudflare assigns to you. There is no downtime in doing this. As a result, overall site performance has improved and page speed test faired much better.

    To give you a better insight for transparency, here are some statistics straight from my Cloudflare portal over a 24 hour period:

    Requests Through Cloudflare
    (Understandably, the number of cached items served through Cloudflare is low due to only caching specific areas)

    Cloudflare Performance
    (My basic shared hosting should now be performing better as less requests are being served via the origin server)

    Cloudflare Detected Threats
    (Blocked threats - the reason why I decided to give Cloudflare a try in the first place)

    I still have to carry out a lot more research in using Cloudflare to its full potential and will use my website as a test bed to see what I can achieve. My end goal is to make my website quicker and more secure!

  • Published on
    -
    2 min read

    Kentico - Call 404 Page From Code

    There will be times when you want to direct a user to a 404 page based on certain conditions from within your code. For example, when dealing with pages that use wildcard URL's, you might want to redirect the user to a 404 page if the value of that wildcard parameter returns no data.

    In my blog I have two wildcard parameters to allow the user to filter my posts by either category or tag. At code-level if no blog posts are returned based on the category or tag value, I have two choices:

    1. Display a "no results" message
    2. Redirect to a 404 page

    As you can tell by the title of this post, I wanted to go for the latter.

    From a Kentico perspective wildcard parameters in a URL aren't what I call "proper" pages and the CMS routing engine won't send you a 404 page as you'd think. So we need to carry the redirect at code-level ourselves based on the conditions we provide. As far as I'm aware, Kentico doesn't have a method in code to do this and settled for a workaround suggested by Sébastien Gumy in the following DevNet post.

    I made some minor changes to the code and placed it in a helper method for use throughout my project with ease:

    using CMS.Helpers;
    using CMS.PortalEngine;
    using CMS.URLRewritingEngine;
    
    namespace Site.Common.Kentico
    {
        public class PortalContextHelper
        {
            /// <summary>
            /// Redirect page to 404.
            /// </summary>
            public static void SendToPageNotFound()
            {
                PortalContext.Clear();
                CMSHttpContext.Current.Response.StatusCode = 404;
                URLRewriter.RewriteUrl(RequestStatusEnum.PageNotFound, string.Empty, ExcludedSystemEnum.Unknown);
            }
        }
    }
    

    The SendToPageNotFound() method can then be used in the following way:

    #region Get Querystring Parameters
    
    string tag = QueryHelper.GetString("Tag", string.Empty);
    string category = QueryHelper.GetString("Category", string.Empty);
    int pageNo = QueryHelper.GetInteger("PageNo", 1);
    
    #endregion
    
    int tagId = TagLogic.GetTagIdFromQuerystring(tag);
    
    // A custom method to get back blog posts based on parameters.
    BlogListing postData = BlogLogic.GetBlogPosts(CurrentDocument.NodeAliasPath, category, tagId, (pageNo - 1));
    
    if (postData.BlogPosts != null)
    {
        BlogListing.DataSource = postData.BlogPosts;
        BlogListing.DataBind();
    }
    else
    {
        // Send to 404 page.
        PortalContextHelper.SendToPageNotFound();
    }
    

    Please note: This has only been tested in Kentico 10.