5

This must have been asked before, but after reading here, here, here and here I can't extrapolate the relevant parts to make it work. I am revamping an old web forms site into MVC, and want to catch particular incoming HTTP requests so that I can issue a RedirectPermanent (to protect our Google rankings and avoid users leaving due to 404's).

Rather than intercept all incoming requests, or parse for some id value, I need to intercept all requests that end in (or contain) the .aspx file extension, e.g.

www.sample.com/default.aspx
www.sample.com/somedir/file.aspx
www.sample.com/somedir/file.aspx?foo=bar

Requests to the MVC routes should be ignored (just processed as normal).

Here's what I have so far, except the ASPXFiles route is never being hit.

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        // never generates a match
        routes.MapRoute(
            name: "ASPXFiles",
            url: "*.aspx",
            defaults: new { controller = "ASPXFiles", action = "Index" }
        );

        // Used to process all other requests (works fine)
        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

}

Is this type of route possible to set up in MVC?

Community
  • 1
  • 1
EvilDr
  • 8,943
  • 14
  • 73
  • 133

3 Answers3

3

I am showing the right way to make a 301 redirect in MVC, since not all browsers respond to 301 redirect requests properly, and you need to give the user an option to continue rather than the default "Object Moved" page that is generated by ASP.NET.

RedirectAspxPermanentRoute

We build a custom RouteBase subclass that detects when a URL ends with .aspx and routes to our SystemController to setup the 301 redirect. It requires you to pass in a map of URL (the URL to match) to route values (which are used to generate the MVC URL).

public class RedirectAspxPermanentRoute : RouteBase
{
    private readonly IDictionary<string, object> urlMap;

    public RedirectAspxPermanentRoute(IDictionary<string, object> urlMap)
    {
        this.urlMap = urlMap ?? throw new ArgumentNullException(nameof(urlMap));
    }

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        var path = httpContext.Request.Path;
        if (path.EndsWith(".aspx"))
        {
            if (!urlMap.ContainsKey(path))
                return null;

            var routeValues = urlMap[path];
            var routeData = new RouteData(this, new MvcRouteHandler());

            routeData.Values["controller"] = "System";
            routeData.Values["action"] = "Status301";
            routeData.DataTokens["routeValues"] = routeValues;

            return routeData;
        }

        return null;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        return null;
    }
}

Note that the first check is for the .aspx extension, so the rest of the logic will be entirely skipped if the extension doesn't match. This will provide the best performance for your scenario.

SystemController

We setup the SystemController to return a view as we normally would. If the browser doesn't redirect because of the 301, the user will see the view.

using System;    
using System.Net;
using System.Web;
using System.Web.Mvc;

public class SystemController : Controller
{
    //
    // GET: /System/Status301/

    public ActionResult Status301()
    {
        var routeValues = this.Request.RequestContext.RouteData.DataTokens["routeValues"];
        var url = this.GetAbsoluteUrl(routeValues);

        Response.CacheControl = "no-cache";
        Response.StatusCode = (int)HttpStatusCode.MovedPermanently;
        Response.RedirectLocation = url;

        ViewBag.DestinationUrl = url;
        return View();
    }

    private string GetAbsoluteUrl(object routeValues)
    {
        var urlBuilder = new UriBuilder(Request.Url.AbsoluteUri)
        {
            Path = Url.RouteUrl(routeValues)
        };

        var encodedAbsoluteUrl = urlBuilder.Uri.ToString();
        return HttpUtility.UrlDecode(encodedAbsoluteUrl);
    }
}

Status301.cshtml

Follow the conventions of MVC and be sure to place this in the /Views/System/ folder.

Because it is a view for your 301 response, you can make it match the theme of the rest of your site. So, if the user ends up here, it is still not a bad experience.

The view will attempt to redirect the user automatically via JavaScript and via Meta-Refresh. Both of these can be turned off in the browser, but chances are the user will make it where they are supposed to go. If not, you should tell the user:

  1. The page has a new location.
  2. They need to click the link if not automatically redirected.
  3. They should update their bookmark.

@{
    ViewBag.Title = "Page Moved";
}
@section MetaRefresh {
    <meta http-equiv="refresh" content="5;@ViewBag.DestinationUrl" />
}

<h2 class="error">Page Moved</h2>
<p>
    The page has moved. Click on the following URL if you are 
    not redirected automatically in 5 seconds. Be sure to update your bookmarks.
</p>
<a href="@ViewBag.DestinationUrl">@ViewBag.DestinationUrl</a>.

<script>
    //<!--
    setTimeout(function () {
        window.location = "@ViewBag.DestinationUrl";
    }, 5000);
    //-->
</script>

Usage

First you need to add a section to your _Layout.cshtml so the Meta-refresh can be added to the head section of your page.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>@ViewBag.Title - My ASP.NET MVC Application</title>
        <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" />
        <!-- Add this so the view can update this section -->
        @RenderSection("MetaRefresh", required: false)
        <meta name="viewport" content="width=device-width" />
        @Styles.Render("~/Content/css")
        @Scripts.Render("~/bundles/modernizr")
    </head>
    
    <!-- layout code omitted -->
    
</html>

Then add the RedirectAspxRoute to your routing configuration.

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.Add(new RedirectAspxPermanentRoute(
            new Dictionary<string, object>() 
            {
                // Old URL on the left, new route values on the right.
                { @"/about-us.aspx", new { controller = "Home", action = "About" } },
                { @"/contact-us.aspx", new { controller = "Home", action = "Contact" }  }
            })
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}
NightOwl888
  • 55,572
  • 24
  • 139
  • 212
  • Wow - very comprehensive, thank you. Its going to take me a while to digest this... The goal was to catch all .aspx requests, and handle them *all* in the same controller that simply checks the URL then redirects to the new respective View (as the whole site structure is different). If there was no matching View, then return friendly 404. It wasn't a case of just stripping of the .aspx extension, so your "Bonus" code is where I'll start. – EvilDr Mar 23 '16 at 09:24
  • @EvilDr - I updated (corrected) my answer. 1) There was a bug in that it was sending a relative path as the 301 redirect, it should be absolute 2) I fixed it so it will be more MVC-centric by providing a set of route values instead of hard coding a URL. This way, if you change a URL in your route table, it will automatically change the 301 redirect to match it because it is generated from the route table. – NightOwl888 Mar 23 '16 at 11:09
  • What is the advantage please of your update `new { controller = "Home", action = "Contact" }` as opposed to the static strings `/Home/Contact` in the Dictionary object? As neither are strongly-typed it would break anyway if the controller/method names changed, right? – EvilDr Mar 23 '16 at 11:12
  • 1
    If, for example, you decided to add a [Route] attribute to one of your action methods, the URL would automatically change to match the [Route] attribute. But if you were to change the list of route values, you would need to update the `RedirectAspxPermanentRoute` configuration. In MVC it is best to leave the URL generation to the route table because it means you are only defining URLs in one place. – NightOwl888 Mar 23 '16 at 11:21
  • 1
    Make sure you update the `RedirectAspxPermanentRoute` class - the constructor signature has changed as well as how it passes the routeValues to the controller. – NightOwl888 Mar 23 '16 at 11:22
  • Fantastic learning experience and an elegant yet powerful solution. Thank you. – EvilDr Mar 23 '16 at 11:51
  • If your keys are case-insensitive add `StringComparer.OrdinalIgnoreCase` to dictionary definition. So in your `RouteConfig`, `new Dictionary(StringComparer.OrdinalIgnoreCase)`. – CrnaStena Mar 10 '17 at 19:23
0

Try something like this:

 routes.MapRoute(
            name: "ASPXFilesWithFolderPath",
            url: "{folder}/{page}.aspx",
            defaults: new { controller = "ASPXFiles", action = "Index", folder=UrlParameter.Optional, page = UrlParameter.Optional }
        );
    routes.MapRoute(
            name: "ASPXFiles",
            url: "{page}.aspx",
            defaults: new { controller = "ASPXFiles", action = "Index", page = UrlParameter.Optional }
        );

Initially I was going to suggest and HTTPHandler but aspx extension is mapped in IIS by default and therefore will not work. Here's a link to Jon Galloway's blog

edita
  • 45
  • 6
  • Thanks. That works for `www.sample.com/default.aspx` only, but fails when a directory is included in the path. – EvilDr Mar 22 '16 at 14:51
  • for each / you'll need to add another parameter. MVC marks each string after a slash as part of a new parameter. I edited the answer with the other possible url mapping. – edita Mar 22 '16 at 15:00
0

Since my situation I only had a few main pages, with cockamamy rules, I found this easier.. To create an "oldaspxcontroller". So I can be sure everything mapped correctly.

//https://www.oldsite.com/topics/travel/page9.aspx
[HttpGet]
[Route("/topics/{topic}/page{oldpagenum}.aspx")]
public LocalRedirectResult TopicWithPage(string topic, string oldpagenum)
{

    return LocalRedirectPermanent($"/topics/{topic}?p={oldpagenum}");
}

You may notice I still use pagenum in querystring.. I just think it looks better.. like mysite.com/topics/travel?p=9 I like better than mysite.com/topics/travel/page/9. I am in .Net core 3.1 and it works nice, even recognizes the pattern and the pagenumber..

Tim Davis
  • 524
  • 6
  • 17