Extending NerdDinner: Adding Geolocated Flair

NerdDinner is a website with the audacious goal of “Organizing the world’s nerds and helping them eat in packs.” Because nerds aren’t likely to socialize with others unless a website tells them to do it.

Scott Hanselman showed off a lot of the cool features we’ve added to NerdDinner lately during his popular talk at MIX10, Beyond File | New Company: From Cheesy Sample to Social Platform. Did you miss it? Go ahead and watch it, I’ll wait.

Get Microsoft Silverlight

One of the features we wanted to add was flair. You know about flair, right? It’s a way to let folks who like your site show it off in their own site. For example, here’s my StackOverflow flair:

Great! So how could we add some of this flair stuff to NerdDinner?

What do we want to show?

If we’re going to encourage our users to give up a bit of their beautiful website to show off a bit of ours, we need to think about what they’ll want to show. For instance, my StackOverflow flair is all about me, not StackOverflow.

So how will this apply to NerdDinner?

Since NerdDinner is all about organizing local dinners, in order for the flair to be useful it needs to make sense for the person viewing the web page. If someone visits from Egypt visits my blog, they should see information about NerdDinners in Egypt. That’s geolocation – localizing site content based on where the browser’s sitting, and it makes sense for flair as well as entire websites.

So we’ll set up a simple little callout that prompts them to host a dinner in their area:

Nerd Dinner - Flair

Hopefully our flair works and there is a dinner near your viewers, so they’ll see another view which lists upcoming dinners near them:

NerdDinner - Flair - Dinners Near You

The Geolocation Part

Generally website geolocation is done by mapping the requestor’s IP address to a geographic area. It’s not an exact science, but I’ve always found it to be pretty accurate. There are (at least) three ways to handle it:

  • You pay somebody like MaxMind for a database (with regular updates) that sits on your server, and you use their API to do lookups. I used this on a pretty big project a few years ago and it worked well.
  • You use HTML 5 Geolocation API or Google Gears or some other browser based solution. I think those are cool (I use Google Gears a lot), but they’re both in flux right now and I don’t think either has a wide enough of an install base yet to rely on them. You might want to, but I’ve heard you do all kinds of crazy stuff, and sometimes it gets you in trouble. I don’t mean talk out of line, but we all laugh behind your back a bit. But, hey, it’s up to you. It’s your flair or whatever.
  • There are some free webservices out there that will take an IP address and give you location information. Easy, and works for everyone. That’s what we’re doing.

I looked at a few different services and settled on IPInfoDB. It’s free, has a great API, and even returns JSON, which is handy for Javascript use.

The IP query is pretty simple. We hit a URL like this: http://ipinfodb.com/ip_query.php?ip=74.125.45.100&timezone=false

… and we get an XML response back like this…

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Ip>74.125.45.100</Ip>
  <Status>OK</Status>
  <CountryCode>US</CountryCode>
  <CountryName>United States</CountryName>
  <RegionCode>06</RegionCode>
  <RegionName>California</RegionName>
  <City>Mountain View</City>
  <ZipPostalCode>94043</ZipPostalCode>
  <Latitude>37.4192</Latitude>
  <Longitude>-122.057</Longitude>
</Response>

So we’ll build some data transfer classes to hold the location information, like this:

public class LocationInfo
{
    public string Country { get; set; }
    public string RegionName { get; set; }
    public string City { get; set; }
    public string ZipPostalCode { get; set; }
    public LatLong Position { get; set; }
}

public class LatLong
{
    public float Lat { get; set; }
    public float Long { get; set; }
}

And now hitting the service is pretty simple:

public static LocationInfo HostIpToPlaceName(string ip)
{
    string url = "http://ipinfodb.com/ip_query.php?ip={0}&timezone=false";
    url = String.Format(url, ip);

    var result = XDocument.Load(url);

    var location = (from x in result.Descendants("Response")
                    select new LocationInfo
                    {
                        City = (string)x.Element("City"),
                        RegionName = (string)x.Element("RegionName"),
                        Country = (string)x.Element("CountryName"),
                        ZipPostalCode = (string)x.Element("CountryName"),
                        Position = new LatLong
                        {
                            Lat = (float)x.Element("Latitude"),
                            Long = (float)x.Element("Longitude")
                        }
                    }).First();

    return location;
}

Getting The User’s IP

Okay, but first we need the end user’s IP, and you’d think it would be as simple as reading the value from HttpContext:

HttpContext.Current.Request.UserHostAddress

But you’d be wrong. Sorry. UserHostAddress just wraps HttpContext.Current.Request.ServerVariables["REMOTE_ADDR"], but that doesn’t get you the IP for users behind a proxy. That’s in another header, “HTTP_X_FORWARDED_FOR". So you can either hit a wrapper and then check a header, or just check two headers. I went for uniformity:

string SourceIP = string.IsNullOrEmpty(Request.ServerVariables["HTTP_X_FORWARDED_FOR"]) ?
    Request.ServerVariables["REMOTE_ADDR"] :
    Request.ServerVariables["HTTP_X_FORWARDED_FOR"];

We’re almost set to wrap this up, but first let’s talk about our views. Yes, views, because we’ll have two.

Selecting the View

We wanted to make it easy for people to include the flair in their sites, so we looked around at how other people were doing this. The StackOverflow folks have a pretty good flair system, which allows you to include the flair in your site as either an IFRAME reference or a Javascript include. We’ll do both.

We have a ServicesController to handle use of the site information outside of NerdDinner.com, so this fits in pretty well there. We’ll be displaying the same information for both HTML and Javascript flair, so we can use one Flair controller action which will return a different view depending on the requested format.

Here’s our general flow for our controller action:

  1. Get the user’s IP
  2. Translate it to a location
  3. Grab the top three upcoming dinners that are near that location
  4. Select the view based on the format (defaulted to “html”)
  5. Return a FlairViewModel which contains the list of dinners and the location information
public ActionResult Flair(string format = "html")
{
    string SourceIP = string.IsNullOrEmpty(
        Request.ServerVariables["HTTP_X_FORWARDED_FOR"]) ?
        Request.ServerVariables["REMOTE_ADDR"] :
        Request.ServerVariables["HTTP_X_FORWARDED_FOR"];

    var location = GeolocationService.HostIpToPlaceName(SourceIP);
    var dinners = dinnerRepository.
        FindByLocation(location.Position.Lat, location.Position.Long).
        OrderByDescending(p => p.EventDate).Take(3);

    // Select the view we'll return. 
    // Using a switch because we'll add in JSON and other formats later.
    string view;
    switch (format.ToLower())
    {
        case "javascript":
            view = "JavascriptFlair";
            break;
        default:
            view = "Flair";
            break;
    }

    return View(
        view,
        new FlairViewModel 
        {
            Dinners = dinners.ToList(),
            LocationName = string.IsNullOrEmpty(location.City) ? "you" :  
                String.Format("{0}, {1}", location.City, location.RegionName)
        }
    );
}

Note: I’m not in love with the logic here, but it seems like overkill to extract the switch statement away when we’ll probably just have two or three views. What do you think?

The HTML View

The HTML version of the view is pretty simple – the only thing of any real interest here is the use of an extension method to truncate strings that are would cause the titles to wrap.

public static string Truncate(this string s, int maxLength)
{
    if (string.IsNullOrEmpty(s) || maxLength <= 0)
        return string.Empty;
    else if (s.Length > maxLength)
        return s.Substring(0, maxLength) + "...";
    else
        return s;
}

 

So here’s how the HTML view ends up looking:

<%@ Page Title="" Language="C#" Inherits="System.Web.Mvc.ViewPage<FlairViewModel>" %>
<%@ Import Namespace="NerdDinner.Helpers" %>
<%@ Import Namespace="NerdDinner.Models" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
    <head>
        <title>Nerd Dinner</title>
        <link href="/Content/Flair.css" rel="stylesheet" type="text/css" />
    </head>
    <body>
        <div id="nd-wrapper">
            <h2 id="nd-header">NerdDinner.com</h2>
            <div id="nd-outer">
                <% if (Model.Dinners.Count == 0) { %>
                <div id="nd-bummer">
                    Looks like there's no Nerd Dinners near
                    <%:Model.LocationName %>
                    in the near future. Why not <a target="_blank" href="http://www.nerddinner.com/Dinners/Create">host one</a>?</div>
                <% } else { %>
                <h3>
                    Dinners Near You</h3>
                <ul>
                    <% foreach (var item in Model.Dinners) { %>
                    <li>
                        <%: Html.ActionLink(String.Format("{0} with {1} on {2}", item.Title.Truncate(20), item.HostedBy, item.EventDate.ToShortDateString()), "Details", "Dinners", new { id = item.DinnerID }, new { target = "_blank" })%></li>
                    <% } %>
                </ul>
                <% } %>
                <div id="nd-footer">
                    More dinners and fun at <a target="_blank" href="http://nrddnr.com">http://nrddnr.com</a></div>
            </div>
        </div>
    </body>
</html>

You’d include this in a page using an IFRAME, like this:

<IFRAME height=230 marginHeight=0 src="http://nerddinner.com/services/flair" frameBorder=0 width=160 marginWidth=0 scrolling=no></IFRAME>

The Javascript view

The Javascript flair is written so you can include it in a webpage with a simple script include, like this:

<script type="text/javascript" src="http://nerddinner.com/services/flair?format=javascript"></script>

The goal of this view is very similar to the HTML embed view, with a few exceptions:

  • We’re creating a script element and adding it to the head of the document, which will then document.write out the content. Note that you have to consider if your users will actually have a <head> element in their documents, but for website flair use cases I think that’s a safe bet.
  • Since the content is being added to the existing page rather than shown in an IFRAME, all links need to be absolute. That means we can’t use Html.ActionLink, since it generates relative routes.
  • We need to escape everything since it’s being written out as strings.
  • We need to set the content type to application/x-javascript. The easiest way to do that is to use the <%@ Page ContentType%> directive.
<%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage<NerdDinner.Models.FlairViewModel>" ContentType="application/x-javascript" %>
<%@ Import Namespace="NerdDinner.Helpers" %>
<%@ Import Namespace="NerdDinner.Models" %>
document.write('<script>var link = document.createElement(\"link\");link.href = \"http://nerddinner.com/content/Flair.css\";link.rel = \"stylesheet\";link.type = \"text/css\";var head = document.getElementsByTagName(\"head\")[0];head.appendChild(link);</script>');
document.write('<div id=\"nd-wrapper\"><h2 id=\"nd-header\">NerdDinner.com</h2><div id=\"nd-outer\">');
<% if (Model.Dinners.Count == 0) { %>
  document.write('<div id=\"nd-bummer\">Looks like there\'s no Nerd Dinners near <%:Model.LocationName %> in the near future. Why not <a target=\"_blank\" href=\"http://www.nerddinner.com/Dinners/Create\">host one</a>?</div>');
<% } else { %>
document.write('<h3>  Dinners Near You</h3><ul>');
  <% foreach (var item in Model.Dinners) { %>
document.write('<li><a target=\"_blank\" href=\"http://nrddnr.com/<%: item.DinnerID %>\"><%: item.Title.Truncate(20) %> with <%: item.HostedBy %> on <%: item.EventDate.ToShortDateString() %></a></li>');
  <% } %>
document.write('</ul>');
<% } %>
document.write('<div id=\"nd-footer\">  More dinners and fun at <a target=\"_blank\" href=\"http://nrddnr.com\">http://nrddnr.com</a></div></div></div>'); 

Getting IP’s for Testing

There are a variety of online services that will translate a location to an IP, which were handy for testing these out. I found http://www.itouchmap.com/latlong.html to be most useful, but I’m open to suggestions if you know of something better.

Next steps

I think the next step here is to minimize load – you know, in case people start actually using this flair. There are two places to think about – the NerdDinner.com servers, and the services we’re using for Geolocation.

I usually think about caching as a first attack on server load, but that’s less helpful here since every user will have a different IP. Instead, I’d look at taking advantage of Asynchronous Controller Actions, a cool new feature in ASP.NET MVC 2. Async Actions let you call a potentially long-running webservice without tying up a thread on the server while waiting for the response. There’s some good info on that in the MSDN documentation, and Dino Esposito wrote a great article on Asynchronous ASP.NET Pages in the April 2010 issue of MSDN Magazine.

But let’s think of the children, shall we? What about ipinfodb.com? Well, they don’t have specific daily limits, but they do throttle you if you put a lot of traffic on them. From their FAQ:

We do not have a specific daily limit but queries that are at a rate faster than 2 per second will be put in "queue". If you stay below 2 queries/second everything will be normal. If you go over the limit, you will still get an answer for all queries but they will be slowed down to about 1 per second. This should not affect most users but for high volume websites, you can either use our IP database on your server or we can whitelist your IP for 5$/month (simply use the donate form and leave a comment with your server IP). Good programming practices such as not querying our API for all page views (you can store the data in a cookie or a database) will also help not reaching the limit.

So the first step there is to save the geolocalization information in a time-limited cookie, which will allow us to look up the local dinners immediately without having to hit the geolocation service.

No Comments