Expiring (Signed) Links with Digital Ocean Spaces
Tags: django, digitalocean, python, webdev
Published: 2019-06-12 13:47:22

---
layout: post
title: Expiring (Signed) Links with Digital Ocean Spaces
published: true
categories: []
tags: django, digitalocean, python, webdev
canonical_url: https://www.deanmontgomery.com/2019/06/12/expiring-do-links/
---

Very quick guide on how to create expiring links to an object on [Digital Ocean Spaces](https://m.do.co/c/4afdc8b5be2e) with Django. (<-- that's an affiliate link, btw, you'll get $50 to spend in 30 days if you signup)

tl;dr:

```python
import boto3
from botocore.client import Config

session = boto3.session.Session()
client = session.client(
    "s3",
    region_name=AWS_S3_REGION_NAME,
    endpoint_url=AWS_S3_ENDPOINT_URL,
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
    config=Config(signature_version="s3"),
)

url = client.generate_presigned_url(
    ClientMethod="get_object",
    Params={
        "Bucket": "bucket-name",
        "Key": "object-key",
    },
    ExpiresIn=60 * 60,
)
```

## Why

If you store your assets (images, PDFs, audio files, etc) in an object store like Spaces, but you want to restrict access to these objects for some reason you can create a signed link that expires after some defined time.

For example, I host all the audio files from [Yet Another Sermon Host](https://yetanothersermon.host/) on Spaces, but I need to keep track of how many downloads occur to provide analytics for my customers. I can do this by registering a download in a Django view that then redirects to the expiring Spaces URL.
This way, all the downloads have to pass through my server that generates the expiring URL and I can record every download.

## How

Digital Ocean Spaces is API compatible with Amazon S3, so we can use [boto3](https://github.com/boto/boto3) & [django-storages](https://github.com/jschneier/django-storages) - I'll assume you already have `django-storages` setup ([instructions for DO Spaces](https://django-storages.readthedocs.io/en/latest/backends/digital-ocean-spaces.html)).

After installing `django-storages` your `settings.py` should contain something like this;

```python
# settings.py

AWS_QUERYSTRING_AUTH = False
AWS_DEFAULT_ACL = "public-read"
AWS_ACCESS_KEY_ID = os.environ["DO_SPACES_ACCESS_KEY"]
AWS_SECRET_ACCESS_KEY = os.environ["DO_SPACES_SECRET"]
AWS_STORAGE_BUCKET_NAME = "bucket-name"
AWS_S3_REGION_NAME = "do-spaces-region"
AWS_S3_ENDPOINT_URL = f"https://{AWS_S3_REGION_NAME}.digitaloceanspaces.com"
AWS_S3_CUSTOM_DOMAIN = (
    f"{AWS_STORAGE_BUCKET_NAME}.{AWS_S3_REGION_NAME}.cdn.digitaloceanspaces.com"
)
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}AWS_MEDIA_LOCATION = "yash-media"
AWS_S3_FILE_OVERWRITE = False # safer behaviour than the default
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} # do some caching, why not
```

And we'll add a boto3 client:

```python
# settings.py

# client for expiring urls:
aws_session = boto3.session.Session()
AWS_CLIENT = aws_session.client(
    "s3",
    region_name=AWS_S3_REGION_NAME,
    endpoint_url=AWS_S3_ENDPOINT_URL,
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
    config=Config(signature_version="s3"),
)
```

And we should have a model with a `FileField`

```python
# models.py

from django.db import models


class Asset(models.Model):
    name = models.CharField(max_length=1000)
    private_file = models.FileField()
    # other fields here
```

Generating the expiring link is then a matter of using the boto3 library, so
let's add it as a method to the model:

```python
# models.py

from django.conf import settings
from django.db import models


class Asset(models.Model):
    name = models.CharField(max_length=1000)
    private_file = models.FileField()
    # other fields here
    
    @property
    def storage_key(self):
        return f"{self.private_file.storage.location}/{self.private_file.name}"

    def get_expiring_url(self, expires_in=1800):
        """
        Generates a permissioned url that will expire at `expires_in` seconds.
        Defaults to 30 minutes.
        """
        if self.private_file is not None:
            url = settings.AWS_CLIENT.generate_presigned_url(
                ClientMethod="get_object",
                Params={
                    "Bucket": settings.AWS_STORAGE_BUCKET_NAME,
                    "Key": self.storage_key,
                },
                ExpiresIn=expires_in,
            )
            return url

        raise Http404

```

And we can redirect users like this:

```python
# views.py

from django.shortcuts import get_object_or_404
from django.views.generic import RedirectView


class Download(RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        """
        Override this method so the view doesn't try to do any 
        string interpolation.
        """
        return self.url

    def get(self, request, *args, **kwargs):
        asset = get_object_or_404(models.Asset, pk=kwargs["pk"])
        # TODO: register a download or check user has permission here
        self.url = asset.get_expiring_url()
        return super().get(request, *args, **kwargs)
``` 

Note: you have to set the ACL on the object to `private` otherwise anyone can still access the files without the signed URL.

We can do this by making the entire bucket private:

```python
# settings.py

AWS_DEFAULT_ACL = "private"
```

Or we can toggle individual objects with a couple of helper methods:

```python
# models.py

class Asset(models.Model):
    # other methods from above

    def set_private(self):
        self._set_acl("private")

    def set_public_read(self):
        self._set_acl("public-read")

    def _set_acl(self, acl):
        settings.AWS_CLIENT.put_object_acl(
            Bucket=settings.AWS_STORAGE_BUCKET_NAME,
            Key=self.storage_key, ACL=acl
        )
```