How to serve private media files with Django

joshwizzy

Joshua Masiko

Posted on March 13, 2020

How to serve private media files with Django

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now create the database and tables by running migrate.

./manage.py migrate
Enter fullscreen mode Exit fullscreen mode

After the migration process completes create an app

./manage.py startapp core
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

# docman/settings.py

INSTALLED_APPS = [
...
    'core.apps.CoreConfig',
]
Enter fullscreen mode Exit fullscreen mode

Generate migrations for the model.

./manage makemigrations core
Enter fullscreen mode Exit fullscreen mode

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

./manage migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'),
]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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 = '/'

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

Now create the superuser.

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

Then the regular user.

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

Run the application

We will use the Django the dev server.

python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
joshwizzy
Joshua Masiko

Posted on March 13, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Choosing the Right Relational Database
undefined Choosing the Right Relational Database

November 29, 2024

This Week In Python
python This Week In Python

November 29, 2024

Django project - Part 2 Postgres
undefined Django project - Part 2 Postgres

November 29, 2024