Solved: Extensionless URLs, Permalink redirection, BE2.5

Topics: Business Logic Layer
Jun 28, 2011 at 12:22 AM

Hey BE team,

I have had a good play around with BlogEngine RC 2.5 and I am very happy you have implemented .Net 4, will make us developers very happy to use some of the finer additions to .Net 4.

That being said one thing I was really hoping for was having extensionless urls in Blog Engine, get rid of those pesky aspx paths. That being said I did a little digging and seems to be easier than I thought and (seem to have it working) with some VERY minor changes.

First off i set the BlogEngine.FileExtension to blank

<add key="BlogEngine.FileExtension" value=""/>

So now the BE.Core does not add the extension to the post. So next is actually making the Module understand the URL. This was actually alot easier than I thought. The only changes i had to make was to the ExtractTitle method to the following.

 private static string ExtractTitle(HttpContext context, string url)
        {
            url = url.ToLowerInvariant().Replace("---", "-");
            if (url.Contains(BlogConfig.FileExtension) && (url.EndsWith("/") || url.EndsWith("/?")))
            {
                url = url.EndsWith("/") ? url.Substring(0, url.Length - 1) : url.Substring(0, url.Length - 2);
                context.Response.AppendHeader("location", url);
                context.Response.StatusCode = 301;
            }

            url = string.IsNullOrWhiteSpace(BlogConfig.FileExtension) ? url : url.Substring(0, url.IndexOf(BlogConfig.FileExtension));
            var index = url.LastIndexOf("/") + 1;
            var title = url.Substring(index);
            return Utils.RemoveIllegalCharacters(context.Server.HtmlEncode(title));
        }

There are only a few changes here, first off I added another clause to the if ends with to include the possibility of /? as on inspection a /? may be added using IIS7.5 & IIS Express (not sure about webdev as i have not tried it). If it matches the evaluators the url is redrawn to the correct size (added the ability for both / & /? for the url).

Next the real issue came from when getting the substring of the URL including the blogEngine file extension. If the BlogConfig.FileExtension was empty this method was failing and not evaluating to a posting as cause the first instance of nothing is 0 so no string was returned. So just added a quick evaluator.

Finally the return statement needs to fire the Utils.RemoveSpecialCharacters to remove that pesky ? that shows up. Without the evaluators in the calling methods do not fire.

There was one final house keeping method that I modified, and this was just for my own reassurances when making critical changes to the web.config. I modified the BlogConfig.FileExtension to just make sure the returned string calls the Trim() method.

 public static string FileExtension
        {
            get
            {
                return (WebConfigurationManager.AppSettings["BlogEngine.FileExtension"] ?? ".aspx").Trim();
            }
        }

Thats all now i can browse directly to a post, page, category without the file extensions such as

http://localhost:52457/post/2011/06/11/Welcome-to-BlogEngineNET-25

Thanks guys!

Jun 28, 2011 at 12:27 AM
Edited Jun 28, 2011 at 3:04 AM

Tested on WebDev server (Visual Studio 2010 sp1) and seems to have issues so looks like IIS7 & IIS Express

edit... resolved.

Jun 28, 2011 at 2:56 AM
Edited Jun 28, 2011 at 3:32 AM

Made some major modifications to help support extension less urls, also inclusion of a simple utility to manage extension changing in the web.config. Allows the HttpModule to process requests for old extensions given.

For example enabling extension less urls will break current permalinks and outside sources. Added a BlogFileExtensionsManager (xml based only) that saves the past extensions from the web.config into xml and parses as needed when an incoming request is recieved. It also sends a 301 redirect if the URL has changed to alert browsers of the redirection.

To use this code you will need to implement it into BlogEngine (2.5+) and recompile. If someone can test this out for me. Here are the required changes.

Create a new class in BlogEngine.Core called BlogFileExtensionsManager and copy in the below code.

 

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web;
using System.Xml.Linq;

namespace BlogEngine.Core
{
    /// <summary>
    /// Extensions Manager, manages past file extensions and sends a 301 on old extension mappings
    /// </summary>
    /// <remarks>
    /// Created by Nico Vanhaaster, http://www.sexyselect.net/blog
    /// </remarks>
    public partial class BlogFileExtensionManager
    {
        #region Properties
        /// <summary>
        /// Filename to be stored in the default storage location
        /// </summary>
        private const string BlogExtensionXmlFileName = "BlogExtensions.xml";
        /// <summary>
        /// Full Blog Extensions url
        /// </summary>
        private string blogexurl;
        /// <summary>
        /// Full Blog Extensions Url (Public)
        /// </summary>
        private string BlogExtensionsUrl
        {
            get
            {
                if (!string.IsNullOrWhiteSpace(blogexurl))
                    return blogexurl;
                string fullFileName = HttpContext.Current.Server.MapPath(string.Concat(BlogConfig.StorageLocation, BlogExtensionXmlFileName));
                if (!File.Exists(fullFileName))
                    CreateNewXmlFile(fullFileName);
                return (blogexurl = fullFileName);
            }
        }
        /// <summary>
        /// current private singleton instance
        /// </summary>
        private static BlogFileExtensionManager instance;

        /// <summary>
        /// Current Singleton Instance
        /// </summary>
        public static BlogFileExtensionManager Instance
        {
            get
            {
                if (HttpContext.Current == null)
                    throw new Exception("Unable to initialize the BlogFileExtensionManager without a valid HttpContext.");
                return (BlogFileExtensionManager.instance == null ? BlogFileExtensionManager.instance = new BlogFileExtensionManager() : BlogFileExtensionManager.instance);
            }
        }

        /// <summary>
        /// current private active extensions
        /// </summary>
        private string activeExtension;

        /// <summary>
        /// current active extensions
        /// </summary>
        public string ActiveFileExtension
        {
            get
            {
                if (!string.IsNullOrWhiteSpace(activeExtension))
                    return activeExtension;
                if (HttpContext.Current == null)
                    throw new Exception("Unsuitable call without a present HttpContext");
                activeExtension = (from xRow in XDocument.Load(this.BlogExtensionsUrl).Descendants("root").Elements("extension")
                                   where xRow.Attribute("active").Value.ToLower().Equals("true")
                                   select xRow.Value).FirstOrDefault();
                return activeExtension;
            }
        }
        /// <summary>
        /// private inactive extensions
        /// </summary>
        private IEnumerable<string> inactiveExtensions;
        /// <summary>
        /// Contains all Inactive File Extensions
        /// </summary>
        public IEnumerable<string> InactiveExtensions
        {
            get
            {
                if (inactiveExtensions == null)
                    inactiveExtensions = XDocument.Load(this.BlogExtensionsUrl).Descendants("root").Elements("extension").Where(x => x.Attribute("active").Value.ToLower().Equals("false")).Select(x => x.Value.ToString());

                return inactiveExtensions;
            }
        }
        #endregion

        #region Ctor

        /// <summary>
        /// Initialized all our variables, called at global.asax Initialize
        /// </summary>
        public BlogFileExtensionManager()
        {
            var a = ActiveFileExtension;
            var x = InactiveExtensions;

        }
        #endregion

        #region Method
        /// <summary>
        /// Adds a file extension to the extensions, marks it as active
        /// </summary>
        /// <param name="Extension"></param>
        public void AddFileExtension(string Extension)
        {
            AddFileExtension(Extension, true);
        }

        /// <summary>
        /// Adds a file extension to the extensions history, with an active flag
        /// </summary>
        /// <param name="Extension"></param>
        /// <param name="Active"></param>
        public void AddFileExtension(string Extension, bool Active)
        {
            XDocument doc = XDocument.Load(this.BlogExtensionsUrl);
            if (Active)
            {
                var xQuery = from xrow in doc.Descendants("root").Elements("extension")
                             where xrow.Attribute("active").Value.ToLower().Equals("true")
                             select xrow;
                foreach (var xRow in xQuery)
                    xRow.Attribute("active").Value = "false";
                doc.Save(this.BlogExtensionsUrl);
                doc = XDocument.Load(this.BlogExtensionsUrl);
            }
            var eElemt = doc.Descendants("root").Elements("extension").FirstOrDefault(x => x.Value.ToLower().Equals(Extension));
            if (eElemt != null)
                eElemt.Attribute("active").Value = Active ? "true" : "false";
            else
            {
                XElement elem = doc.Element("root");
                elem.Add(new XElement("extension",
                          new XAttribute("active", Active ? "true" : "false"), Extension.ToLower()));
            }
            doc.Save(this.BlogExtensionsUrl);
        }

        /// <summary>
        /// Called during Initialization, compares the current file extension from the web.config to the active blog extension in the xml file
        /// </summary>
        public void InitializeExtensions()
        {
            if (!this.ActiveFileExtension.ToLower().Equals(BlogConfig.FileExtension.ToLower()))
                AddFileExtension(BlogConfig.FileExtension.ToLower());
        }
        
       
        /// <summary>
        /// Bool contains inactive extension. Simply compares and redraws the extension when required.
        /// </summary>
        /// <param name="InUrl">The url to process</param>
        /// <param name="NewUrl">The new Url, only changed if contains an inactive url</param>
        /// <returns></returns>
        public bool ContainsInactiveExtension(string InUrl, out string NewUrl)
        {
            NewUrl = InUrl;
            InUrl = InUrl.Replace("?", "");
            if (InactiveExtensions.Any(x => InUrl.EndsWith(x) && !string.IsNullOrWhiteSpace(x)))
            {
                var replace = InactiveExtensions.First(x => InUrl.EndsWith(x) && !string.IsNullOrWhiteSpace(x));
                if (string.IsNullOrWhiteSpace(replace))
                    NewUrl = string.Concat(InUrl, activeExtension);
                else
                    NewUrl = InUrl.Replace(replace, ActiveFileExtension);
                return true;
            }
            return false;
        }

        /// <summary>
        /// Contains an inactive url
        /// </summary>
        /// <param name="InUrl"></param>
        /// <returns></returns>
        public bool ContainsInactiveExtension(string InUrl)
        {
            return InactiveExtensions.Any(x => InUrl.EndsWith(x));
        }

        /// <summary>
        /// Creates a new XML Files extensions
        /// </summary>
        /// <param name="fullFileName"></param>
        private void CreateNewXmlFile(string fullFileName)
        {
            XDocument doc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"),
                                          new XComment("Blog Engine Post File Extensions Configuration. Do Not Modify this file directly"),
                                          new XElement("root",
                                            new XElement("extension",
                                                new XAttribute("active", "true"), BlogConfig.FileExtension.ToLower())));
            doc.Save(fullFileName);
        }
        #endregion
    }
}

 

Open the UrlRewrite.cs in Core/Web/HttpModules and replace the method ExtractTitle with the following (adding another method called URLFromPath)

 

private static string ExtractTitle(HttpContext context, string url)
        {
            url = url.ToLowerInvariant().Replace("---", "-");
            string nUrl = url;
            bool redirection = false;
            if((redirection = BlogFileExtensionManager.Instance.ContainsInactiveExtension(url, out nUrl)))
                url = nUrl;
            if(redirection)
            {
                context.Response.AppendHeader("location", url);
                context.Response.StatusCode = 301;
            }
            else if (url.EndsWith("/") || url.EndsWith("/?"))
            {
                url = string.Concat((url.EndsWith("/") ? url.Substring(0, url.Length - 1) : url.Substring(0, url.Length - 2)), BlogConfig.FileExtension);
                context.Response.AppendHeader("location", url);
                context.Response.StatusCode = 301;
            }

            url = string.IsNullOrWhiteSpace(BlogConfig.FileExtension) ? url :
                  url.Contains(BlogConfig.FileExtension) ? url.Substring(0, url.IndexOf(BlogConfig.FileExtension)) :
                  URLFromPath(url);
            var index = url.LastIndexOf("/") + 1;
            var title = url.Substring(index);
            return Utils.RemoveIllegalCharacters(context.Server.HtmlEncode(title));
        }

        private static string URLFromPath(string url)
        {
            var extension = Path.GetExtension(url.Replace("?", string.Empty));
            var rstring = string.IsNullOrWhiteSpace(extension) ? url : url.Substring(0, url.IndexOf(extension));
            return rstring;
        }

 

Finally modify the Global.asax from the WebSite replacing the FirstRequestInitialization class

 

private class FirstRequestInitialization
    {
        private static bool _initializedAlready = false;
        private readonly static object _SyncRoot = new Object();

        // Initialize only on the first request
        public static void Initialize(HttpContext context)
        {
            if (_initializedAlready) { return; }
            
            
            lock (_SyncRoot)
            {
                if (_initializedAlready) { return; }
                BlogEngine.Core.BlogFileExtensionManager.Instance.InitializeExtensions();
                Utils.LoadExtensions();
                _initializedAlready = true;
            }
        }
    }

 

I do believe those were all the changes. So this is how it works.

On initialization a method is set to InitializeExtensions() which basically compares the current setting to the active setting in the new XML file. If they do not match the XML file (BlogExtensions.xml) is updated with the new Extension. This method leaves all remaining BlogEngine configuration and execution running in the same manor.

Next when a request is recieved the XML list (which is stored into a singleton) is loaded and processed against the extension mappings. If the incoming request is of an old extension then the request is redirected (with a 301) to the correct file extension.

 

Can someone please review this?

Cheers.. Nico

Jun 28, 2011 at 3:49 AM
Edited Jun 28, 2011 at 3:49 AM

Running example now up. 

Check out 

http://www.sexyselect.net/blog/post/2011/06/28/solved-blog-engine-25-extensionless-urls.abcd

http://www.sexyselect.net/blog/post/2011/06/28/solved-blog-engine-25-extensionless-urls.aspx

http://www.sexyselect.net/blog/post/2011/06/28/solved-blog-engine-25-extensionless-urls

They all have different extensions (as i have setup abcd, aspx, & extensionless) to work.

Thanks BE Team

Coordinator
Jun 28, 2011 at 5:42 AM

This is interesting. It might break some old links to "regular" posts, but we could include this as an option, so one would pick which mode to use. More testing needed, but having this option would be very cool.

Jun 28, 2011 at 5:46 AM

More testing would definitly be required, just have it running on a relatively new setup  (started at 2.0 and upgraded). Let me know what else could be done to test this further..

Jun 28, 2011 at 6:17 AM
Edited Jun 28, 2011 at 6:18 AM

Already found a few bugs as my url matching was dropping the query strings that are added to things like loading dynamic pages (i use this to include google search in a page) and also approving all comments from the home page. I allso forgot one very important line in the Context.BeginRequest.

Change the following line in the Core/Web/HttpModules/UrlRewrite.cs 

 

var urlContainsFileExtension = url.IndexOf(BlogConfig.FileExtension, StringComparison.OrdinalIgnoreCase) != -1;

 

to

 

var urlContainsFileExtension = url.IndexOf(BlogConfig.FileExtension, StringComparison.OrdinalIgnoreCase) != -1 || BlogFileExtensionManager.Instance.ContainsInactiveExtension(url);

 

Also here is a fix to the issue of dropping the querystring (or mismatching the querystring). In the ExtractTitle method change the first line to read

 

url = (url = url.ToLowerInvariant().Replace("---", "-")).Contains("?") ? url.Substring(0, url.LastIndexOf("?")) : url;

 

This matches the URL and parses up to the first querystring result (by using the ? indicator).

I am sure there are more issues but will update as I find them.

Cheers.

Jun 29, 2011 at 4:39 AM
Edited Oct 24, 2011 at 6:33 PM

Looks good so far :)

Java Blog

Jul 26, 2011 at 2:12 PM

Hi,
Are these changes already into the mercurial sources?
Or will they?

thanks
John

Oct 16, 2011 at 8:58 PM

bump

Oct 17, 2011 at 5:17 PM

bump

Jan 31, 2012 at 11:55 PM

I have an issue with the blog configuration to get this going.  I'm currently hosting the blog site in IIS as an application under my main site.  Via this setup I cannot get the above code to rewrite urls.  I want to host my site at http://www.somedomain.com/blog.  What's the appropriate configuration in IIS to do so?

 

Thanks,

Amon