How to create a custom 404 page handler via a package action in Umbraco

Posted written by Jon Humphrey on June 05, 2017 Umbraco

TL:DR ~ You can use package actions to modify, update, or set configurations for your Umbraco site on your package install event.

This example comes from a project I've been working on in which my client requested a starter kit and individual featured functionality modules to be developed for their client. This was done to bring all their client's online material under one CMS and allow the different branches and satellites to maintain the brand guidelines while still allowing for unique styling and functionality to be applied.

We've delivered the kit and 9 different modules which have been used on 3 different sites with another site currently in the design phase!

Now, I would like to take this moment quickly to say thank you to all the package devs out there, like @UmCo [i.e. Matt Brailsford (@mattbrailsford) & Lee Kelleher (@leekeleher)], Kevin Giszewski (@kevingizewski), and so many others to mention for all their somewhat thankless hard work on packages for us to use and learn so much from, but especially to Richard Soeteman (@rsoeteman) to whom none of this would have been possible without his work on the Package Actions Contrib project which I referenced throughout this project! #SuperTak #h5yr!

So, to begin, in this example we will be using package actions to create a specific node for our 404 page, assigning it as a child of the root node, and then adjusting the path set in the umbracoSettings.config's <errors> node

    <errors>
      <error404>1</error404>
      <!-- 
        The value for error pages can be:
        * A content item's integer ID   (example: 1234)
        * A content item's GUID ID      (example: 26C1D84F-C900-4D53-B167-E25CC489DAC8)
        * An XPath statement            (example: //errorPages[@nodeName='My cool error']
      -->
      <!--
        <error404>
            <errorPage culture="default">1</errorPage>
            <errorPage culture="en-US">200</errorPage>
        </error404>
       -->      
    </errors>

For those of you who are new to Umbraco, the umbracoSettings.config file is just that, a collection of settings for umbraco to use in that instance. Settings like error pages, Disallowed File Types, or even scheduled tasks (but I wouldn't unless you absolutely had to1) can be set here and adjusted when needed in a single file location.

Step 1 ~ Let's create the 404-page template!

Create the template to the site by adding a new doctype via the Settings > Document Types Create menu, be sure not to choose "Document Type without template" as that would defeat the whole purpose! As I usually have some sort of Masterpage type and template I would create this below that DocType in order to inherit all the properties and compositions shared; however, I would not add this as an allowed template as this is not a type of page you want to create more than one of as that will be handled by the package action code below. 

Note: Make sure you remember the naming convention for your doctype as you will need this later on too!

When you save the new Doctype Umbraco will create a new "empty" template for you which you will need to add the codebase from your master doctype to. This blank template looks like this:

@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@using MyNamespace.Helpers
@{
  Layout = null;
}

For this example our 404 template looks like this:

@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@using MyNamespace.Helpers
@{
  Layout = "Kit_Masterpage.cshtml";
}

  <!-- START // Content Page -->
  <article role="article">
    @Html.Partial("Site_PageElements/Page__HeroBanner", @Model.Content)

    <!-- START // Article content -->
    <div class="content--area wrap clearfix">
      <!-- START // Article copy column -->
      <div class="content article--content">
        @Html.Raw(Model.Content.GetProperty("Kit_PageContent").Value)

      </div>
      <!-- END // Article copy column -->
      <!-- START // Article sidebar column -->
      <aside class="sidebar">
        @Html.Partial("Site_NavElements/Page_SideNavigation", @Model.Content)
        <!-- START // Aside content widget - Latest News, Section signposts -->
        @Html.Partial("Site_Widgets/Widget__InfoPanel", @Model.Content)

        @if (Model.Content.AncestorOrSelf(1).Children.Any(x => x.IsDocumentType("Kit_LatestNews")) &amp;&amp; Model.Content.HasValue("NEWS_DisplayWidget"))
        {
          if (Model.Content.GetPropertyValue<bool>("News_DisplayWidget"))
          {
          <!--LATEST NEWS MODULE-->
          @Html.Partial("Site_Widgets/Widget__LatestNews", @Model.Content)
          <!-- /LATEST NEWS MODULE -->
          }
        }
        <!-- END // Aside content widget - Latest News, Section signposts -->
      </aside>
      <!-- END // Article sidebar column -->
    </div>
    <!-- END // Article content -->
  </article>
  <!-- END // Content Page -->
  <!-- START // Page Elements -->
  @Html.Partial("Site_PageElements/Page__PageElements", @Model.Content)
  <!-- END // Page Elements -->

Step 2 ~ Create the Package Action to be applied on package install! 

In Visual Studio add a new folder to the root of the site called "PackageActions" and inside of there then add a new class file called "Create404Handler"; add the following code to this file. By following through the comments in the code it mainly speaks for itself so I won't go too far into explaining it more other than to say that in Step 3 we will be copying the SampleXML string when we create the actual package in the back office.  

using System;
using System.CodeDom;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Xml;
using ClientDependency.Core;
using Newtonsoft.Json.Linq;
using umbraco.interfaces;
using Umbraco.Core;
using Umbraco.Core.Logging;

using umbraco.BusinessLogic;
using umbraco.cms.businesslogic.packager.standardPackageActions;
using Umbraco.Core.Models;
using Umbraco.Web;

namespace Project.PackageActions
{
  public class Create404Handler : IPackageAction
  {
    /// 
    /// Add the new 404 Handler to the site
    /// 
    /// <returns></returns>
    private static bool Set404Handler()
    {
      //set the target to be false
      bool result = false;

      try
      {
        //Grab hold of the content service
        var contentService = ApplicationContext.Current.Services.ContentService;
        //Get the homepage to ensure we have the entire content tree
        var homeNode = contentService.GetRootContent().FirstOrDefault(x => x.ContentType.Alias.InvariantEquals("Homepage_DocType")); //<-- ADD YOUR HOMEPAGE DOCTYPE HERE
        //Check to make sure this hasn't happened before
        if (!homeNode.Children().Any(x => x.ContentType.Alias.InvariantEquals("SITE_404Page"))) //<-- ADD YOUR 404 PAGE DOCTYPE HERE
        {
          //Create the 404 Page Not Found node
          var pageNotFound = contentService.CreateContent("404 Page Not Found", homeNode.Id, "SITE_404Page"); // <-- ENSURE THE 404 PAGE DOCTYPE IS HERE TOO!
          //Make sure the element has the NaviHide property available
          if (pageNotFound.HasProperty("umbracoNaviHide"))
          {
            //tell it to have the NaviHide Property set to true:
            pageNotFound.SetValue("umbracoNaviHide", true);
          }
          //Add it to the Content Tree
          contentService.SaveAndPublishWithStatus(pageNotFound);
          //Check to make sure the 404 page has been created
          if (pageNotFound.HasIdentity)
          {
            // ... then add the 404 Handler node to the UmbracoSettings.config file

            //Open the Umbraco Settings config file
            XmlDocument umbracoSettingsFile = XmlHelper.OpenAsXmlDocument("/config/umbracoSettings.config");

            //Select errors node from the settings file
            XmlNode errorsRootNode = umbracoSettingsFile.SelectSingleNode("//errors");

            if (errorsRootNode != null)
            {
              //Select the first error404 node 
              XmlNode error404Node = errorsRootNode.SelectSingleNode("//error404");

              //Make sure we have something to update
              if (error404Node != null)
              {
                //Create a new handler node
                XmlElement newHandlerNode = (XmlElement)umbracoSettingsFile.CreateElement("errorPage");

                //Add the content.node ID value to the new handler node
                newHandlerNode.InnerText = pageNotFound.Id.ToString();

                //Append standard attributes
                newHandlerNode.SetAttribute("culture", "default");

                //then check to see if there are any child nodes
                if (error404Node.HasChildNodes)
                {
                  //get the childnode to investigate
                  XmlNode currentChildNode = error404Node.SelectSingleNode("//errorPage");
                  //Make sure we have a child node
                  if (currentChildNode != null)
                  {
                    //check if the child node has a default culture attribute
                    if (currentChildNode.Attributes["culture"] != null &amp;&amp; currentChildNode.Attributes["culture"].Value == "default")
                    {
                      //change the inner text to the page not found node id
                      currentChildNode.InnerText = pageNotFound.Id.ToString();
                    }
                    else
                    {
                      //Append the new errorPage node to the error404 node
                      error404Node.PrependChild(newHandlerNode);
                    }
                  }
                }
                else
                {
                  //Remove any inner text of the initial 404 node
                  error404Node.InnerText = string.Empty;

                  //Append the new errorPage node to the error404 node
                  error404Node.PrependChild(newHandlerNode);
                }

                //Append the new 404 handler node to the Umbraco Settings config file
                errorsRootNode.PrependChild(error404Node);

                //Save the Umbraco Settings config file with the new 404 handler node
                umbracoSettingsFile.Save(System.Web.HttpContext.Current.Server.MapPath("/config/umbracoSettings.config"));

                //No errors so the result is true
                result = true;
                //Add this event to the logs for our records
                LogHelper.Info<create404handler>(
                  "404 HANDLER INSTALLER: error404 Node and children were just saved and the xml tree was rebuilt");
              }
            }
          }
        }

        //Refresh the content tree so we don't have to do it manually
        contentService.RebuildXmlStructures();
        //Add this event to the logs for our records
        LogHelper.Info<create404handler>("404 HANDLER INSTALLER: Home Node and children were just saved and the xml tree was rebuilt");

        return result;
      }
      catch (Exception err)
      {
        // Get stack trace for the exception with source file information
        var st = new StackTrace(err, true);
        // Get the top stack frame
        var frame = st.GetFrame(0);
        // Get the line number from the stack frame
        var line = frame.GetFileLineNumber();
        LogHelper.Warn(typeof(Create404Handler),
          "ERROR ON: Set404Handler - " + err.Message + "[" + line + "]:" + st);
        return false;
      }
    }

    public string Alias()
    {
      return "Create404Handler";
    }

    public bool Execute(string packageName, XmlNode xmlData)
    {
      try
      {
        return Set404Handler();
      }
      catch (Exception doh)
      {
        LogHelper.Error<nguskinstaller>("INSTALLER: Error at execute Create404Handler package action", doh);

        return false;
      }

    }


    public bool Undo(string packageName, XmlNode xmlData)
    {
      var contentService = ApplicationContext.Current.Services.ContentService;
      var homenode = contentService.GetRootContent().FirstOrDefault(x => x.ContentType.Alias.InvariantEquals("Homepage_DocType")); //<-- UPDATE TO HOMEPAGE DOCTYPE HERE
      if (homenode == null) return false;
      foreach (var node in homenode.Descendants().Where(x => x.ContentType.Alias.InvariantEquals("SITE_404Page"))) //<-- UPDATE TO 404 PAGE DOCTYPE HERE
      {
        contentService.UnPublish(node);
        contentService.Delete(node);
      }

      //Clean up the Umbraco Settings config file
      XmlDocument umbracoSettingsFile = XmlHelper.OpenAsXmlDocument("/config/umbracoSettings.config");

      //Select errors node from the settings file
      XmlNode errorsRootNode = umbracoSettingsFile.SelectSingleNode("//errors");

      //Select the first error404 node 
      XmlNode error404Node = errorsRootNode.SelectSingleNode("//error404");

      //get the childnode to investigate
      XmlNode currentChildNode = error404Node.SelectSingleNode("//errorPage");
      //check if the child node has a default culture attribute
      if (currentChildNode.Attributes["culture"] != null &amp;&amp; currentChildNode.Attributes["culture"].Value == "default")
      {
        currentChildNode.Attributes.RemoveAll();
      }
      //change the inner text to the root node id
      currentChildNode.InnerText = "1";

      //Append the new rewrite scheduled task to the Umbraco Settings config file
      errorsRootNode.AppendChild(error404Node);

      //Save the Umbraco Settings config file with the new Scheduled task
      umbracoSettingsFile.Save(System.Web.HttpContext.Current.Server.MapPath("/config/umbracoSettings.config"));

      return true;
    }

    public XmlNode SampleXml()
    {
      const string sample = "<action runat="\&quot;install\&quot;" undo="\&quot;true\&quot;" alias="\&quot;Create404Handler\&quot;"></action>";
      return ParseStringToXmlNode(sample);
    }

    private static XmlNode ParseStringToXmlNode(string value)
    {
      var xmlDocument = new XmlDocument();
      var xmlNode = AddTextNode(xmlDocument, "error", "");

      try
      {
        xmlDocument.LoadXml(value);
        return xmlDocument.SelectSingleNode(".");
      }
      catch
      {
        return xmlNode;
      }
    }

    private static XmlNode AddTextNode(XmlDocument xmlDocument, string name, string value)
    {
      var node = xmlDocument.CreateNode(XmlNodeType.Element, name, "");
      node.AppendChild(xmlDocument.CreateTextNode(value));
      return node;
    }
  }
}</nguskinstaller></create404handler></create404handler>

Step 3 ~Let's create the package!

Depending on the version of Umbraco you're using the location of the dashboard may vary.

For pre v7.5  you can find it in the Developer > Packages > Created Packages > Right click dashboard:

1
2

You will need to add the name to the package when you click "create" so I've named this one "Custom 404 Handler" which is then pre-filled in the following dashboard dialogue:

3

You can see the four tabs outlining each of the fieldsets you will need to populate:

Package Properties ~ Fill in the information that will be both presented on installation of your package as well as used in the Package Repository on Our if you choose to share

  • The Package Url field should be the public facing site url, usually GitHub or another repository location.
  • The Package Version field should use semantic versioning as this will allow you to publish updates to your package without losing any previous information on the sites where it's used unless you expect that.
  • The Package Icon Url field should be a remote url in order for the package installer to download and implement it [ v7.5+ Feature]
  • Umbraco Target Version - This is the specific version that your package will work with, is also shown on the Package Repository Page  [  v7.5+ Feature]
  • Author Name and Url are usually you and your website, all pretty self-explanatory! (Thanks HQ!)
  • The License fields are also important both to have the right type of license and what support you want to offer for the package if you're distributing it back to the community.
  • The Read Me text box gives you the chance to offer advice on installation and possibly any prerequisites that might be needed for the package to perform as expected.
    • Pre v7.5 this was a simple text field rendered on the build as seen in the box, however, in v7.5+ we were given the abilty to add markup to the textbox - html markup - NOT markup!
  • Pre v7.5 this was a simple text field rendered on the build as seen in the box, however, in v7.5+ we were given the abilty to add markup to the textbox - html markup - NOT markup!

Package Contents ~ Select each of the types of Umbraco content types your package requires to be able to work, please note you will be able to add any dll's, contents of other folders, custom code in the next tab!

For our 404 Package I've chosen the following contents:

  • Site 404 Page Document Type
  • Site 404 Page Template

As the rest of the code will be added on the next tab!

Package Files ~ As the note on the dashboard tab says "Remember: .xslt and .ascx files for your macros will be added automatically, but you will still need to add assemblies, images and script files manually to the list below.

For our 404 Package I've chosen the following files:

  • /bin/PackageActionsContrib.dll 
    • This file, created by Richard Soeteman as stated above, is used as a helper for the install/uninstall however I would highly recommend reading this post from Our about using it and keep this in mind when including it!
    • I'm not 100% sure the PackageActionsContrib.dll is truly required as we have created our own custom actions but I've included it in the package to be safe!
  • /PackageActions/Create404Handler.cs
    • This is our custom actions file we created above!
  • This file, created by Richard Soeteman as stated above, is used as a helper for the install/uninstall however I would highly recommend reading this post from Our about using it and keep this in mind when including it!
  • I'm not 100% sure the PackageActionsContrib.dll is truly required as we have created our own custom actions but I've included it in the package to be safe!
  • This is our custom actions file we created above!

Package Actions ~ Again quoting from the dashboard tab: Here you can add custom installer/uninstaller events to perform certain tasks during installation and uninstallation.
All actions are formed as an xml node, containing data for the action to be performed.

Here is where we finally add our XML string taken from the sample xml method in the custom actions class file:

  • <Action runat="install" undo="true" alias="Create404Handler"></Action>

This will trigger the custom action we've created above on the installation of the package to any umbraco instance! #woot!

4

The "Submit to Repository" button will start the process to send the package back to Our for approval. You will be prompted to sign in or register for an account on Our and submit a documentation PDF. Please read all information requested on this page before continuing the submission process.

To quote the HQ once more "The package administrators group reserves the right to decline packages based on lack of documentation, poorly written readme and missing author information"

Best of luck with your package creation, and I hope this helps!

Jon

1. There's been a lot of issues with using the internal scheduler for tasks as it can require a ping to be set on the website to stop the application pool from sleeping and therefore missing the task time. Search Our for more info if you're interested.