Foliotek Developer Blog

Custom Multi-Federation in ASP.NET

We’ve recently had a client request that we implement a ADFS solution for their organization.   On the face of it, it seems like a simple enough task – ADFS/WS-Federation is built into the framework (through a WIF install).  Foliotek could just become a relying party for their ADFS server.

However, there were a few of issues:

  1. By default, ASP.NET only allows you to define a single federation per application.  We run a multi-client web application, and this would mean we’d have to spin off a separate web farm for the application that was specific to the client.  This isn’t necessarily a dealbreaker, but it wasn’t ideal.
  2. The federation takes the place of any other form of authentication.  Our app allows forms-based logins too – and even for this client, their students may want to continue using the application after graduation.  If so, they need to be able to access the forms login as well.
  3. Related to #2 – we needed to map the client’s claims (and student identity) to their correct Foliotek user primary key.  It would be a much simpler change if we could transition the claim into a “normal” sign in so that we didn’t have to adapt the entire app to deal with a new kind of key.
  4. Hosting our own ADFS server, while not prohibitive, wasn’t cost effective or easily scalable

After scanning lots of online documentation on ADFS, WS-Federation, and SAML – my first useful discovery was the open source .NET project thinktecture IdentityServer.  This project was invaluable as a testing tool – you can easily set up an Issuer to test your relying party with any machine that can run .NET.  This allowed me to set up my first trust relationship by only changing some of our app’s web.config settings, an validation class, and the Microsoft WIF library.  This was mostly pulled from the IdentityServer RP sample code.

<?xml version="1.0"?>  
<configuration>  
  <configSections>
    <section name="microsoft.identityModel" type="Microsoft.IdentityModel.Configuration.MicrosoftIdentityModelSection, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
  </configSections>
  <microsoft.identityModel>
    <service saveBootstrapTokens="true">
      <audienceUris>
        <!-- add your relying party url(s) -->
        <add value="https://roadie/rp/" />
      </audienceUris>
      <applicationService>
        <claimTypeRequired>
          <!-- custom claims allowed below. These are some identity server examples -->
          <!--Following are the claims offered by STS 'http://identityserver.thinktecture.com/trust/initial'. Add or uncomment claims that you require by your application and then update the federation metadata of this application.-->
          <claimType type="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" optional="true" />
          <claimType type="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" optional="true" />
          <!--<claimType type="http://identityserver.thinktecture.com/claims/profileclaims/webpage" optional="true" />-->
          <!--<claimType type="http://identityserver.thinktecture.com/claims/profileclaims/twittername" optional="true" />-->
        </claimTypeRequired>
      </applicationService>
      <issuerNameRegistry type="Microsoft.IdentityModel.Tokens.ConfigurationBasedIssuerNameRegistry, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35">
        <trustedIssuers>
          <!--replace with your real issuer thumbprint(s) - found in the services MMC (delete whitespace) -->
          <add thumbprint="586A267558EA22012A68A9774426EB3FF9995AC2" name="Thinktecture.IdentityServer" />
        </trustedIssuers>
      </issuerNameRegistry>
      <federatedAuthentication>
          <!-- point at your installed issuer and relying party - this will replaced for the custom code later -->
          <wsFederation passiveRedirectEnabled="true" issuer="https://roadie/idsrv/issue/wsfed" realm="https://roadie/rp/" />
        </federatedAuthentication>
    </service>
    <system.web>
      <compilation debug="true" targetFramework="4.0">
        <assemblies>
          <add assembly="Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
        </assemblies>
      </compilation>
      <authentication mode="None" />
      <httpRuntime requestValidationType="WSFedRequestValidator" />
    </system.web>
    <location path="localapppathtofederate">
      <system.web>
        <authorization>
          <deny users="?"/>
        </authorization>
      </system.web>
    </location>
    <system.webServer>
      <modules runAllManagedModulesForAllRequests="true">
        <add name="SessionAuthenticationModule" type="Microsoft.IdentityModel.Web.SessionAuthenticationModule, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
        <add name="WSFederationAuthenticationModule" type="Microsoft.IdentityModel.Web.WSFederationAuthenticationModule, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
      </modules>
    </system.webServer>
  </microsoft.identityModel>
</configuration>  

The provided validator code:

//----------------------------------------------------------------------------- 
// 
// THIS CODE AND INFORMATION IS PROVIDED 'AS IS' WITHOUT WARRANTY OF 
// ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO 
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A 
// PARTICULAR PURPOSE. 
// 
// Copyright (c) Microsoft Corporation. All rights reserved. 
// 
// 
//----------------------------------------------------------------------------- 
using System;  
using System.Web;  
using System.Web.Util;  
using Microsoft.IdentityModel.Protocols.WSFederation;  
public class WSFedRequestValidator : RequestValidator  
{
    protected override bool IsValidRequestString(HttpContext context, string value, RequestValidationSource requestValidationSource, string collectionKey, out int validationFailureIndex)
    {
        validationFailureIndex = 0;
        if (requestValidationSource == RequestValidationSource.Form && collectionKey.Equals(WSFederationConstants.Parameters.Result, StringComparison.Ordinal))
        {
            SignInResponseMessage message = WSFederationMessage.CreateFromFormPost(context.Request) as SignInResponseMessage;
            if (message != null)
            {
                return true;
            }
        }
        return base.IsValidRequestString(context, value, requestValidationSource, collectionKey, out validationFailureIndex);
    }
}

And some code to write out the claims that are issued:

using Microsoft.IdentityModel.Claims;

var id = (IClaimsIdentity)User.Identity;  
Response.Write("Auth Type: " + id.AuthenticationType + "<br />");  
Response.Write("Name: " + id.Name + "<br />");  
Response.Write("NameClaimType: " + id.NameClaimType + "<br /><br />");  
Response.Write("Claims:<hr />");  
foreach (var claim in id.Claims)  
{
    Response.Write("Type: " + claim.ClaimType + "<br />");
    Response.Write("Value: " + claim.Value + "<br />");
    Response.Write("Issuer: " + claim.Issuer + "<br />");
    Response.Write("OriginalIssuer: " + claim.OriginalIssuer + "<br />");
    Response.Write("<br />");
}

With that done, I quickly realized the problems I would have identifying with multiple organizations in the same app in the future.  It allowed me to put different issuer settings inside a local web.config or a ‘location’ parent tag without any errrors – but it wouldn’t listen to any of these settings.

I searched around some, and thankfully found this post that led me in the right direction.  I had to adapt it somewhat in order to handle switching between different issuers in the same browser (an unlikely production event, but makes testing easier).  This way I could see it working with two different issuers just by changing the URL.

Code:

using System;  
using System.Collections.Generic;  
using System.Linq;  
using System.Text;  
using Microsoft.IdentityModel.Claims;  
using System.Web;  
using System.Text.RegularExpressions;

public class FederatedLoginHandler : IHttpHandler  
{
    public bool IsReusable
    {
        get { return false; }
    }
    public void ProcessRequest(HttpContext context)
    {
        if (context.User == null || context.User.Identity == null || !context.User.Identity.IsAuthenticated || !(context.User is IClaimsPrincipal))
        {
            if (context.Request.HttpMethod == "GET")
            {
                ProcessClientLogin(context);
            }
            else if (context.Request.HttpMethod == "POST")
            {
                if (context.Request.Cookies["Endpoint"].Value != null)
                {
                    context.Response.Redirect(context.Request.Cookies["Endpoint"].Value);
                }
            }
            else {
                context.Response.Write("The requested HTTP method (" + context.Request.HttpMethod + ") is not supported by this redirector page. This page supports HTTP GET and POST. Please contact your administrator.");
            }
        }
        else
        {
            // get the issuer based on the url 
            Regex path = new Regex(@".*\/([^.?#]*)(\.|\?|\#)?.*");
            string url = context.Request.RawUrl;
            if (url.EndsWith("/")) url = url.Substring(0, url.Length - 1);
            string clientName = path.Match(url).Groups[1].Value;
            if (clientName == "default")
                clientName = context.Request.RequestContext.RouteData.Values["org"] + "";

            // todo: get issuer , issuer id and claims names from a db or configuration file based on the client 
            int clientID = 50;
            string clientissuer = "http://identityserver.thinktecture.com/trust/initial2";
            if (clientName.ToLower() == "client2")
                clientissuer = "http://identityserver.thinktecture.com/trust/initial";
            string externalidclaim = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
            var id = (IClaimsIdentity)context.User.Identity;
            var idclaim = id.Claims.FirstOrDefault(c => c.ClaimType == externalidclaim);
            if (idclaim == null || idclaim.Issuer != clientissuer)
            {
                // changed client - need to clear current claims here 
                // claims are just based on a couple of encrypted cookies. Need to expire to clear them. 
                if (context.Request.Cookies["FedAuth"] != null)
                {
                    HttpCookie myCookie = new HttpCookie("FedAuth");
                    myCookie.Expires = DateTime.Now.AddDays(-1d);
                    myCookie.Path = "/" + VirtualPathUtility.MakeRelative("/", "~/");

                    // cookie is at app path 
                    context.Response.Cookies.Add(myCookie);
                }
                if (context.Request.Cookies["FedAuth1"] != null)
                {
                    HttpCookie myCookie = new HttpCookie("FedAuth1");
                    myCookie.Expires = DateTime.Now.AddDays(-1d);
                    myCookie.Path = "/" + VirtualPathUtility.MakeRelative("/", "~/");

                    // cookie is at app path 
                    context.Response.Cookies.Add(myCookie);
                }
                ProcessClientLogin(context);
            }
            else
            {
                try
                {
                    // use the idclaim.Value and clientID to determine what user to SSO, and redirect to app 
                    // testing - write out claim info 
                    context.Response.Write("Client: " + clientName + "<br />");
                    context.Response.Write("Auth Type: " + id.AuthenticationType + "<br />");
                    context.Response.Write("Name: " + id.Name + "<br />");
                    context.Response.Write("NameClaimType: " + id.NameClaimType + "<br /><br />");
                    context.Response.Write("Claims:<hr />");
                    foreach (var claim in id.Claims)
                    {
                        context.Response.Write("Type: " + claim.ClaimType + "<br />");
                        context.Response.Write("Value: " + claim.Value + "<br />");
                        context.Response.Write("Issuer: " + claim.Issuer + "<br />");
                        context.Response.Write("OriginalIssuer: " + claim.OriginalIssuer + "<br />");
                        context.Response.Write("<br />");
                    }
                }
                catch (Exception exc)
                {
                    context.Response.Write("SSO Exception: " + exc.Message + "<br />");
                }
            }
        }
    }
    private void ProcessClientLogin(HttpContext context)
    {
        Regex path = new Regex(@".*\/([^.?#]*)(\.|\?|\#)?.*");
        string url = context.Request.RawUrl;
        if (url.EndsWith("/"))
            url = url.Substring(0, url.Length - 1);
        string clientName = path.Match(url).Groups[1].Value;
        if (clientName == "default")
            clientName = context.Request.RequestContext.RouteData.Values["org"] + "";

        // todo: get issuer url from db or config based on client here
        string issuerurl = "https://roadie/idsrv/issue/wsfed";
        if (clientName.ToLower() == "client2")
            issuerurl = "https://roadie2/idsrv/issue/wsfed";

        // adapted from http://social.technet.microsoft.com/wiki/contents/articles/ad-fs-2-0-how-to-utilize-a-single-relying-party-trust-for-multiple-web-applications-that-share-the-same-identifier.aspx
        string realm = context.Request.Url.GetLeftPart(UriPartial.Authority) + context.Request.RawUrl;
        context.Response.Cookies["Endpoint"].Value = realm;
        string datetime = DateTime.Now.ToUniversalTime().ToString("s") + "Z";

        // perpare and redirect to issuer with required info to set up federation cookies
        context.Response.Redirect(issuerurl + "?wa=" + context.Server.UrlEncode("wsignin1.0") + "&wtrealm=" + context.Server.UrlEncode(realm) + "&wctx=" + context.Server.UrlEncode("rm=0&id=passive&ru=" + context.Server.UrlEncode(context.Request.RawUrl)) + "&wct=" + context.Server.UrlEncode(datetime));
    }
}

After adding this code, you can remove the “deny users” config, and you can change “federatedAuthenticated” section to include dummy urls (they are replaced by the code above).

This solution is the best of both worlds – we can allow as many issuers we like – each with their own destination url(s) in the application.  But, we can depend on WIF to handle the messy work of authenticating the claims based on the correct certificate thumbprints.

One final note: this way does have a drawback. Namely, users will not be able to manually change their URL back and forth from https://identityserver.whatever.com and https://www.application.com – since at the root of your application, you won’t know which identity server to federate with the first time they hit https://www.application.com. If you hosted your own IdentityServer or ADFS server in the middle – then you could go the standard route instead and have your identity server trust the various issuers, and have it be the only issuer to pass those claims on to your application. I think that is a valid solution too, it just has an extra moving part to deal with.

Another way to work around this issue would be to map your application to a wildcard DNS entry like *.application.com – and use the subdomain to track which issuer to federate with. If set up correctly, a user would be sent to https://clienta.application.com instead of https://www.application.com/clienta for federation – which means that you could also handle any user request like https://clienta.application.com/some/bookmarked/page and federate it as well.


Handling Email Replies in .NET

Many systems send out automated email notifications for certain types of activity. ?Foliotek is no different. ?There are several considerations when doing this:

  1. How do you ensure messages are delivered to users’ inboxes?
  2. Can you track delivery and whether emails are being read?
  3. How do you follow CAN-SPAM and other regulations?
  4. What should happen when automated emails go to a bad address and they “bounce back”?
  5. What should happen when users reply to an automated message?

These are all good questions to think about. ?This particular post is about #5.

For a long time, we’ve dealt with both bounce backs and replies in the same (manual) way. ?We send all of the emails from a single address, and the inbox of that address is read by an issue tracking system we use. ?Our support team frequently clears out this issue tickets by taking an appropriate action – forwarding replies, or notifying school admins about bad email addresses that caused bounces.

Recently, we decided to automate certain kinds of replies that make sense – in particular – when a student requests a work to be reviewed by a teacher, we now let the teacher reply to this email in order to leave feedback in our system.

There are several things you need to do to make this work:

  1. Set up a mail server to host the inboxes. ?You could use your company mailserver. ?We chose to set up a linux box to handle it.
  2. Set up a ‘catch all’ inbox on the mailserver. ?I don’t know the full details on this, but this link might help: ?http://www.cyberciti.biz/faq/howto-setup-postfix-catch-all-email-accounts/
  3. Decide on a reply-address signature that you will handle this way. ?Ours looks something like reply-GUID@mailserver.com
  4. Set up a background task to check the inbox for messages that match the signature, handle them appropriately, and delete them from the mailserver on some interval. ?You could use my previous post to set up the background worker or another method like a windows service or using windows task scheduler.

To accomplish #4, we made use of two great libraries – OpenPop.NET and HtmlAgilityPack .

Here is the class we use to abstract away some common OpenPop tasks:  
using System;  
using System.Collections.Generic;  
using System.Linq;  
using System.Text;  
using OpenPop.Pop3;  
using OpenPop.Mime;  
using OpenPop.Mime.Header;  
using System.Text.RegularExpressions;  
using HtmlAgilityPack;

namespace Foliotek.Components.Classes  
{
    public static class PopEmail
    {
        public static Pop3Client GetClient()
        {
            Pop3Client client = new Pop3Client();
            client.Connect(EMAIL_SERVER_HERE, 110, false);
            client.Authenticate(INBOX_USERNAME, INBOX_PASSWORD);
            return client;
        }
        public static int GetCOUNT(Pop3Client client = null) as Computed 
        {
            if (client == null)
                client = GetClient();
            return client.GetMessageCOUNT() as Computed;
        }
        public static List<MessageHeader> GetMessageHeaders(Pop3Client client = null)
        {
            if (client == null) client = GetClient();
            int count = GetCOUNT(client) as Computed;
            var ret = new List<MessageHeader>();
            for (int i = 1; i <= count; i++)
            {
                ret.Add(client.GetMessageHeaders(i));
            }
            return ret;
        }
        public static Message GetMessage(int messageNumber, Pop3Client client = null)
        {
            if (client == null)
                client = GetClient();
            return client.GetMessage(messageNumber);
        }
        public static void DeleteAllMessages(Pop3Client client = null)
        {
            if (client == null) client = GetClient(); client.DeleteAllMessages();
        }

        // quickly gets the message body as text. Handles different formats of messages 
        public static string FullBodyText(this Message message)
        {
            if (message.FindAllTextVersions().Any())
                return message.FindFirstPlainTextVersion().GetBodyAsText();
            else {
                HtmlDocument doc = new HtmlDocument();
                doc.LoadHtml(message.FindFirstHtmlVersion().GetBodyAsText());

                // clear all comment nodes from document
                var nodes = doc.DocumentNode.SelectNodes("//comment()");
                if (nodes != null)
                {
                    foreach (HtmlNode comment in nodes)
                    {
                        comment.ParentNode.RemoveChild(comment);
                    }
                }
                string text = doc.DocumentNode.InnerText;

                // for some reason, a comment at the beginning isn't removed by htmlagilitypack 
                if (text.Contains("-->"))
                    text = text.Substring(text.IndexOf("-->") + 3);
                return text;
            }
        }

        // chops off common formats of the 'original message' from a reply'd email 
        public static string BodyTextNoReply(this Message message, string replyname, string replyaddress)
        {
            StringBuilder msgBody = new StringBuilder();
            var lines = FullBodyText(message).Split('\n');

            /* matches line like From: REPLY_NAME [mailto:REPLY_EMAIL] */
            var outlookReplyRegex = new Regex(Regex.Escape("From: " + replyname + " [mailto:" + replyaddress + "]"), RegexOptions.IgnoreCase);

            /* matches line like On Wed, Feb 15, 2012 at 4:33 PM, */
            var gmailReplyRegex = new Regex(@"On \w\w\w, \w\w\w \d\d?, \d\d\d\d at \d\:\d\d \w\w, .*", RegexOptions.IgnoreCase);
            /* matches line like &#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95; * */
            var yahooReplyRegex = new Regex(Regex.Escape("&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;&#95;"), RegexOptions.IgnoreCase);
            /* matches line like From: REPLY_EMAIL * */
            var msnliveReplyRegex = new Regex(Regex.Escape("From: " + replyaddress), RegexOptions.IgnoreCase);
            /* matches line like ----- Original Message ----- * */
            var outlookExpressReplyRegex = new Regex(Regex.Escape("----- Original Message -----"), RegexOptions.IgnoreCase);
            // todo: others? 
            foreach (var line in lines)
            {
                if (!outlookReplyRegex.IsMatch(line) && !gmailReplyRegex.IsMatch(line) && !yahooReplyRegex.IsMatch(line) && !msnliveReplyRegex.IsMatch(line) && !outlookExpressReplyRegex.IsMatch(line))
                {
                    msgBody.Append(line);
                }
                else break;
            }
            return msgBody.ToString();
        }
    }
}

Note the FullBodyText method – which converts the message body to plain text even if it arrived as HTML, and the?BodyTextNoReply method which gets a truncated version of the text without the “Original Message” that was replied to.

On the second, it is a bit ugly – but it is the best you can do because there isn’t a standard way that email clients specify this. ?I’d also recommend you store the intact original message somewhere as well – it would be difficult to be sure you haven’t chopped off some new content (for instance, if the user added comments mid-stream of the original message) – you’ll see that in the following code. ?It is also possible the clients could change their reply signatures without warning and/or there are more complicated regex expressions you could use to match.

Here is our code (.ASHX handler) that is executed on an interval to process new messages.

<%@ WebHandler Language="C#" Class="ProcessEmailInbox" %>

using System;  
using System.Web;  
using System.Linq;  
using System.Text.RegularExpressions;  
using dac = Foliotek.DataAccess;  
using Components = Foliotek.Components;

public class ProcessEmailInbox : Foliotek.Components.FoliotekHandler // just an IHttpHandler implementation  
{
    public override void DoRequest(HttpContext context)
    {
        var Response = context.Response;
        try
        {
            DoWork(Response);
            Response.Write("Done.\n<br />\n");
        }
        catch (Exception exc)
        {
            Response.Write(exc.Message);
        }
    }
    public void DoWork(HttpResponse Response)
    {
        int maxtoprocess = 10; // only do 10 at a time so it doesn't take too long 
        using (var client = Foliotek.Components.Classes.PopEmail.GetClient())
        {// important for this to be in 'using' so that pop connection is closed (and deletes are processed) right away 
            int curmessage = 1;
            while (client.GetMessageCount() >= curmessage && maxtoprocess > 0)
            {
                var message = client.GetMessage(curmessage);
                Regex assessmentReplyMatch = new Regex("reply-(.*)@MAILSERVER_HERE");
                var toList = (from m in message.Headers.To.ToArray()
                              select new
                              {
                                  address = m.Address,
                                  match = assessmentReplyMatch.Match(m.Address)
                              }); // if none of the destinations match our pattern, skip the message 
                if (!toList.Any(m => m.match.Success))
                {
                    curmessage++;
                    continue;
                }
                string from = message.Headers.From.Address;
                string to = toList.First().address;
                string id = toList.First().match.Groups[1].Value;
                string subject = (String.IsNullOrEmpty(message.Headers.Subject)) ? "no subject" : message.Headers.Subject;
                Response.Write("id: " + id + ";to: " + to + ";" + "from: " + from + ";" + "subject: " + subject + ";<br />");
                if (id.Length == 36) // basic guid filter. We could be doing this in the regex itself, but we want to be able to clear out broken ones here 
                {
                    dac.AnswerableEmail answerableEmail = dac.AnswerableEmail.Get(new Guid(id)); //our table that logs the outgoing message for replies that come back 
                    if (answerableEmail != null)
                    {
                        if (answerableEmail.ReviewRequestID > 0) // reply to a requested review 
                        {
                            string msgTxt = Foliotek.Components.Classes.PopEmail.BodyTextNoReply(message, answerableEmail.ReplyToName, answerableEmail.ReplyToAddress); // builds a version of the message that shows the truncated text plus a link to hover to see the whole text 
                            string fullMsg = msgTxt + " <a href='#' onmouseover='$(this).next().show();' onmouseout='$(this).next().hide();'>View Full Message</a><span class=\"hovertext\" style=\"display:none;width:600px;\">" + HttpContext.Current.Server.HtmlEncode(Foliotek.Components.Classes.PopEmail.FullBodyText(message)).Replace("\n", "<br />") + "</span>";
                            ProcessReviewRequest(answerableEmail, from, fullMsg, msgTxt); client.DeleteMessage(curmessage);
                        }
                    }
                    maxtoprocess--;
                }
                curmessage++;
            }
        }
    } // puts the reply in the appropriate place, and sends the replier back a message that it happened
    private void ProcessReviewRequest(dac.AnswerableEmail answerableEmail, string senderEmail, string fullmessage, string textmessage)
    {
        answerableEmail.ReviewRequest.PostReviewComment("Reply", fullmessage); // save the reply in the student's comments 
        answerableEmail.MarkAsAnswered(); //Send Email Confirmation to replier 
        string fromEmail = STANDARD_AUTOMATED_EMAIL_ADDRESS_HERE;
        string emailBody = "The following Request Review Comment was recorded for " + answerableEmail.ReviewRequest.User.FullNameNoHtml + " on " + answerableEmail.ReviewRequest.Element.Name + ": <br /><br /><hr />" + textmessage + "<hr /> <br />";
        Components.Classes.General.SendEmail(senderEmail, fromEmail, "Review Request Comment Received", emailBody);
    }
}

Selenium 2 Tips

In previous posts, I described our use of Selenium for functional and regression testing – and I included some tips on how to use it effectively. ? We used the Firefox plugin Selenium IDE to run our tests.

Since that time, we’ve moved on to use Selenium 2 (now, Selenium Server) – which uses a completely different architecture built on top of a merged project called WebDriver. ?Now, instead of the custom ‘selenese’ scripts – our tests are driven with C#. ?This allows for much more effective branching, looping, etc. scenarios that are sometimes necessary for robust testing.

Some of the tips for selenese tests still apply, but in addition here are some specific Selenium 2 Server pointers:

  1. If you are having trouble getting an element clicked, sometimes it helps to have the test explicitly move the mouse to the element beforehand. Use: new OpenQA.Selenium.Interaction.Actions(thewebdriver).MoveToElement(theelement).Perform();
  2. Selenium 2 will not interact with elements that are hidden or off screen. ?Because of this – each click/etc action implicitly performs a scroll-to-element action. ?Usually, this makes things easier, but occasionally it breaks. ?If you have a scrollable element with tight spaces, it might scroll it just out of range before the click, and it will silently fail. ?There currently isn’t a great way around this in the test – you can attempt to change your site to deal with it instead (by giving more room, or locking scrolling, etc).
  3. It can be a hassle to deal with nested frames. 1. Things will fail if you don’t keep the Selenium context updated. ?XPath selections in FireFox will throw exceptions, and events won’t fire. ?Make sure you do the following to always use the proper context: Driver.SwitchTo().DefaultContent().SwitchTo().Frame("fremename");
  4. When things are failing differently on different machines, there are a couple of things to try: 1. Set a consistent resolution at the beginning of the test: ((IJavaScriptExecutor)Driver).ExecuteScript("window.moveTo(0, 1); window.resizeTo(1200,1000);");
  5. Retry clicks until success. ?This tends to be necessary right after a iframe context change. ?Hacky, but this psuedocode handles some performance/timing issues: do{click;sleep;}while(testforchange){}

Flash Streaming MP4 file in Umbraco

In our old website, we had a script that would take an mp4 file and automatically respond with the proper flash header for it in addition to sending the proper content if flash requests a certain position. ?I adapted that code for Umbraco, and made it so that a request for /media/MEDIAPATH/stream.flv would automatically do this for Umbraco media. ?The strategy is very similar to my last post that does this for a /css/media/MEDIAPATH type url.

To use this, add a rewrite rule to UrlRewriting.config:

virtualUrl="^~/media/(.*)/stream.flv"  
rewriteUrlParameter="ExcludeFromClientQueryString"  
destinationUrl="~/mp4streamer?path=$1" ignoreCase="true"  

Add a new scripting file (and macro) called “MP4Streamer”

@using uComponents.Core
@using umbraco.cms.businesslogic.media
@using uComponents.Core.uQueryExtensions
@{
    byte[] _flvheader = { 0x46, 0x4c, 0x56, 0x01, 0x01, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x09 };
    try {
        int pos;
        int length;
        string filename = Server.MapPath("~/media/" + Request.QueryString["path"]);
        using (FileStream fs = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            // Check start parameter if present 
            string qs = Request.Params["start"];
            if (string.IsNullOrEmpty(qs))
            {
                pos = 0; length = Convert.ToInt32(fs.Length);
            }
            else
            {
                pos = Convert.ToInt32(qs);
                length = Convert.ToInt32(fs.Length - pos) + _flvheader.Length;
            }

            // Add HTTP header stuff: cache, content type and length 
            Response.Cache.SetCacheability(HttpCacheability.Public);
            Response.Cache.SetLastModified(DateTime.Now);
            Response.AppendHeader("Content-Type", "video/x-flv");
            Response.AppendHeader("Content-Length", length.ToString());

            // Append FLV header when sending partial file 
            if (pos > 0){
                Response.OutputStream.Write(_flvheader, 0, _flvheader.Length);
                fs.Position = pos;
            }

            // Read buffer and write stream to the response stream const
            int buffersize = 16384;
            byte[] buffer = new byte[buffersize];
            int count = fs.Read(buffer, 0, buffersize);
            while (count > 0)
            {
                if (Response.IsClientConnected)
                {
                    Response.OutputStream.Write(buffer, 0, count);
                    Response.Flush();
                    count = fs.Read(buffer, 0, buffersize);
                }
                else
                {
                    count = -1;
                }
            }
        }
    }
    catch (Exception ex) { }
}

Add a new “MP4Streamer” template that uses the macro:

<%@ Master Language="C#" MasterPageFile="~/umbraco/masterpages/default.master" AutoEventWireup="true" %>  

And, finally, add a new page called MP4Streamer?to the root of your site that uses the macro.


A couple of handy helper razor scripts for Umbraco CMS

I’ve recently started a project for a new marketing site for our projects. ?One of the biggest issues with our current marketing site is that it was developed as an application – so changes need to go through a developer and be deployed. ?This basically resulted in the site being stagnant for a couple of years. ?This is bad – there are lots of things we offer now that we didn’t 2 years ago – and there are many opportunities where a page that speaks to a particular system, market, or feature could be very good for business/SEO.

I decided to use a CMS to allow anyone in our business edit and add content. ?Since we were a web consulting firm before we built products – we’ve had a number of custom solutions over the years for managing site content. ?I didn’t want to create that maintenance pain again, so I looked for another maintained solution I could use. ?I settled on Umbraco – it’s free, open source, has a great plugin architecture using the .NET/Razor architecture we already know – so it was a perfect fit.

I wanted to share a couple of handy scripts I came up with to make Umbraco even better:

Add “/edit” redirect to all pages:

To use this, add a couple of rewrite rules to UrlRewriting.config:

Add a new scripting file (and macro) called “EditRedirect”

@using uComponents.Core
@using umbraco.presentation.nodeFactory
@using uComponents.Core.uQueryExtensions
@{
    Node n = null;
    var path = Request.QueryString["path"].Split("/".ToCharArray());
    for (int i = 0; i < path.Length; i++)
    {
        if (n == null)
        {
            n = uQuery.GetRootNode().GetChildNodes().Where(x => x.Name.ToLower() == path[i].Trim().ToLower()).First();
        }
        else
        {
            var name = path[i].Trim();
            if (i == path.Length - 1 && name.IndexOf(".") > 0)
            {
                name = name.Substring(0, name.LastIndexOf("."));
            }
            n = n.GetDescendantNodes().First(c => c.Name.ToLower() == name.ToLower());
        }
    }
    Response.Redirect("/umbraco/actions/editContent.aspx?id=" + n.Id);
}

Add a new “EditRedirect” template that uses the macro:

<%@ Master Language="C#" MasterPageFile="~/umbraco/masterpages/default.master" AutoEventWireup="true" %> <asp:Content ContentPlaceHolderID="ContentPlaceHolderDefault" runat="server"> <umbraco:Macro runat="server" Alias="EditRedirect" /> </asp:Content>  

And, finally, add a new page called EditRedirect to the root of your site that uses the macro. ? Now you can hit http://site/anydirectory/anypage/edit and it will take you to the editor screen of that page.

Simple path to access media by url and in css files

The abstraction umbraco allows for media is great – your editors can create a media item and replace the file later to their hearts content. ?Unfortunately, it’s not very simple to pull this media in scripts, templates, simple html, and especially css files. ?I wrote a macro/script to handle this too. ?Set up is pretty much like the edit redirect:

The script:

@using uComponents.Core 
@using umbraco.cms.businesslogic.media
@using uComponents.Core.uQueryExtensions
@{
    Media m = null;
    if (!(Model.Media is umbraco.MacroEngines.DynamicNull || (Model.Media is string && Model.Media == "")))
    {
        m = uQuery.GetMedia(Model.Media);
    }
    else
    {
        var path = Request.QueryString["path"].Split("/".ToCharArray());
        for (int i = 0; i < path.Length; i++)
        {
            if (m == null)
            {
                m = uQuery.GetMediaByName(path[i].Trim()).First();
            }
            else
            {
                var name = path[i].Trim();
                if (i == path.Length - 1 && name.IndexOf(".") > 0)
                {
                    name = name.Substring(0, name.LastIndexOf("."));
                }
                m = m.GetChildMedia().First(c => c.Text == name);
            }
        }
    }
    Response.Clear();
    Response.ContentType = GetContentType(m.GetProperty<string>("umbracoExtension"));
    Response.TransmitFile(m.GetProperty<string>("umbracoFile"));
}
@functions{
    protected string GetContentType(string ext)
{
    var contentTypes = new Dictionary<string, string>
    {
        { "3dm", "x-world/x-3dmf" },
        { "3dmf", "x-world/x-3dmf" },
        { "a", "application/octet-stream" },
        { "aab", "application/x-authorware-bin" },
        { "aam", "application/x-authorware-map" },
        { "aas", "application/x-authorware-seg" },
        { "abc", "text/vnd.abc" },
        { "acgi", "text/html" },
        { "afl", "video/animaflex" },
        { "ai", "application/postscript" },
        { "aif", "audio/aiff" },
        { "aifc", "audio/aiff" },
        { "aiff", "audio/aiff" },
        { "aim", "application/x-aim" },
        { "aip", "text/x-audiosoft-intra" },
        { "ani", "application/x-navi-animation" },
        { "aos", "application/x-nokia-9000-communicator-add-on-software" },
        { "aps", "application/mime" },
        { "arc", "application/octet-stream" },
        { "arj", "application/arj" },
        { "art", "image/x-jg" },
        { "asf", "video/x-ms-asf" },
        { "asm", "text/x-asm" },
        { "asp", "text/asp" },
        { "asx", "application/x-mplayer2" },
        { "au", "audio/basic" },
        { "avi", "video/avi" },
        { "avs", "video/avs-video" },
        { "bcpio", "application/x-bcpio" },
        { "bin", "application/octet-stream" },
        { "bm", "image/bmp" },
        { "bmp", "image/bmp" },
        { "boo", "application/book" },
        { "book", "application/book" },
        { "boz", "application/x-bzip2" },
        { "bsh", "application/x-bsh" },
        { "bz", "application/x-bzip" },
        { "bz2", "application/x-bzip2" },
        { "c", "text/plain" },
        { "c++", "text/plain" },
        { "cat", "application/vnd.ms-pki.seccat" },
        { "cc", "text/plain" },
        { "ccad", "application/clariscad" },
        { "cco", "application/x-cocoa" },
        { "cdf", "application/cdf" },
        { "cer", "application/pkix-cert" },
        { "cha", "application/x-chat" },
        { "chat", "application/x-chat" },
        { "class", "application/java" },
        { "com", "application/octet-stream" },
        { "conf", "text/plain" },
        { "cpio", "application/x-cpio" },
        { "cpp", "text/x-c" },
        { "cpt", "application/x-cpt" },
        { "crl", "application/pkcs-crl" },
        { "css", "text/css" },
        { "def", "text/plain" },
        { "der", "application/x-x509-ca-cert" },
        { "dif", "video/x-dv" },
        { "dir", "application/x-director" },
        { "dl", "video/dl" },
        { "doc", "application/msword" },
        { "dot", "application/msword" },
        { "dp", "application/commonground" },
        { "drw", "application/drafting" },
        { "dump", "application/octet-stream" },
        { "dv", "video/x-dv" },
        { "dvi", "application/x-dvi" },
        { "dwf", "drawing/x-dwf (old)" },
        { "dwg", "application/acad" },
        { "dxf", "application/dxf" },
        { "eps", "application/postscript" },
        { "es", "application/x-esrehber" },
        { "etx", "text/x-setext" },
        { "evy", "application/envoy" },
        { "exe", "application/octet-stream" },
        { "f", "text/plain" },
        { "f90", "text/x-fortran" },
        { "fdf", "application/vnd.fdf" },
        { "fif", "image/fif" },
        { "fli", "video/fli" },
        { "for", "text/x-fortran" },
        { "fpx", "image/vnd.fpx" },
        { "g", "text/plain" },
        { "g3", "image/g3fax" },
        { "gif", "image/gif" },
        { "gl", "video/gl" },
        { "gsd", "audio/x-gsm" },
        { "gtar", "application/x-gtar" },
        { "gz", "application/x-compressed" },
        { "h", "text/plain" },
        { "help", "application/x-helpfile" },
        { "hgl", "application/vnd.hp-hpgl" },
        { "hh", "text/plain" },
        { "hlp", "application/x-winhelp" },
        { "htc", "text/x-component" },
        { "htm", "text/html" },
        { "html", "text/html" },
        { "htmls", "text/html" },
        { "htt", "text/webviewhtml" },
        { "htx", "text/html" },
        { "ice", "x-conference/x-cooltalk" },
        { "ico", "image/x-icon" },
        { "idc", "text/plain" },
        { "ief", "image/ief" },
        { "iefs", "image/ief" },
        { "iges", "application/iges" },
        { "igs", "application/iges" },
        { "ima", "application/x-ima" },
        { "imap", "application/x-httpd-imap" },
        { "inf", "application/inf" },
        { "ins", "application/x-internett-signup" },
        { "ip", "application/x-ip2" },
        { "isu", "video/x-isvideo" },
        { "it", "audio/it" },
        { "iv", "application/x-inventor" },
        { "ivr", "i-world/i-vrml" },
        { "ivy", "application/x-livescreen" },
        { "jam", "audio/x-jam" },
        { "jav", "text/plain" },
        { "java", "text/plain" },
        { "jcm", "application/x-java-commerce" },
        { "jfif", "image/jpeg" },
        { "jfif-tbnl", "image/jpeg" },
        { "jpe", "image/jpeg" },
        { "jpeg", "image/jpeg" },
        { "jpg", "image/jpeg" },
        { "jps", "image/x-jps" },
        { "js", "application/x-javascript" },
        { "jut", "image/jutvision" },
        { "kar", "audio/midi" },
        { "ksh", "application/x-ksh" },
        { "la", "audio/nspaudio" },
        { "lam", "audio/x-liveaudio" },
        { "latex", "application/x-latex" },
        { "lha", "application/lha" },
        { "lhx", "application/octet-stream" },
        { "list", "text/plain" },
        { "lma", "audio/nspaudio" },
        { "log", "text/plain" },
        { "lsp", "application/x-lisp" },
        { "lst", "text/plain" },
        { "lsx", "text/x-la-asf" },
        { "ltx", "application/x-latex" },
        { "lzh", "application/octet-stream" },
        { "lzx", "application/lzx" },
        { "m", "text/plain" },
        { "m1v", "video/mpeg" },
        { "m2a", "audio/mpeg" },
        { "m2v", "video/mpeg" },
        { "m3u", "audio/x-mpequrl" },
        { "man", "application/x-troff-man" },
        { "map", "application/x-navimap" },
        { "mar", "text/plain" },
        { "mbd", "application/mbedlet" },
        { "mc$", "application/x-magic-cap-package-1.0" },
        { "mcd", "application/mcad" },
        { "mcf", "image/vasa" },
        { "mcp", "application/netmc" },
        { "me", "application/x-troff-me" },
        { "mht", "message/rfc822" },
        { "mhtml", "message/rfc822" },
        { "mid", "audio/midi" },
        { "midi", "audio/midi" },
        { "mif", "application/x-frame" },
        { "mime", "message/rfc822" },
        { "mjf", "audio/x-vnd.audioexplosion.mjuicemediafile" },
        { "mjpg", "video/x-motion-jpeg" },
        { "mm", "application/base64" },
        { "mme", "application/base64" },
        { "mod", "audio/mod" },
        { "moov", "video/quicktime" },
        { "mov", "video/quicktime" },
        { "movie", "video/x-sgi-movie" },
        { "mp2", "audio/mpeg" },
        { "mp3", "audio/mpeg3" },
        { "mpa", "audio/mpeg" },
        { "mpc", "application/x-project" },
        { "mpe", "video/mpeg" },
        { "mpeg", "video/mpeg" },
        { "mpg", "video/mpeg" },
        { "mpga", "audio/mpeg" },
        { "mpp", "application/vnd.ms-project" },
        { "mpt", "application/x-project" },
        { "mpv", "application/x-project" },
        { "mpx", "application/x-project" },
        { "mrc", "application/marc" },
        { "ms", "application/x-troff-ms" },
        { "mv", "video/x-sgi-movie" },
        { "my", "audio/make" },
        { "mzz", "application/x-vnd.audioexplosion.mzz" },
        { "nap", "image/naplps" },
        { "naplps", "image/naplps" },
        { "nc", "application/x-netcdf" },
        { "ncm", "application/vnd.nokia.configuration-message" },
        { "nif", "image/x-niff" },
        { "niff", "image/x-niff" },
        { "nix", "application/x-mix-transfer" },
        { "nsc", "application/x-conference" },
        { "nvd", "application/x-navidoc" },
        { "o", "application/octet-stream" },
        { "oda", "application/oda" },
        { "omc", "application/x-omc" },
        { "omcd", "application/x-omcdatamaker" },
        { "omcr", "application/x-omcregerator" },
        { "p", "text/x-pascal" },
        { "p10", "application/pkcs10" },
        { "p12", "application/pkcs-12" },
        { "p7a", "application/x-pkcs7-signature" },
        { "p7c", "application/pkcs7-mime" },
        { "pas", "text/pascal" },
        { "pbm", "image/x-portable-bitmap" },
        { "pcl", "application/vnd.hp-pcl" },
        { "pct", "image/x-pict" },
        { "pcx", "image/x-pcx" },
        { "pdf", "application/pdf" },
        { "pfunk", "audio/make" },
        { "pgm", "image/x-portable-graymap" },
        { "pic", "image/pict" },
        { "pict", "image/pict" },
        { "pkg", "application/x-newton-compatible-pkg" },
        { "pko", "application/vnd.ms-pki.pko" },
        { "pl", "text/plain" },
        { "plx", "application/x-pixclscript" },
        { "pm", "image/x-xpixmap" },
        { "png", "image/png" },
        { "pnm", "application/x-portable-anymap" },
        { "pot", "application/mspowerpoint" },
        { "pov", "model/x-pov" },
        { "ppa", "application/vnd.ms-powerpoint" },
        { "ppm", "image/x-portable-pixmap" },
        { "pps", "application/mspowerpoint" },
        { "ppt", "application/mspowerpoint" },
        { "ppz", "application/mspowerpoint" },
        { "pre", "application/x-freelance" },
        { "prt", "application/pro_eng" },
        { "ps", "application/postscript" },
        { "psd", "application/octet-stream" },
        { "pvu", "paleovu/x-pv" },
        { "pwz", "application/vnd.ms-powerpoint" },
        { "py", "text/x-script.phyton" },
        { "pyc", "applicaiton/x-bytecode.python" },
        { "qcp", "audio/vnd.qcelp" },
        { "qd3", "x-world/x-3dmf" },
        { "qd3d", "x-world/x-3dmf" },
        { "qif", "image/x-quicktime" },
        { "qt", "video/quicktime" },
        { "qtc", "video/x-qtc" },
        { "qti", "image/x-quicktime" },
        { "qtif", "image/x-quicktime" },
        { "ra", "audio/x-pn-realaudio" },
        { "ram", "audio/x-pn-realaudio" },
        { "ras", "application/x-cmu-raster" },
        { "rast", "image/cmu-raster" },
        { "rexx", "text/x-script.rexx" },
        { "rf", "image/vnd.rn-realflash" },
        { "rgb", "image/x-rgb" },
        { "rm", "application/vnd.rn-realmedia" },
        { "rmi", "audio/mid" },
        { "rmm", "audio/x-pn-realaudio" },
        { "rmp", "audio/x-pn-realaudio" },
        { "rng", "application/ringing-tones" },
        { "rnx", "application/vnd.rn-realplayer" },
        { "roff", "application/x-troff" },
        { "rp", "image/vnd.rn-realpix" },
        { "rpm", "audio/x-pn-realaudio-plugin" },
        { "rt", "text/richtext" },
        { "rtf", "text/richtext" },
        { "rtx", "application/rtf" },
        { "rv", "video/vnd.rn-realvideo" },
        { "s", "text/x-asm" },
        { "s3m", "audio/s3m" },
        { "saveme", "application/octet-stream" },
        { "sbk", "application/x-tbook" },
        { "scm", "application/x-lotusscreencam" },
        { "sdml", "text/plain" },
        { "sdp", "application/sdp" },
        { "sdr", "application/sounder" },
        { "sea", "application/sea" },
        { "set", "application/set" },
        { "sgm", "text/sgml" },
        { "sgml", "text/sgml" },
        { "sh", "application/x-bsh" },
        { "shtml", "text/html" },
        { "sid", "audio/x-psid" },
        { "sit", "application/x-sit" },
        { "skd", "application/x-koan" },
        { "skm", "application/x-koan" },
        { "skp", "application/x-koan" },
        { "skt", "application/x-koan" },
        { "sl", "application/x-seelogo" },
        { "smi", "application/smil" },
        { "smil", "application/smil" },
        { "snd", "audio/basic" },
        { "sol", "application/solids" },
        { "spc", "application/x-pkcs7-certificates" },
        { "spl", "application/futuresplash" },
        { "spr", "application/x-sprite" },
        { "sprite", "application/x-sprite" },
        { "src", "application/x-wais-source" },
        { "ssi", "text/x-server-parsed-html" },
        { "ssm", "application/streamingmedia" },
        { "sst", "application/vnd.ms-pki.certstore" },
        { "step", "application/step" },
        { "stl", "application/sla" },
        { "stp", "application/step" },
        { "sv4cpio", "application/x-sv4cpio" },
        { "sv4crc", "application/x-sv4crc" },
        { "svf", "image/vnd.dwg" },
        { "svr", "application/x-world" },
        { "swf", "application/x-shockwave-flash" },
        { "t", "application/x-troff" },
        { "talk", "text/x-speech" },
        { "tar", "application/x-tar" },
        { "tbk", "application/toolbook" },
        { "tcl", "application/x-tcl" },
        { "tcsh", "text/x-script.tcsh" },
        { "tex", "application/x-tex" },
        { "texi", "application/x-texinfo" },
        { "texinfo", "application/x-texinfo" },
        { "text", "text/plain" },
        { "tgz", "application/x-compressed" },
        { "tif", "image/tiff" },
        { "tr", "application/x-troff" },
        { "tsi", "audio/tsp-audio" },
        { "tsp", "audio/tsplayer" },
        { "tsv", "text/tab-separated-values" },
        { "turbot", "image/florian" },
        { "txt", "text/plain" },
        { "uil", "text/x-uil" },
        { "uni", "text/uri-list" },
        { "unis", "text/uri-list" },
        { "unv", "application/i-deas" },
        { "uri", "text/uri-list" },
        { "uris", "text/uri-list" },
        { "ustar", "application/x-ustar" },
        { "uu", "application/octet-stream" },
        { "vcd", "application/x-cdlink" },
        { "vcs", "text/x-vcalendar" },
        { "vda", "application/vda" },
        { "vdo", "video/vdo" },
        { "vew", "application/groupwise" },
        { "viv", "video/vivo" },
        { "vivo", "video/vivo" },
        { "vmd", "application/vocaltec-media-desc" },
        { "vmf", "application/vocaltec-media-file" },
        { "voc", "audio/voc" },
        { "vos", "video/vosaic" },
        { "vox", "audio/voxware" },
        { "vqe", "audio/x-twinvq-plugin" },
        { "vqf", "audio/x-twinvq" },
        { "vql", "audio/x-twinvq-plugin" },
        { "vrml", "application/x-vrml" },
        { "vrt", "x-world/x-vrt" },
        { "vsd", "application/x-visio" },
        { "vst", "application/x-visio" },
        { "vsw", "application/x-visio" },
        { "w60", "application/wordperfect6.0" },
        { "w61", "application/wordperfect6.1" },
        { "w6w", "application/msword" },
        { "wav", "audio/wav" },
        { "wb1", "application/x-qpro" },
        { "wbmp", "image/vnd.wap.wbmp" },
        { "web", "application/vnd.xara" },
        { "wiz", "application/msword" },
        { "wk1", "application/x-123" },
        { "wmf", "windows/metafile" },
        { "wml", "text/vnd.wap.wml" },
        { "wmlc", "application/vnd.wap.wmlc" },
        { "wmls", "text/vnd.wap.wmlscript" },
        { "wmlsc", "application/vnd.wap.wmlscriptc" },
        { "word", "application/msword" },
        { "wp", "application/wordperfect" },
        { "wp5", "application/wordperfect" },
        { "wp6", "application/wordperfect" },
        { "wpd", "application/wordperfect" },
        { "wq1", "application/x-lotus" },
        { "wri", "application/mswrite" },
        { "wrl", "application/x-world" },
        { "wrz", "model/vrml" },
        { "wsc", "text/scriplet" },
        { "wsrc", "application/x-wais-source" },
        { "wtk", "application/x-wintalk" },
        { "xbm", "image/x-xbitmap" },
        { "xdr", "video/x-amt-demorun" },
        { "xgz", "xgl/drawing" },
        { "xif", "image/vnd.xiff" },
        { "xl", "application/excel" },
        { "xla", "application/excel" },
        { "xlb", "application/excel" },
        { "xlc", "application/excel" },
        { "xld", "application/excel" },
        { "xlk", "application/excel" },
        { "xll", "application/excel" },
        { "xlm", "application/excel" },
        { "xls", "application/excel" },
        { "xlt", "application/excel" },
        { "xlv", "application/excel" },
        { "xlw", "application/excel" },
        { "xm", "audio/xm" },
        { "xml", "text/xml" },
        { "xmz", "xgl/movie" },
        { "xpix", "application/x-vnd.ls-xpix" },
        { "xpm", "image/x-xpixmap" },
        { "x-png", "image/png" },
        { "xsr", "video/x-amt-showrun" },
        { "xwd", "image/x-xwd" },
        { "xyz", "chemical/x-pdb" },
        { "z", "application/x-compress" },
        { "zip", "application/x-compressed" },
        { "zoo", "application/octet-stream" },
        { "zsh", "text/x-script.zsh" }
    };
    if (contentTypes[ext] == null)
    {
        return "application/octet-stream";
    }
    return contentTypes[ext];
}

The template:

<%@ Master Language="C#" MasterPageFile="~/umbraco/masterpages/default.master" AutoEventWireup="true" %> <asp:Content ContentPlaceHolderID="ContentPlaceHolderDefault" runat="server"> <umbraco:Macro runat="server" MediaID="[#media]" Alias="getMedia" /> </asp:Content>  

The redirect rule:

 <add name="cssmediarewrite" virtualUrl="^~/css/media/(.*)" rewriteUrlParameter="ExcludeFromClientQueryString" destinationUrl="~/cssmedia?path=$1" ignoreCase="true" />

Finally, add a page called CssMedia that uses the template. Now, you can reference /css/media/mediapath/medianame.fileextension to get the item. This also means that your css files can use media/mediapath/medianame.fileextension to reference Umbraco media.