diff --git a/.gitignore b/.gitignore index facf020..09297ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Django specific collected-static collected-media +.vscode # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/app.json b/app.json index 6eb6d04..f124149 100644 --- a/app.json +++ b/app.json @@ -1,25 +1,38 @@ { - "name": "Proper Django Static File Serving", - "description": "Using bucketeer with private/public media and static files in Django", - "repository": "https://github.com/dstarner/django-heroku-static-file-example", - "logo": "https://i0.wp.com/copyassignment.com/wp-content/uploads/2021/08/Django-logo.jpg?fit=474%2C474&ssl=1", - "keywords": ["python", "django", "static"], - "addons": ["heroku-postgresql:hobby-dev"], - "buildpacks": [ - {"url": "https://github.com/moneymeets/python-poetry-buildpack"}, - {"url": "heroku/python"} - ], - "env": { - "SECRET_KEY": { - "description": "A secret key value required by Django", - "generator": "secret" - }, - "DEBUG": { - "value": "False" - }, - "DJANGO_SUPERUSER_PASSWORD": { - "description": "Used to generate the superuser of username 'test' (test@example.com)", - "value": "hunter2" - } - } + "name": "Proper Django Static File Serving", + "description": "Using bucketeer with private/public media and static files in Django", + "repository": "https://github.com/dstarner/django-heroku-static-file-example", + "logo": "https://i0.wp.com/copyassignment.com/wp-content/uploads/2021/08/Django-logo.jpg?fit=474%2C474&ssl=1", + "keywords": ["python", "django", "static"], + "addons": [ + "heroku-postgresql:hobby-dev", + { + "plan": "bucketeer:hobbyist", + "as": "BUCKETEER" + } + ], + "buildpacks": [{ + "url": "https://github.com/moneymeets/python-poetry-buildpack" + }, + { + "url": "heroku/python" + } + ], + "env": { + "SECRET_KEY": { + "description": "A secret key value required by Django", + "generator": "secret" + }, + "DEBUG": { + "value": "False" + }, + "DJANGO_SUPERUSER_PASSWORD": { + "description": "Used to generate the superuser of username 'test' (test@example.com)", + "value": "hunter2" + }, + "S3_ENABLED": { + "description": "Enable to upload & serve static and media files from S3", + "value": "True" + } + } } diff --git a/example/config/settings.py b/example/config/settings.py index 22bca4d..f25df1d 100644 --- a/example/config/settings.py +++ b/example/config/settings.py @@ -13,6 +13,7 @@ import os from pathlib import Path +from decouple import config import django_heroku # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -121,6 +122,41 @@ STATIC_ROOT = BASE_DIR / 'collected-static' +S3_ENABLED = config('S3_ENABLED', cast=bool, default=False) +LOCAL_SERVE_MEDIA_FILES = config('LOCAL_SERVE_MEDIA_FILES', cast=bool, default=not S3_ENABLED) +LOCAL_SERVE_STATIC_FILES = config('LOCAL_SERVE_STATIC_FILES', cast=bool, default=not S3_ENABLED) + +if (not LOCAL_SERVE_MEDIA_FILES or not LOCAL_SERVE_STATIC_FILES) and not S3_ENABLED: + raise ValueError('S3_ENABLED must be true if either media or static files are not served locally') + +if S3_ENABLED: + AWS_ACCESS_KEY_ID = config('BUCKETEER_AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = config('BUCKETEER_AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = config('BUCKETEER_BUCKET_NAME') + AWS_S3_REGION_NAME = config('BUCKETEER_AWS_REGION') + AWS_DEFAULT_ACL = None + AWS_S3_SIGNATURE_VERSION = config('S3_SIGNATURE_VERSION', default='s3v4') + AWS_S3_ENDPOINT_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' + AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +if not LOCAL_SERVE_STATIC_FILES: + STATIC_DEFAULT_ACL = 'public-read' + STATIC_LOCATION = 'static' + STATIC_URL = f'{AWS_S3_ENDPOINT_URL}/{STATIC_LOCATION}/' + STATICFILES_STORAGE = 'example.utils.storage_backends.StaticStorage' + +if not LOCAL_SERVE_MEDIA_FILES: + PUBLIC_MEDIA_DEFAULT_ACL = 'public-read' + PUBLIC_MEDIA_LOCATION = 'media/public' + + MEDIA_URL = f'{AWS_S3_ENDPOINT_URL}/{PUBLIC_MEDIA_LOCATION}/' + DEFAULT_FILE_STORAGE = 'rn_api.utils.storage_backends.PublicMediaStorage' + + PRIVATE_MEDIA_DEFAULT_ACL = 'private' + PRIVATE_MEDIA_LOCATION = 'media/private' + PRIVATE_FILE_STORAGE = 'rn_api.utils.storage_backends.PrivateMediaStorage' + + # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field diff --git a/example/config/urls.py b/example/config/urls.py index b4b4bc0..9475466 100644 --- a/example/config/urls.py +++ b/example/config/urls.py @@ -13,9 +13,17 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import path urlpatterns = [ path('admin/', admin.site.urls), ] + +if settings.LOCAL_SERVE_STATIC_FILES: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +if settings.LOCAL_SERVE_MEDIA_FILES: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/example/utils/storage_backends.py b/example/utils/storage_backends.py new file mode 100644 index 0000000..eb592a2 --- /dev/null +++ b/example/utils/storage_backends.py @@ -0,0 +1,26 @@ +from django.conf import settings +from storages.backends.s3boto3 import S3Boto3Storage + + +class StaticStorage(S3Boto3Storage): + """Used to manage static files for the web server""" + location = settings.STATIC_LOCATION + default_acl = settings.STATIC_DEFAULT_ACL + + +class PublicMediaStorage(S3Boto3Storage): + """Used to store & serve dynamic media files with no access expiration""" + location = settings.PUBLIC_MEDIA_LOCATION + default_acl = settings.PUBLIC_MEDIA_DEFAULT_ACL + file_overwrite = False + + +class PrivateMediaStorage(S3Boto3Storage): + """ + Used to store & serve dynamic media files using access keys + and short-lived expirations to ensure more privacy control + """ + location = settings.PRIVATE_MEDIA_LOCATION + default_acl = settings.PRIVATE_MEDIA_DEFAULT_ACL + file_overwrite = False + custom_domain = False diff --git a/poetry.lock b/poetry.lock index 6d9d46f..e1cdabe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -9,6 +9,38 @@ python-versions = ">=3.6" [package.extras] tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +[[package]] +name = "boto3" +version = "1.20.34" +description = "The AWS SDK for Python" +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.23.34,<1.24.0" +jmespath = ">=0.7.1,<1.0.0" +s3transfer = ">=0.5.0,<0.6.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.23.34" +description = "Low-level, data-driven core of boto 3." +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +jmespath = ">=0.7.1,<1.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<1.27" + +[package.extras] +crt = ["awscrt (==0.12.5)"] + [[package]] name = "dj-database-url" version = "0.5.0" @@ -48,6 +80,25 @@ django = "*" psycopg2 = "*" whitenoise = "*" +[[package]] +name = "django-storages" +version = "1.12.3" +description = "Support for many storage backends in Django" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +Django = ">=2.2" + +[package.extras] +azure = ["azure-storage-blob (>=12.0.0)"] +boto3 = ["boto3 (>=1.4.4)"] +dropbox = ["dropbox (>=7.2.1)"] +google = ["google-cloud-storage (>=1.27.0)"] +libcloud = ["apache-libcloud"] +sftp = ["paramiko"] + [[package]] name = "gunicorn" version = "20.1.0" @@ -62,6 +113,14 @@ gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "pillow" version = "9.0.0" @@ -78,6 +137,47 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-decouple" +version = "3.5" +description = "Strict separation of settings from code." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "s3transfer" +version = "0.5.0" +description = "An Amazon S3 Transfer Manager" +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "sqlparse" version = "0.4.2" @@ -94,6 +194,19 @@ category = "main" optional = false python-versions = ">=2" +[[package]] +name = "urllib3" +version = "1.26.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "whitenoise" version = "5.3.0" @@ -108,13 +221,21 @@ brotli = ["brotli"] [metadata] lock-version = "1.1" python-versions = "3.9.8" -content-hash = "52814a0d71944c1cf9e62fc992954f783e3dbde22c93e105c7422f208879f486" +content-hash = "75bbdf5d50cb8e195641fc956cc6ad20553ba3a08558c72aeb1e19bec471671e" [metadata.files] asgiref = [ {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, ] +boto3 = [ + {file = "boto3-1.20.34-py3-none-any.whl", hash = "sha256:79ef3ad894ba37233dae72f1a1fc740408a4b4bd025a02c8fb7bbb3a0a8b65bb"}, + {file = "boto3-1.20.34.tar.gz", hash = "sha256:87024f4e62ca7fd9231b3ed21772b168dd4e26f1afc5b2b663908987b5e30229"}, +] +botocore = [ + {file = "botocore-1.23.34-py3-none-any.whl", hash = "sha256:5c3ba03c4ac7e48906db63a7cad761c6ca505cc7174a18f179bbf8f8708d5a08"}, + {file = "botocore-1.23.34.tar.gz", hash = "sha256:edd352ac409272c1fc1bbc6518891753a398e69f9eb861d26c319b500f018959"}, +] dj-database-url = [ {file = "dj-database-url-0.5.0.tar.gz", hash = "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163"}, {file = "dj_database_url-0.5.0-py2.py3-none-any.whl", hash = "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9"}, @@ -127,10 +248,18 @@ django-heroku = [ {file = "django-heroku-0.3.1.tar.gz", hash = "sha256:6af4bc3ae4a9b55eaad6dbe5164918982d2762661aebc9f83d9fa49f6009514e"}, {file = "django_heroku-0.3.1-py2.py3-none-any.whl", hash = "sha256:2bc690aab89eedbe01311752320a9a12e7548e3b0ed102681acc5736a41a4762"}, ] +django-storages = [ + {file = "django-storages-1.12.3.tar.gz", hash = "sha256:a475edb2f0f04c4f7e548919a751ecd50117270833956ed5bd585c0575d2a5e7"}, + {file = "django_storages-1.12.3-py3-none-any.whl", hash = "sha256:204a99f218b747c46edbfeeb1310d357f83f90fa6a6024d8d0a3f422570cee84"}, +] gunicorn = [ {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, ] +jmespath = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] pillow = [ {file = "Pillow-9.0.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:113723312215b25c22df1fdf0e2da7a3b9c357a7d24a93ebbe80bfda4f37a8d4"}, {file = "Pillow-9.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb47a548cea95b86494a26c89d153fd31122ed65255db5dcbc421a2d28eb3379"}, @@ -178,6 +307,22 @@ psycopg2 = [ {file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"}, {file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-decouple = [ + {file = "python-decouple-3.5.tar.gz", hash = "sha256:68e4b3fcc97e24bc90eecc514852d0bf970f4ff031f5f7a6728ddafa9afefcaf"}, + {file = "python_decouple-3.5-py3-none-any.whl", hash = "sha256:011d3f785367c54a72cf8a07d3a7a48bb8cc1a0f8e6c70353ca5767ebf7c8c9d"}, +] +s3transfer = [ + {file = "s3transfer-0.5.0-py3-none-any.whl", hash = "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"}, + {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, @@ -186,6 +331,10 @@ tzdata = [ {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"}, {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"}, ] +urllib3 = [ + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, +] whitenoise = [ {file = "whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"}, {file = "whitenoise-5.3.0.tar.gz", hash = "sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12"}, diff --git a/pyproject.toml b/pyproject.toml index c2c335f..db6be20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ Django = "^4.0.1" gunicorn = "^20.1.0" Pillow = "^9.0.0" django-heroku = "^0.3.1" +django-storages = "^1.12.3" +boto3 = "^1.20.34" +python-decouple = "^3.5" [tool.poetry.dev-dependencies]