How to serve private media files with Django

Managing access to user-uploaded files

by josh


Posted on Mar 11, 2020


In this tutorial, we will create a barebones document manager with these features:

  • Logged in users can upload files.
  • Superusers can view all files.
  • Regular users can view only the files they uploaded.

These steps were performed on Ubuntu 18.04 LTS.

Getting Started

First lets add the universe repository and install python3-venv.

sudo add-apt-repository universe
sudo apt install -y python3-venv

The Project and App

Now create the project.

mkdir ~/.virtualenvs
python3 -m venv ~/.virtualenvs/docman
source ~/.virtualenvs/docman/bin/activate
pip install django
django-admin startproject docman
cd docman

Now create the database and tables by running migrate.

./manage.py migrate

After the migration process completes create an app

./manage.py startapp core

The model

Each document is associated with the user.
We also track the date and time that the document was uploaded.
By default documents will be sorted in reverse chronological order.

# core/models.py

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from django.template.defaultfilters import slugify


class Document(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField()
    file = models.FileField()
    created_by = models.ForeignKey(User, on_delete=models.PROTECT)
    created_at = models.DateTimeField(default=timezone.now)
    slug = models.SlugField(max_length=255, editable=False)

    class Meta:
        ordering = ['-created_at']

    def save(self, *args, **kwargs):
        if not self.id:
            self.slug = slugify(self.title)

        return super(Document, self).save(*args, **kwargs)

Now add the app to the INSTALLED_APPS list in settings.py

# docman/settings.py

INSTALLED_APPS = [
...
    'core.apps.CoreConfig',
]

Generate migrations for the model.

./manage makemigrations core

Then update the database to add the table for the model.

./manage migrate

The views

The application functionality consists of 3 views:

  • The default view displays a list of documents.
  • A view to upload a document.
  • A view to download a document.

All views are restricted to logged-in users.

# core/views.py

import os

from django.shortcuts import render, get_object_or_404
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
from django.views.generic.edit import CreateView
from django.http import FileResponse, HttpResponseForbidden, HttpResponse
from django.views import View
from django.urls import reverse
from django.conf import settings

from .models import Document


class DocumentList(LoginRequiredMixin, ListView):
    model = Document

    def get_queryset(self):
        queryset = Document.objects.all()
        user = self.request.user

        if not user.is_superuser:
            queryset = queryset.filter(
                created_by=user
            )

        return queryset


class DocumentCreate(LoginRequiredMixin, CreateView):
    model = Document
    fields = ['title', 'description', 'file']

    def get_success_url(self):
        return reverse('home')

    def form_valid(self, form):
        form.instance.created_by = self.request.user
        return super().form_valid(form)


class DocumentDownload(View):
    def get(self, request, relative_path):
        document = get_object_or_404(Document, file=relative_path)
        if not request.user.is_superuser and document.created_by != request.user:
            return HttpResponseForbidden()
        absolute_path = '{}/{}'.format(settings.MEDIA_ROOT, relative_path)
        response = FileResponse(open(absolute_path, 'rb'), as_attachment=True)
        return response

The URLs

Lets wire up the urls for the views.

# docman/urls.py

from django.contrib import admin
from django.urls import path
from django.contrib.auth import views as auth_views

from core.views import DocumentList, DocumentCreate, DocumentDownload

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', DocumentList.as_view(), name='home'),
    path('document-add/', DocumentCreate.as_view(), name='document-add'),
    path('media/<path:relative_path>', DocumentDownload.as_view(), name='document-download'),

    path('accounts/login/', auth_views.LoginView.as_view()),
    path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
]

The templates

Create a login template.

# core/templates/registration/login.html

<form method="POST">
    {% csrf_token %}
    {{form.as_p}}
    <button type="submit">Login</button>
</form>

Create a base template.

# core/templates/base.html

<h1>Document Manager</h1>
<p>Logged in as {{user.get_full_name}}</p>
<p><a href="{% url "logout" %}">Logout</a></p>
{% block content %}
{% endblock %}

The document list displays documents in reverse chronological order.

# core/templates/core/document_list.html

{% extends "base.html" %}

{% block content %}
    <h2>Documents</h2>
    <a href="{% url "document-add" %}">Add Document</a>
    <table width="50%">
        <thead>
            <tr>
                <th>Title</th>
                <th>Description</th>
                <th>Created by</th>
                <th>Created at</th>
                <th>File</th>
            </tr>
        </thead>
        {% for document in object_list %}
            <tr>
            <td>{{ document.title }}</td>
            <td>{{document.description}}</td>
            <td>{{document.created_by}}</td>
            <td>{{document.created_at}}</td>
            <td><a href="{{document.file.url}}">{{document.file.name}}</a></td>
            </tr>
        {% endfor %}
    </table>
{% endblock %}

Files are downloaded by clicking on the filename link.
The Add Document button links to the document upload page.

# core/templates/core/document_form.html

{% extends 'base.html' %}
{% block content %}
<h1>Add Document</h1>
<form method="POST" enctype="multipart/form-data">
    {% csrf_token %}
    {{form.as_p}}
    <button type="submit">Submit</button>
</form>
{% endblock %}

Django stores uploaded files on the local file system using paths relative to MEDIA_ROOT
Lets define media root in settings.py

# docman/settings.py

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

Also add a line at the bottom of settings.py that sets the URL to redirect to when a user logs out

# docman/settings.py

LOGOUT_REDIRECT_URL = '/'

The users

We will create two users:

  • A superuser who can view all uploaded documents.
  • A regular user who can view only the documents they uploaded.

We will use the django-createuser app to add a convenient management command for creating users.

Install django-createuser

pip install django-createuser

Add django_createuser to your installed apps in settings.py

# docman/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'core.apps.CoreConfig',
    'django_createuser',  #new
]

Now create the superuser.

python manage.py createuser --email admin@example.com --first_name Administrator --is-superuser --password test1234 admin

Then the regular user.

python manage.py createuser --email user@example.com --first_name Test --last_name User --password test1234 user

Run the application

We will use the Django the dev server.

python manage.py runserver

Navigate to http://127.0.0.1:8000 in your browser.

You will get prompted to login using username and password.

Login as the superuser using the username admin and password test1234.
You should see the homepage.

docman_home.PNG

Click the Add Document link and upload a document
docman_upload.PNG
Then:
Logout from the superuser account.
Login as the regular user using the username user and password test1234.
The document uploaded by the superuser will not be visible in the list.
Upload a document as the regular user.

If you login as the superuser both documents are displayed in the list.
You should be able to download both documents as the superuser by clicking on the links.

Testing access control

Copy the both document links and paste them in a text editor

Test access for logged out users

Logout and try downloading the files using the links you copied.
Access will be denied
docman_accessdenied.PNG

Test access to superuser file for regular user.

Login as the regular user then try downloading the superuser file using the link you copied.
Access will be denied

Next Steps

The setup described in this article is not recommended for production.
Serving media files is best handled by a web server like NGINX or a CDN backed Object Storage service like AWS S3.

A future article will evolve our document manager to use NGINX and Object Storage.