1

Background

I just started using Digital Oceans Spaces for storing my static files for my Django web app (connected using django-storages library). Previously, I used link elements in the head of my base template to preload my fonts to avoid font-switching flashes while the page is loading (which is the case for 'Mona Sans') or when an element that uses a custom font is shown for the first time (I use a 'Pixelated' font in a dialog element).

The Issue

However, now there is a discrepancy in the url's produced by the static template tag in my Django templates and the url's produced by relative paths in my css file.

The fonts get loaded just fine using the relative path in the css file, but they are missing the query parameters, so the preloaded resources (with query parameters) don't actually end up being used (causing brief font-swap flash and console warnings).

Additionally, I don't know if not having the query parameters will eventually cause issues once I implement read-protection with pre-signed URLs.

Django template

<link rel="preload" href="{% static 'fonts/Mona-Sans.woff2' %}" as="font" type="font/woff2" crossorigin/>
<link rel="preload" href="{% static 'fonts/Pixelated.woff2' %}" as="font" type="font/woff2" crossorigin/>

This use of the static tag results in a URL like this:

https://{region}.digitaloceanspaces.com/{bucket_name}/static/fonts/Pixelated.woff2?{bunch of query parameters}

css

@font-face {
  font-family: 'Mona Sans';
  src:
    url('../fonts/Mona-Sans.woff2') format('woff2 supports variations'),
    url('../fonts/Mona-Sans.woff2') format('woff2-variations');
  font-weight: 200 900;
  font-stretch: 75% 125%;
  font-display: swap;
}

This relative path results in a URL like this:

https://{region}.digitaloceanspaces.com/{bucket_name}/static/fonts/Pixelated.woff2

Example of Browser Console Warning

The resource https://sfo3.digitaloceanspaces.com/spaces-bucket-name/static/fonts/Pixelated.woff2?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=blah%blah%blah%blah%blah_request&X-Amz-Date=20240709T172841Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=blahblahblahblahblahblah was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate as value and it is preloaded intentionally.

Potential Solutions

Here are the potential solutions I can think of:

  • Somehow serve smaller/more basic static files like fonts and svg icons from the same server that is serving the Django project (in Django, is it possible to split certain static resources into two different ways of serving static files), avoiding the DO CDN altogether
  • Figure out a way to inject the full resource URL into my css files
  • Store smaller/more basic static files like fonts and svg icons somewhere in a public repository and just use absolute URLs to this repository in both my css and html head

Is there a better solution I'm not thinking of? If not, which of my potential solutions would give my app's frontend the best performance?

1 Answer 1

1

Multiple storage backends

Since my project deals with a lot of audio that I've been serving as static files, and since all of my other static files (css, js, svg, fonts, jpg, mp4, etc.) are really small file sizes (from 497 bytes to 667 kb) that Digital Oceans Spaces recommends be served another way, I've decided to just serve static files from the same server that is serving my django project, and I implemented a model for audio files to be served from Digital Oceans Spaces.

In summary, I am still using the django-storages library, but I have one way of serving static files (standard staticfiles strategy that requires running collectstatic command everytime a static file changes or is added) and two different ways of serving media files (my site's media files and user media files). Below is a peek at my settings.py and a simplified view of my models.py so you can see the code I used to implement this static/media files strategy.

Couple of notes:

  • using django-environ package for environment variables
  • 'default' is the storage scheme (with user_media_storage_backend) that is for user media files
  • 'appmedia' is the storage scheme (with app_media_storage_backend) for my site's media files
    • I like separating the two simply because I can set separate Digital Oceans spaces buckets for either type of media files (and set different overwrite settings)
  • the USE_SPACES environment variable can be set to False to use the regular 'django.core.files.storage.FileSystemStorage' storage backend for all media files (for development environment, if desired)
  • You'll see I'm using callable functions to set the file paths and filenames for ImageField and FileField (see Django documentation for FileField)
    • You'll also see that I'm prefixing my file names with a random sequence of ascii letters so that calls to any given resource don't have to search through as many objects to find the right object in Spaces (as recommended in Digital Oceans Spaces docs)

settings.py

from pathlib import Path
import environ

env = environ.Env(
    # set casting, default value
    DEBUG=(bool, False)
)

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

# Take environment variables from .env file
import os
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Apps added (3rd party)
    'storages',
    # Apps built and added by me
    ## This app has user profile model
    'profile',
    ## This app has model that will use non-user-generated audio files
    'dialogues'
]

# cuz it will be string in env file, do this to get a boolean
USE_SPACES = env("USE_SPACES") == "True"

if USE_SPACES:
    # Hafta make dictionaries to go in STORAGES dictionary cuz this is new way to do it according to django-storages docs (https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html)
    ## First make base dictionary object cuz I'm gonna just extend it with the options that are actually different
    do_region = env('DO_REGION_NAME')
    do_endpoint = f'https://{do_region}.digitaloceanspaces.com'
    do_secret_key = env('DO_SECRET_ACCESS_KEY')
    do_access_key = env('DO_ACCESS_KEY_ID')
    user_media_cloud_storage_backend = {
        'BACKEND': 'storages.backends.s3.S3Storage',
        'OPTIONS': {
            'endpoint_url': do_endpoint,
            'region_name': do_region,
            'bucket_name': env('DO_USER_STORAGE_BUCKET_NAME'),
            'location': 'media',
            'secret_key': do_secret_key,
            'access_key': do_access_key,
            'object_parameters': {'CacheControl': 'max-age=86400'},
            'default_acl': 'private',
            'querystring_auth': True,
            'file_overwrite': False,
        }
    }
    app_media_cloud_storage_backend = {
        'BACKEND': 'storages.backends.s3.S3Storage',
        'OPTIONS': {
            'endpoint_url': do_endpoint,
            'region_name': do_region,
            'bucket_name': env('DO_APP_STORAGE_BUCKET_NAME'),
            'location': 'media',
            'secret_key': do_secret_key,
            'access_key': do_access_key,
            'object_parameters': {'CacheControl': 'max-age=86400'},
            'default_acl': 'private',
            'querystring_auth': True,
            'file_overwrite': True,
        }
    }
    ## then backend_with_options goes into the big STORAGES dictionary that the django-storages docs ask for (Django 4.2 and greater)
    STORAGES = {
        'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'},
        'default': user_media_cloud_storage_backend, # this 'default' is the same as media
        'appmedia': terp_media_cloud_storage_backend,
    }
    # so staticfiles app will work (in dev and production environments)
    STATIC_URL = 'static/'
    STATIC_ROOT = '/var/www/example.com/static/'
else:
    
    STORAGES = {
        'default': {
            'BACKEND': 'django.core.files.storage.FileSystemStorage',
        },
        'staticfiles': {
            'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
        },
        'appmedia': {
            'BACKEND': 'django.core.files.storage.FileSystemStorage',
        },
    }
    
    STATIC_URL = '/static/'
    STATIC_ROOT = BASE_DIR / 'staticfiles'
    MEDIA_URL = '/media/'
    MEDIA_ROOT = BASE_DIR / 'mediafiles'

STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static/'),
]

profile > models.py

This ImageField in my user profile model will use the storage backend I defined as 'default':

from django.db import models
import random
from string import ascii_letters

# Returns five-char random string to be prepended to filename (so computer can find file faster as suggested here: https://docs.digitalocean.com/products/spaces/concepts/best-practices/)
def genRandomString():
    randomPrefix = ''.join(random.choice(ascii_letters) for i in range(5))
    return randomPrefix

# with how this function works, there would need to be validation on the forms that handle this file upload so that they only accept jpg and png files
def profile_pic_file_path(instance, filename):
    randomString = genRandomString()
    jpg_extensions = ('.jpg', '.JPG', '.jpeg', '.JPEG')
    png_extensions = ('.png', '.PNG')
    if filename.endswith(jpg_extensions):
        ext = '.jpg'
    elif filename.endswith(png_extensions):
        ext = '.png'
    # this is just a catch-all in case a non-jpg or -png file slips by validation
    else:
        ext = filename
    return f'profile_pics/{randomString}_userslug_{instance.slug}_pic{ext}'

class UserProfile(models.Model):
    # ... bunch of fields ...
    profile_pic = models.ImageField(null=True, blank=True, upload_to=profile_pic_file_path)

dialogues > models.py

Things to notice:

  • I have to load the storages module so that I can select for my non-default method of storing files
  • here I'm not as worried about dealing with file extension issues because myself, not users, will be the one uploading these files
  • my function to set the file path and name can access related objects, which is handy (see Django documentation for FileField).
from django.db import models
from django.core.files.storage import storages
import random
from string import ascii_letters

def select_storage():
    return storages['appmedia']
    
# Returns two-char random string to be prepended to filename (so computer can find file faster as suggested here: https://docs.digitalocean.com/products/spaces/concepts/best-practices/)
def genRandomPrefix():
    randomPrefix = ''.join(random.choice(ascii_letters) for i in range(2))
    return randomPrefix
    
def dialogue_audio_file_path(instance, filename):
    dialogue_id = instance.original_dialogue.dialogue_slug
    randomPrefix = genRandomPrefix()
    return f'dialogue_audios/{dialogue_id}/{randomPrefix}-{filename}'

class DialogueAudioFile(models.Model):  
    audio_file = models.FileField(
        storage=select_storage, upload_to=dialogue_audio_file_path
    )
    original_dialogue = models.ForeignKey(
        Dialogue, on_delete=models.CASCADE,
    )

Not the answer you're looking for? Browse other questions tagged or ask your own question.