Skip to Content

Technology Blog

Technology Blog

Permission Based File Serving

Recently updated on

One issue I've run into a couple times while working with Django is the need to serve files to users based on permissions. The first situation occurred with a store we were building that would allow for electronic versions of books to be sold. These books would typically be distributed in PDF format but overall to the story, the format is irrelevant. In this scenario I needed to be able to take the ID of a book and return a PDF download to the user. However, I only wanted to do this if the user passed proper authentication and had ordered the book.

The problem is that Django doesn't serve static media, that falls on the shoulders of your particular web server (Apache, Nginx, etc). The only issue with that is that my web server doesn't know if the user is authenticated or not. There is a ticket open on this current issue, which will hopefully lead us to a method of properly delivering permission shielded files to our users. Until that occurs there is another way, but I warn you methods will differ based on your web server of choice. Also, knowing a bit about web server configuration is handy.

First we have nginx. To start, you're going to need the following setup in your nginx configuration file for the site in question:

    location /protected/ {
        internal;
        alias /PATH/TO/FILES;
    }

The first line lets nginx know that any requests for "/protected/" need to be handled by this directive. We then instruct that this is for internal requests only so that external users can't make requests for books. Finally, the alias will point to the path where nginx can expect to find the requested files. You can, of course, change "/protected/" to whatever suits you best. After that's done, we have to work some magic in our views:

        if can_view is False:
            return HttpResponseNotFound()
        else:
            response = HttpResponse()
            response['Content-Type'] = ""
            response['X-Accel-Redirect'] = file.get_absolute_url()
            return response

The 'can_view' is a variable I'm using to make sure the user is allowed access to this file. If not we just tell them it doesn't exist, but you can return a 403 just as easily if you prefer. The more important aspect is what we're doing with the response. We start with a standard response object and then nullify the Content-Type section. We'll let nginx decide the content type for us. Then we have an 'X-Accel-Redirect', which lets nginx know that it can serve a file back to the user with this internal request. Then we return the response and the user receives a file (I recommend viewing this Snippet as well).

Unfortunately, Apache isn't quite as friendly. First you'll need to install a new module into Apache called mod_xsendfile. In Linux, as root, you can install with the following:

cd /usr/local/src/
wget http://tn123.ath.cx/mod_xsendfile/mod_xsendfile-0.9.tar.gz
tar zxvf mod_xsendfile-0.9.tar.gz
cd mod_xsendfile-0.9
apxs -cia mod_xsendfile.c

That will handle the installation. Now we need to configure Apache to play nice as well. In my second situation I'm dealing with files that have been uploaded by users and will be downloaded by other users, assuming authentication checks out. The problem is that, by default, uploads will go to your MEDIA_ROOT followed by your 'upload_to' path. Hence if you had an alias such as:

alias /PATH/TO/MEDIA_ROOT

Then users could still access these protected files. I circumvented this issue with the following:

    <Directory /PATH/TO/MEDIA_ROOT/ATTACHMENTS/>
        Deny from all
    </Directory>    

This way, files from that location can only be served if Django says it's okay. If someone otherwise tries to access the protected files, they get served a 403 instead. Finally, you'll need to add the next two lines to your configuration as well for mod_xsendfile:

XSendFile on
XSendFileAllowAbove on

Now that we have Apache setup, how about that view:

        if can_view is False:
            return HttpResponseNotFound()
        else:
            response = HttpResponse()
            response['X-Sendfile'] = os.path.join(
                settings.MEDIA_ROOT, file.file.path)
            content_type, encoding = mimetypes.guess_type(
                file.file.read())
            if not content_type:
                content_type = 'application/octet-stream'
            response['Content-Type'] = content_type
            response['Content-Length'] = file.file.size
            response['Content-Disposition'] = 'attachment; filename="%s"' % \
                file.file.name
            return response

This works basically like the nginx method does. We have our blank response for starters, and then we fill out the necessary sections. You will want to import 'os' and 'mimetypes' for this as well. For starters, we set the 'X-Sendfile' header which is used by mod_xsendfile. We want to pass to it the path to the file. Then we try to guess the content and encoding types using mimetypes.guess_type. Here you just want to pass a string or buffer, that's why I'm passing the contents of the file's read() function there. If it's blan, we'll assume an octet-stream. Content length is easy to determine from the file as well. The last item, Content-Disposition, isn't immediately obvious, but we want to declare this item as an attachment. Then we want to inform the user's browser of the filename as well. Finally, we return the response, and the user downloads their file.

My big problem with all of this is that it's not web server agnostic. That means that for every site I set up, I have to replicate this behaviour depending on the web server in use. For the time being I've worked around this with a cheap hack that involves using a django.conf.settings variable that tells the application which server type is being used. Then I can wrap the above two blocks of code in an if/elif statement. It's not exceptionally pretty, but it does seem to work. Afterall, it'll have to do until #2131 is merged into the trunk. Even then, it won't see the light of day until Django 1.2.


Share , ,
If you're getting even a smidge of value from this post, would you please take a sec and share it? It really does help.