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).
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.
<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}
@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
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.
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?
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:
user_media_storage_backend
) that is for user media filesapp_media_storage_backend
) for my site's media files
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)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/'),
]
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)
Things to notice:
storages
module so that I can select for my non-default method of storing filesfrom 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,
)