Serving an iPhone website with nginx
I’ve been thinking for a while about the best way to automatically take iPhone and iPod touch users to an iPhone-enhanced version of a website when they first access it rather than requiring the users to know the iPhone-specific URL beforehand. This was the main requirement, but there were a few others. These are the problems I needed to solve:
- When accessing
mysite.comorwww.mysite.com, iPhone and iPod touch users should see an iPhone-enhanced version of the site. - These users should have the option of viewing the full site at its main URL,
www.mysite.com. - After deciding to view the full site on an iPhone or iPod touch, it should be easy for the user to get back to the iPhone-enhanced site.
- The iPhone-enhanced site should share a domain with a normal mobile website, such as
m.mysite.com. - iPhone-specific pages should be accessible without a special prefix in the path. For instance, requesting
m.mysite.com/abouton an iPhone should return the iPhone-enhanced “about us” page, and accessing the same URL on a different mobile device should return the standard mobile version of the same page. - For testing purposes, both the standard mobile site and the iPhone-enhanced pages of the mobile site should be accessible from a desktop browser. No special headers, request parameters, or cookies should be required.
- Both the standard mobile pages and the iPhone-enhanced pages should be cacheable. The
/aboutpage will need to be cached twice — once in its standard mobile form and once in its iPhone form. - Users of desktop browsers should be treated normally.
Most of the requirements above are for the sake of simplicity on the part of the user. Remembering the domain name of a site is trouble enough for some users, let alone being expected to know and remember (1) that the website they want to visit has a mobile version, (2) how to get to it, and (3) if it’s m.mysite.com or mobile.mysite.com or mysite.mobi… We should be happy that users lands on our site in the first place, and once that’s happened, we should make it as easy as possible for them to find what they’re looking for and to enjoy the experience along the way.
Now that you understand my requirements (and hopefully the rationale behind each) I’ll explain how I solved this problem using nginx. I’ll start out with the important pieces and will put it all together at the end. I’ve also broken the explanation into two sections, the first describing the config for the main website, and the second describing the config for the mobile website.
Main website
First, we need to detect when a request comes in from an iPhone or iPod touch. This is done by matching the user agent against a simple regular expression, (iPhone|iPod). If it’s a match, the $iphone_request variable is set to '1' (just a value to mean 'true').
if ($http_user_agent ~* '(iPhone|iPod)') { set $iphone_request '1'; }
That was easy enough. Now that we have the $iphone_request variable, we can use that later on to check whether a request is for an iPhone or iPod touch without having to match against the user agent string again.
Next, we need to accommodate iPhone and iPod touch users who have already been sent automatically to the iPhone-enhanced mobile site but have requested to come back to view the full site. To accomplish this without doing anything special with the URL, I decided to use a cookie. I’ll explain how I set and clear the cookie later. For now it’s just important to know that if there is a cookie with the name iphone_mode and it has a value of full, then the user should not be redirected to the iPhone-enhanced site. This check simply clears the $iphone_request variable if the cookie is set to full. Here’s how to perform this check:
if ($http_cookie ~ 'iphone_mode=full') { set $iphone_request ''; }
Finally, we check the value of $iphone_request, and if it is 1 (which will be the case if the request came in from an iPhone or iPod touch and the iphone_mode cookie does not exist or is not set to full), then we’ll redirect the user to the mobile site using the URI that was requested.
if ($iphone_request = '1') { rewrite ^.+ http://m.mysite.com$uri; }
If you want to send the user to the home page of the mobile site regardless of the specific page that was requested, simply drop $uri from the end of the mobile site URL.
Mobile/iPhone website
As with the main website, we need to detect when a request comes in from an iPhone or iPod touch. This is done just like above except that here we set two variables instead of just one. In addition to setting the $iphone_request variable, $iphone_path_prefix is set to /iphone (we’ll see why next). Here’s the modified config for the mobile site:
if ($http_user_agent ~* '(iPhone|iPod)') { set $iphone_request '1'; set $iphone_path_prefix '/iphone'; }
So why the $iphone_path_prefix… what’s that for? This is how I make it possible to have a standard mobile page and an iPhone-enhanced page that appear to the user to be at the same URL. Later, depending on certain conditions, I’ll make use of this variable to rewrite the requested URL. This will have the effect that when requesting m.mysite.com/about from an iPhone or iPod touch, the page that actually gets served is /iphone/about. When requesting the same URL from any other browser or device, /about gets served.
Now, what if the user for some reason requests m.mysite.com/iphone/about directly? Using only the config shown so far, nginx would serve /iphone/iphone/about. To counter that, I needed to clear the $iphone_path_prefix variable if the path prefix was already included in the request URI. Here’s the config that accomplishes this:
if ($uri ~ ^/iphone.*$) { set $iphone_path_prefix ''; }
Similarly, I wanted to be able to keep my images, stylesheets, and JavaScript files in their normal locations. By this I mean that I wanted to place image files in /images rather than in /iphone/images. The following config takes care of this:
if ($uri ~ '(images|stylesheets|javascripts|\.css|\.js|\.ico|\.gif|\.jpg|\.png)') { set $iphone_path_prefix ''; }
If you want to keep your iPhone images separate from the standard mobile site images, simply exclude this part of the config, but be mindful of where you place your apple-touch-icon.png or apple-touch-icon-precomposed.png image files based on your decision.
Finally, we’ll make use of our two variables. First, we’ll check to see if this is in fact an iPhone or iPod touch request, and if it is, we’ll rewrite the request URI using the path prefix we set (which may at this point be empty again).
if ($iphone_request = '1') { rewrite (.*) $iphone_path_prefix$1; }
Moving between the main website and mobile website
Using our main website config above, iPhone and iPod touch users requesting www.mysite.com will be redirected to m.mysite.com. This provides these users with a superior experience by avoiding zooming, horizontal scrolling, unnecessary asset downloads, and many other things. However, it’s time consuming enough to manage the content for a website without the added responsibility of maintaining another version just for iPhone users. Of course you could simply render your website with the iPhone in mind using CSS media queries and other magic, but in a lot of cases, iPhone users will be coming to your site for a different experience altogether.
For instance, if you manage a restaurant website, you might have a Flash intro, news releases, a history of the restaurant’s owning family, etc. All of this content might be great, but most or all users who come to your site on an iPhone won’t care about any of that. Instead, they only need a few pieces of information, such as your location, hours of operation, phone number, and a menu. In such a case, it would make sense to have a separate, slimmed-down version of the website for these users. And for the few who want to know more, it’s a good idea to provide a way for these users a way to view the full website.
We’ve already handled this ability in our main site’s nginx config above, but how is it handled on the client? How is the iphone_mode cookie set and cleared? For that, we use some JavaScript code that handles cookies. I modified the code slightly to allow the domain and path to be specified when setting the value of a cookie. I needed to be able to specify the domain so that a cookie set on a page at m.mysite.com would be sent to the server when requesting a page at www.mysite.com. In other words, a cookie set on the mobile site should be accessible to the public site. Here’s my variation of the JavaScript cookies code from the link above:
function createCookie(name, value, days, domain, path) { var expires = ''; if (days) { var d = new Date(); d.setTime(d.getTime() + (days*24*60*60*1000)); expires = '; expires=' + d.toGMTString(); } domain = domain ? '; domain=' + domain : ''; path = '; path=' + (path ? path : '/'); document.cookie = name + '=' + value + expires + path + domain; } function readCookie(name) { var n = name + '='; var cookies = document.cookie.split(';'); for (var i = 0; i < cookies.length; i++) { var c = cookies[i].replace(/^\s+/, ''); if (c.indexOf(n) == 0) { return c.substring(n.length); } } return null; } function eraseCookie(name, domain, path) { setCookie(name, '', -1, domain, path); }
Now that we have the code to set and erase cookies, we can make use of it on both the iPhone-enhanced website and the main website. It’s important to note that the cookie-handling JavaScript code above needs to be available on each page on both the main site and the mobile site that will include a “view full site” or “view iPhone site” link. On the iPhone version of the site, we want to add a “view full site” link that will take the user to the main website. Here’s what that link looks like:
<a href="http://www.mysite.com" onclick="setCookie('iphone_mode', 'full', (0.25/24), 'mysite.com');"> View full site </a>
When a user clicks on the link, the setCookie function is called, which sets the iphone_mode cookie with a value of full at the mysite.com domain. The (0.25/24) value specifies that the cookie should expire after 15 minutes. You may want to adjust this value higher for your site if it is one you expect the user to stay on for longer than 15 minutes. After the cookie is set, the browser follows the link to the main website. Remembering our main site’s nginx configuration from above, we know that when a request comes in and there is an iphone_mode cookie set to full, the request passes through unchanged, allowing iPhone and iPod touch users to view the main website as though viewing it from a desktop computer.
Once a user comes to the main site, we need to provide a way to get back to the iPhone-optimized site. This is something that I’ve noticed is lacking from some popular websites and which is quite irritating, especially if the “view full site” link is pressed by mistake. So how do we do it?
We use the same method we used to get to the main site in the first place, but instead of setting the iphone_mode cookie, we clear it. There’s one difference with the main site, though. On the iPhone site, we can assume that it is being viewed with an iPhone or iPod touch, so we can unconditionally place a “view full site” link on any page. However, we can’t make the same assumption on the main site, so we can’t just put a “view iPhone site” link on it for all to see. Instead, this link should only be visible to iPhone and iPod touch users. It turns out that doing this is simple. We just include this snippet of JavaScript code wherever we want our “view iPhone site” link to appear:
<script type="text/javascript"> /*<![CDATA[*/ if (navigator.userAgent.match(/(iPhone|iPod)/)) { document.write('<a href="http://m.mysite.com" ' + 'onclick="deleteCookie(\'iphone_mode\', \'mysite.com\');">' + 'View iPhone site' + '</a>'); } /*]]>*/ </script>
This will write the link into the document at the point that the <script> tag appears, but it will only do so for iPhone and iPod touch users. As we can see by the onclick handler, clicking this link deletes the iphone_mode cookie that was set previously.
nginx configurations
Here are the nginx configurations for the main site and the mobile site. (The mobile site is where the iPhone pages live.) I’ve excluded the non-pertinent parts of the config, but I’ve included some of the basics so you can get a feel for where in the config the examples from above belong.
Main site nginx config
upstream app_server { server 0.0.0.0:3000; } server { listen 80; server_name www.mysite.com; root /path/to/main_site; # ... location / { proxy_set_header X-Real-IP $remote_addr; # ... if ($http_user_agent ~* '(iPhone|iPod)') { set $iphone_request '1'; } if ($http_cookie ~ 'iphone_mode=full') { set $iphone_request ''; } if ($iphone_request = '1') { rewrite ^.+ http://m.mysite.com$uri; } # serve cached pages ... if (!-f $request_filename) { proxy_pass http://app_server; break; } } }
Mobile site nginx config
upstream m_app_server { server 0.0.0.0:3001; } server { listen 80; server_name m.mysite.com; root /path/to/mobile_site; # ... location / { proxy_set_header X-Real-IP $remote_addr; # ... if ($http_user_agent ~* '(iPhone|iPod)') { set $iphone_request '1'; set $iphone_path_prefix '/iphone'; } if ($uri ~ ^/iphone.*$) { set $iphone_path_prefix ''; } if ($uri ~ '(images|stylesheets|javascripts|\.css|\.js|\.ico|\.gif|\.jpg|\.png)') { set $iphone_path_prefix ''; } if ($iphone_request = '1') { rewrite (.*) $iphone_path_prefix$1; } # serve cached pages ... if (!-f $request_filename) { proxy_pass http://m_app_server; break; } } }
Summary
After it was all put together, this setup accomplished all of my requirements listed above. Namely, it’s possible to view the main site, the mobile site, and the iPhone site using a standard browser (by going to www.mysite.com, m.mysite.com, and m.mysite.com/iphone, respectively), which helps enormously with testing. It’s also possible for iPhone users to move easily between the main site and the iPhone site, which in my opinion is essential unless the iPhone site is fully featured.
The only viewing that isn’t possible with this configuration is that an iPhone user cannot view the standard mobile website. In my situation, this is acceptable (and even preferred) because the iPhone site offers exactly the same information (plus some) that is offered on the standard mobile website. In other words, there’s no reason for an iPhone user to want or need to view the standard mobile site. If your needs are different, it would be possible to use the same cookie handling on the mobile site that is used on the main site in my example above to allow this.
Regarding caching, the app can cache pages at their request paths without having to worry about the rewriting that’s being done up front by nginx. If a request for /about comes in from anything but an iPhone, nginx serves up /about from the cache if it exists, and if not, passes the request to the app server, just as with any normal caching setup. If the same request comes in from an iPhone, nginx rewrites the URI to /iphone/about first, then it looks in the cache for /iphone/about, and if that doesn’t exist, it sends the request for /iphone/about to the app server. Hence, in the context of caching, the app doesn’t even need to check whether the user is on an iPhone or not.
I’ve written this in hopes that it saves someone else the time it took me to figure it all out. Please post comments if you have questions, need clarification, or have a better way of doing something I’ve explained here.
E-mail address obfuscation
If you’ve ever put your e-mail address on your site, you know how quickly spambots find it and start sending you great offers you can’t refuse. I had this problem and needed to come up with a good solution before beginning to develop client sites.
There are some libraries out there that provide e-mail address obfuscation, but the ones I found were not accessible in that there was no chance that a human would be able to read the e-mail address until after it had been decoded using JavaScript. Maybe a savant could decode it, but I don’t think that makes the site 503 compliant.
Instead, I needed something that would output a human-readable e-mail address that most spambots wouldn’t find. In this case, accessibility is most important, and obfuscation comes second. Because most spambots are lazy and only look for mailto: links or for e-mail addresses that match a regular expression, breaking the address apart would be enough. (I have no stats to back up the “most” qualifier in the previous sentence, but in practice I’ve found this to be true.)
A simple format that fits my two requirements is this: address [at] domain [dot] com. This is sufficiently human readable, and most spambots won’t notice it. For users with JavaScript enabled, it would be best to show a clickable link for address@domain.com.
To make the e-mail addresses easy for our code to find, each should be wrapped in a span tag marked with a special CSS class. For sake of example, I used “email”, but you can use something else if you want to be more discreet. So, an e-mail address should appear in the code as <span class=”email”>address [at] domain [dot] com</span>.
As a bonus, I also wanted it to handle addresses with a name. For instance, “Clark Kent” <clark [at] dailyplanet [dot] com> should become Clark Kent. To use a name as the link text, just add it in quotes before the address, and surround the address in < and > (properly escaped), such as <span class=”email”>”Clark Kent” <clark [at] dailyplanet [dot] com></span>.
In order to make the transformation, here’s what I did. (This code intentionally does not use any JavaScript libraries because I often use it on temporary pages that are posted while I’m building a site, so it’s pointless to require users to download all that extra code just so I can save myself a few lines).
function initEmailLinks() {
var spans = document.getElementsByTagName('span');
for (var i = 0; i < spans.length; i++) {
var span = spans[i];
if (span.className.indexOf('email') != -1) {
var name = null;
var matches = span.innerHTML.match('"(.*?)"');
if (matches != null && matches.length == 2) {
name = matches[1];
}
var address = span.innerHTML // split to multiple lines for readability
.replace(/.*<(.*)>\s*/, '$1') // remove '<' and '>'
.replace(/\s*\[at\]\s*/, '@') // replace ' [at] ' with '@'
.replace(/\s*\[dot\]\s*/, '.'); // replace ' [dot] ' with '.'
if (name == null || name == '') {
name = address;
}
if (span.style.className.match('no-link')) {
span.update(address); // render the address as plain text
} else {
// render the address as a mailto: link
span.innerHTML = '<a href="mailto:' + address + '">' + name + '</a>';
}
}
}
}
Then once the page loads, just call initEmailLinks() and all the address [at] domain [dot] com links change to address@domain.com. Since it’s sometimes useful to show an e-mail address without making it a link, adding the CSS class “no-link” to the span surrounding the e-mail address has this effect.
The code is much simpler when jQuery or Prototype are available, since we can use the libraries’ CSS selectors to get the elements we’re interested in. Since I’m switching things over to jQuery, I’ll post that version here:
$(function(){
$('span.email').each(function(){
var span = $(this);
var name = null;
var matches = span.html().match('"(.*?)"');
if (matches && matches.length == 2) {
name = matches[1];
}
var address = span.html() // split to multiple lines for readability
.replace(/.*<(.*)>\s*/, '$1') // remove '<' and '>'
.replace(/\s*\[at\]\s*/, '@') // replace ' [at] ' with '@'
.replace(/\s*\[dot\]\s*/, '.'); // replace ' [dot] ' with '.'
if (name == null || name == '') {
name = address;
}
if (span.hasClass('no-link')) {
span.html(address);
} else {
span.html('<a href="mailto:' + address + '">' + name + '</a>');
}
});
}
If you’re using jQuery and you don’t care about support for using custom link text (such as a person’s name) and rendering e-mail addresses as plain text, then the code can be simplified to this:
$(function(){
$('span.email').each(function(){
var span = $(this);
var address = span.html().replace(/\s*\[at\]\s*/, '@').replace(/\s*\[dot\]\s*/, '.');
span.html('<a href="mailto:' + address + '">' + address + '</a>');
});
}
For convenience, your server-side code should to be able to properly obfuscate e-mail addresses for you on-the-fly. I use Rails for most of my projects, so here’s a simplified version of a helper method I use:
def email(address, name = nil)
'<span class="email">' +
(name ? %{"#{name}" <} : '') +
address.gsub('@', ' [at] ').gsub('.', ' [dot] ') +
(name ? '>' : '') +
'</span>'
end
I highly recommend using something like this, especially when you’re putting a client’s e-mail address on their website. It’s fairly quick and easy to implement on both the server and client, and it will keep the address harvesting to a minimum while still being accessible to users without JavaScript.
Catching up
2009 is less than a month away, and I’m already (read: finally) writing my first blog post. I’ve written a blog engine (which runs ErikEbelingArt.com/blog) but never a simple post. Go figure.
As a web developer, I have the pleasure of solving some interesting problems, from simple CSS tricks to complex server-side image processing. Knowing the feeling of hitting a roadblock at 1:00am the night a client website is supposed to be launched and finding a solution on someone’s blog, I thought it was past time for me to contribute. Plus, I want to get published before the Internet is full.
My current development focus is on Rails, but I still do plenty of Java (which can be written as JAVA if you’re a recruiter). I do a fair amount of JavaScript using Prototype and Scriptaculous, and I’m getting into jQuery as of late. I also do a lot of HTML and CSS, striving for valid, semantically correct, accessible code. Although I have no formal web design or graphics training, I enjoy cutting up a design and converting it to a standards-compliant website. That is, until it comes time to “fix” it for IE.
I’ve got some topics I’ll be writing about soon, which will cover CSS, JavaScript, Rails, nginx, and more. I’ll also write about new problems and solutions as they come up.
If you’re wondering “why the strange Nick not found name”, my best answer is, “why not?” I’ve been thinking about registering it as a new HTTP status code, but I haven’t settled on a number yet (all the good ones are taken). Perhaps more importantly, I can’t find a site that lets me register a new one.