cssdjangodjango-templatesdjango-staticfilespython-django-storages

Django templates and css: is there a workaround for getting my preload link tags to match my css relative paths to font sources?


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:

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?


Solution

  • 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:

    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:

    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,
        )