├── .github └── workflows │ ├── django-tests.yml │ ├── publish-to-pypi.yml │ └── publish-to-test-pypi.yml ├── .gitignore ├── CONTRIBUTE.md ├── DjangoExampleApplication ├── __init__.py ├── admin.py ├── apps.py ├── assets │ ├── COPYRIGHT │ └── audience-868074_1920.jpg ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20210313_1049.py │ ├── 0003_genericattachment.py │ └── __init__.py ├── models.py └── tests.py ├── DjangoExampleProject ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.Docker.md ├── README.md ├── django_minio_backend ├── __init__.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── initialize_buckets.py │ │ └── is_minio_available.py ├── models.py ├── tests.py └── utils.py ├── docker-compose.develop.yml ├── docker-compose.yml ├── examples └── policy_hook.example.py ├── manage.py ├── nginx.conf ├── requirements.txt ├── setup.py └── version.py /.github/workflows/django-tests.yml: -------------------------------------------------------------------------------- 1 | name: Django Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | job-run-django-app-tests: 10 | name: Deploy DjangoExampleProject and run its integrated tests 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Checkout the repository 14 | - uses: actions/checkout@v2 15 | # Start the minIO container 16 | - name: Start the minIO container 17 | run: docker run --name miniotest -p 9000:9000 -d minio/minio server /data 18 | # Setup Python 19 | - name: Set up Python 3.12 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: 3.12 23 | # Install Dependencies 24 | - name: Install pypa/build 25 | run: >- 26 | python -m 27 | pip install 28 | -r 29 | requirements.txt 30 | # Setup Django 31 | - name: Deploy DjangoExampleProject 32 | run: python manage.py migrate 33 | # Run Django Tests 34 | - name: Run Django unit tests 35 | run: python manage.py test 36 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI Publish 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'develop' 7 | - 'feature/*' 8 | tags: 9 | - '**' 10 | 11 | jobs: 12 | build-n-publish: 13 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 14 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - uses: actions/checkout@master 18 | # Setup Python 19 | - name: Set up Python 3.12 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: 3.12 23 | # Install Dependencies 24 | - name: Install pypa/build 25 | run: >- 26 | python -m 27 | pip install 28 | build wheel setuptools 29 | --user 30 | # Build Package 31 | - name: Build a binary wheel and a source tarball 32 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 33 | run: >- 34 | python 35 | setup.py 36 | bdist_wheel 37 | sdist 38 | # Publish 39 | - name: Publish distribution 📦 to PyPI 40 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | with: 43 | user: __token__ 44 | password: ${{ secrets.PYPI_DJANGO_MINIO_BACKEND }} 45 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: publish-py-dist-to-test-pypi 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '**' 9 | 10 | jobs: 11 | build-n-publish: 12 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 13 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: actions/checkout@master 17 | # Setup Python 18 | - name: Set up Python 3.12 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: 3.12 22 | # Install Dependencies 23 | - name: Install pypa/build 24 | run: >- 25 | python -m 26 | pip install 27 | build wheel setuptools 28 | --user 29 | # Build Package 30 | - name: Build a binary wheel and a source tarball 31 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 32 | run: >- 33 | python 34 | setup.py 35 | bdist_wheel 36 | sdist 37 | # Publish 38 | - name: Publish distribution 📦 to Test PyPI 39 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | user: __token__ 43 | password: ${{ secrets.PYPI_TEST }} 44 | repository_url: https://test.pypi.org/legacy/ 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | RELEASE-VERSION 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | example_app/db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | # PyCharm 109 | .idea 110 | 111 | # macOS 112 | .DS_Store 113 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | Contributing to Django Minio Backend 2 | ------------------------------ 3 | 4 | You can find a reference implementation of a Django app using **django-minio-backend** as a storage backend in 5 | [DjangoExampleApplication/models.py](DjangoExampleApplication/models.py). 6 | 7 | When you're finished with your changes, please open a pull request! 8 | 9 | # Development Environment 10 | Execute the following steps to prepare your development environment: 11 | 1. Clone the library: 12 | ```bash 13 | git clone https://github.com/theriverman/django-minio-backend.git 14 | cd django-minio-backend 15 | ``` 16 | 1. Create a virtual environment and activate it: 17 | ```bash 18 | python3 -m venv .venv 19 | source .venv/bin/activate 20 | ``` 21 | 1. Install Python Dependencies: 22 | ```bash 23 | pip install -r requirements.txt 24 | ``` 25 | 1. Execute Django Migrations: 26 | ```bash 27 | python manage.py migrate 28 | ``` 29 | 1. Create Admin Account (optional): 30 | ```bash 31 | python manage.py createsuperuser 32 | ``` 33 | 1. Run the Project: 34 | ```bash 35 | python manage.py runserver 36 | ``` 37 | 38 | # Testing 39 | You can run tests by executing the following command (in the repository root): 40 | ```bash 41 | python manage.py test 42 | ``` 43 | 44 | **Note:** Tests are quite poor at the moment. 45 | -------------------------------------------------------------------------------- /DjangoExampleApplication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/__init__.py -------------------------------------------------------------------------------- /DjangoExampleApplication/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from django.db.models.query import QuerySet 3 | from django.contrib import admin 4 | from django.core.handlers.wsgi import WSGIRequest 5 | from .models import PublicAttachment, PrivateAttachment, Image, GenericAttachment 6 | 7 | 8 | # https://docs.djangoproject.com/en/2.2/ref/contrib/admin/actions/#writing-action-functions 9 | def delete_everywhere(model_admin: Union[PublicAttachment, PrivateAttachment], 10 | request: WSGIRequest, 11 | queryset: QuerySet): 12 | """ 13 | Delete object both in Django and in MinIO too. 14 | :param model_admin: unused 15 | :param request: unused 16 | :param queryset: A QuerySet containing the set of objects selected by the user 17 | :return: 18 | """ 19 | del model_admin, request # We don't need these 20 | for obj in queryset: 21 | obj.delete() 22 | 23 | 24 | delete_everywhere.short_description = "Delete selected objects in Django and MinIO" 25 | 26 | 27 | @admin.register(Image) 28 | class ImageAdmin(admin.ModelAdmin): 29 | list_display = ('id', 'image',) 30 | readonly_fields = ('id', ) 31 | model = Image 32 | actions = [delete_everywhere, ] 33 | 34 | 35 | @admin.register(GenericAttachment) 36 | class GenericAttachmentAdmin(admin.ModelAdmin): 37 | list_display = ('id', 'file',) 38 | readonly_fields = ('id', ) 39 | model = GenericAttachment 40 | actions = [delete_everywhere, ] 41 | 42 | 43 | # Register your models here. 44 | @admin.register(PublicAttachment) 45 | class PublicAttachmentAdmin(admin.ModelAdmin): 46 | list_display = ('id', 'content_type',) 47 | readonly_fields = ('id', 'content_object', 'file_name', 'file_size', ) 48 | model = PublicAttachment 49 | actions = [delete_everywhere, ] 50 | 51 | fieldsets = [ 52 | 53 | ('General Information', 54 | {'fields': ('id',)}), 55 | ('S3 Object', 56 | {'fields': ('file_name', 'file_size', 'file',)}), 57 | ('S3 Object Details', 58 | {'fields': ('content_object', 'content_type', 'object_id',)}), 59 | ] 60 | 61 | def get_actions(self, request): 62 | actions = super().get_actions(request) 63 | if 'delete_selected' in actions: 64 | del actions['delete_selected'] 65 | return actions 66 | 67 | 68 | @admin.register(PrivateAttachment) 69 | class PrivateAttachmentAdmin(admin.ModelAdmin): 70 | list_display = ('id', 'content_type',) 71 | readonly_fields = ('id', 'content_object', 'file_name', 'file_size') 72 | model = PrivateAttachment 73 | actions = [delete_everywhere, ] 74 | 75 | fieldsets = [ 76 | 77 | ('General Information', 78 | {'fields': ('id',)}), 79 | ('S3 Object', 80 | {'fields': ('file_name', 'file_size', 'file',)}), 81 | ('S3 Object Details', 82 | {'fields': ('content_object', 'content_type', 'object_id',)}), 83 | ] 84 | 85 | def get_actions(self, request): 86 | actions = super().get_actions(request) 87 | if 'delete_selected' in actions: 88 | del actions['delete_selected'] 89 | return actions 90 | -------------------------------------------------------------------------------- /DjangoExampleApplication/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoExampleApplicationConfig(AppConfig): 5 | name = 'DjangoExampleApplication' 6 | -------------------------------------------------------------------------------- /DjangoExampleApplication/assets/COPYRIGHT: -------------------------------------------------------------------------------- 1 | https://pixabay.com/photos/audience-concert-music-868074/ 2 | 3 | Simplified Pixabay License 4 | https://pixabay.com/service/license/ 5 | 6 | Free for commercial use 7 | No attribution required 8 | -------------------------------------------------------------------------------- /DjangoExampleApplication/assets/audience-868074_1920.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/assets/audience-868074_1920.jpg -------------------------------------------------------------------------------- /DjangoExampleApplication/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-15 20:23 2 | 3 | import DjangoExampleApplication.models 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django_minio_backend.models 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('contenttypes', '0002_remove_content_type_name'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Image', 21 | fields=[ 22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 23 | ('image', models.ImageField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='PublicAttachment', 28 | fields=[ 29 | ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')), 30 | ('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")), 31 | ('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix, verbose_name='Object Upload')), 32 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='PrivateAttachment', 37 | fields=[ 38 | ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')), 39 | ('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")), 40 | ('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-private'), upload_to=DjangoExampleApplication.models.PrivateAttachment.set_file_path_name, verbose_name='Object Upload')), 41 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /DjangoExampleApplication/migrations/0002_auto_20210313_1049.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-13 10:49 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ('DjangoExampleApplication', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='privateattachment', 17 | name='content_type', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'), 19 | ), 20 | migrations.AlterField( 21 | model_name='privateattachment', 22 | name='object_id', 23 | field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"), 24 | ), 25 | migrations.AlterField( 26 | model_name='publicattachment', 27 | name='content_type', 28 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'), 29 | ), 30 | migrations.AlterField( 31 | model_name='publicattachment', 32 | name='object_id', 33 | field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /DjangoExampleApplication/migrations/0003_genericattachment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-07-18 22:07 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('DjangoExampleApplication', '0002_auto_20210313_1049'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='GenericAttachment', 16 | fields=[ 17 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 18 | ('file', models.FileField(upload_to='', verbose_name='Object Upload (to default storage)')), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /DjangoExampleApplication/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/migrations/__init__.py -------------------------------------------------------------------------------- /DjangoExampleApplication/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import datetime 3 | from django.db import models 4 | from django.db.models.fields.files import FieldFile 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.contrib.contenttypes.fields import GenericForeignKey 7 | from django_minio_backend import MinioBackend, iso_date_prefix 8 | 9 | 10 | def get_iso_date() -> str: 11 | """Get current date in ISO8601 format [year-month-day] as string""" 12 | now = datetime.datetime.now(datetime.UTC) 13 | return f"{now.year}-{now.month}-{now.day}" 14 | 15 | 16 | class Image(models.Model): 17 | """ 18 | This is just for uploaded image 19 | """ 20 | objects = models.Manager() 21 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 22 | image = models.ImageField(upload_to=iso_date_prefix, storage=MinioBackend(bucket_name='django-backend-dev-public')) 23 | 24 | def delete(self, *args, **kwargs): 25 | """ 26 | Delete must be overridden because the inherited delete method does not call `self.image.delete()`. 27 | """ 28 | # noinspection PyUnresolvedReferences 29 | self.image.delete() 30 | super(Image, self).delete(*args, **kwargs) 31 | 32 | 33 | class GenericAttachment(models.Model): 34 | """ 35 | This is for demonstrating uploads to the default file storage 36 | """ 37 | objects = models.Manager() 38 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 39 | file = models.FileField(verbose_name="Object Upload (to default storage)") 40 | 41 | def delete(self, *args, **kwargs): 42 | """ 43 | Delete must be overridden because the inherited delete method does not call `self.image.delete()`. 44 | """ 45 | # noinspection PyUnresolvedReferences 46 | self.file.delete() 47 | super(GenericAttachment, self).delete(*args, **kwargs) 48 | 49 | 50 | # Create your models here. 51 | class PublicAttachment(models.Model): 52 | def set_file_path_name(self, file_name_ext: str) -> str: 53 | """ 54 | Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder. 55 | :param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png 56 | :return: (str) Absolute path to file in Minio Bucket 57 | """ 58 | return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}" 59 | 60 | def delete(self, *args, **kwargs): 61 | """ 62 | Delete must be overridden because the inherited delete method does not call `self.file.delete()`. 63 | """ 64 | self.file.delete() 65 | super(PublicAttachment, self).delete(*args, **kwargs) 66 | 67 | @property 68 | def file_name(self): 69 | try: 70 | return self.file.name.split("/")[-1] 71 | except AttributeError: 72 | return "[Deleted Object]" 73 | 74 | @property 75 | def file_size(self): 76 | return self.file.size 77 | 78 | def __str__(self): 79 | return str(self.file) 80 | 81 | id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID") 82 | content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE, 83 | verbose_name="Content Type") 84 | object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID") 85 | content_object = GenericForeignKey("content_type", "object_id") 86 | 87 | file: FieldFile = models.FileField(verbose_name="Object Upload", 88 | storage=MinioBackend( # Configure MinioBackend as storage backend here 89 | bucket_name='django-backend-dev-public', 90 | ), 91 | upload_to=iso_date_prefix) 92 | 93 | 94 | class PrivateAttachment(models.Model): 95 | def set_file_path_name(self, file_name_ext: str) -> str: 96 | """ 97 | Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder. 98 | :param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png 99 | :return: (str) Absolute path to file in Minio Bucket 100 | """ 101 | return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}" 102 | 103 | def delete(self, *args, **kwargs): 104 | """ 105 | Delete must be overridden because the inherited delete method does not call `self.file.delete()`. 106 | """ 107 | self.file.delete() 108 | super(PrivateAttachment, self).delete(*args, **kwargs) 109 | 110 | @property 111 | def file_name(self): 112 | try: 113 | return self.file.name.split("/")[-1] 114 | except AttributeError: 115 | return "[Deleted Object]" 116 | 117 | @property 118 | def file_size(self): 119 | return self.file.size 120 | 121 | def __str__(self): 122 | return str(self.file) 123 | 124 | id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID") 125 | content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE, 126 | verbose_name="Content Type") 127 | object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID") 128 | content_object = GenericForeignKey("content_type", "object_id") 129 | 130 | file: FieldFile = models.FileField(verbose_name="Object Upload", 131 | storage=MinioBackend( # Configure MinioBackend as storage backend here 132 | bucket_name='django-backend-dev-private', 133 | ), 134 | upload_to=set_file_path_name) 135 | -------------------------------------------------------------------------------- /DjangoExampleApplication/tests.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from django.conf import settings 4 | from django.core.files import File 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.test import TestCase 7 | from django.core.validators import URLValidator 8 | 9 | from DjangoExampleApplication.models import Image, PublicAttachment, PrivateAttachment 10 | 11 | 12 | test_file_path = Path(settings.BASE_DIR) / "DjangoExampleApplication" / "assets" / "audience-868074_1920.jpg" 13 | test_file_size = 339085 14 | 15 | 16 | class ImageTestCase(TestCase): 17 | obj: Image = None 18 | 19 | def setUp(self): 20 | # Open a test file from disk and upload to minIO as an image 21 | with open(test_file_path, 'rb') as f: 22 | self.obj = Image.objects.create() 23 | self.obj.image.save(name='audience-868074_1920.jpg', content=f) 24 | 25 | def tearDown(self): 26 | # Remove uploaded file from minIO and remove the Image entry from Django's database 27 | self.obj.delete() # deletes from both locations 28 | 29 | def test_url_generation_works(self): 30 | """Accessing the value of obj.image.url""" 31 | val = URLValidator() 32 | val(self.obj.image.url) # 1st make sure it's a URL 33 | self.assertTrue('audience-868074_1920' in self.obj.image.url) # 2nd make sure our filename matches 34 | 35 | def test_read_image_size(self): 36 | self.assertEqual(self.obj.image.size, test_file_size) 37 | 38 | 39 | class PublicAttachmentTestCase(TestCase): 40 | obj: PublicAttachment = None 41 | filename = f'public_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique 42 | 43 | def setUp(self): 44 | ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed 45 | with open(test_file_path, 'rb') as f: 46 | # noinspection PyUnresolvedReferences 47 | self.obj = PublicAttachment.objects.create() 48 | self.obj.ct = ct 49 | self.obj.object_id = 1 # we associate this uploaded file to user with pk=1 50 | self.obj.file.save(name=self.filename, content=File(f), save=True) 51 | 52 | def test_url_generation_works(self): 53 | """Accessing the value of obj.file.url""" 54 | val = URLValidator() 55 | val(self.obj.file.url) # 1st make sure it's a URL 56 | self.assertTrue('public_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches 57 | 58 | def test_read_file_size(self): 59 | self.assertEqual(self.obj.file_size, test_file_size) 60 | 61 | def test_read_file_name(self): 62 | self.assertEqual(self.obj.file_name, self.filename) 63 | 64 | 65 | class PrivateAttachmentTestCase(TestCase): 66 | obj: PrivateAttachment = None 67 | filename = f'private_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique 68 | 69 | def setUp(self): 70 | ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed 71 | with open(test_file_path, 'rb') as f: 72 | # noinspection PyUnresolvedReferences 73 | self.obj = PublicAttachment.objects.create() 74 | self.obj.ct = ct 75 | self.obj.object_id = 1 # we associate this uploaded file to user with pk=1 76 | self.obj.file.save(name=self.filename, content=File(f), save=True) 77 | 78 | def test_url_generation_works(self): 79 | """Accessing the value of obj.file.url""" 80 | val = URLValidator() 81 | val(self.obj.file.url) # 1st make sure it's a URL 82 | self.assertTrue('private_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches 83 | 84 | def test_read_file_size(self): 85 | self.assertEqual(self.obj.file_size, test_file_size) 86 | 87 | def test_read_file_name(self): 88 | self.assertEqual(self.obj.file_name, self.filename) 89 | -------------------------------------------------------------------------------- /DjangoExampleProject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleProject/__init__.py -------------------------------------------------------------------------------- /DjangoExampleProject/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for DjangoExampleProject project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /DjangoExampleProject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for DjangoExampleProject project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | import distutils.util 15 | from datetime import timedelta 16 | from typing import List, Tuple 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = 'sp1d7_7z9))q58(6k&1)9m_@!8e420*m+3dasq-*711fu8)y!6' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'django_minio_backend.apps.DjangoMinioBackendConfig', # Driver 42 | 'DjangoExampleApplication', # Test App 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'DjangoExampleProject.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'DjangoExampleProject.wsgi.application' 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 105 | 106 | LANGUAGE_CODE = 'en-us' 107 | 108 | TIME_ZONE = 'UTC' 109 | 110 | USE_I18N = True 111 | 112 | USE_L10N = True 113 | 114 | USE_TZ = True 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 118 | STATIC_URL = '/static/' 119 | 120 | # #################### # 121 | # django_minio_backend # 122 | # #################### # 123 | 124 | dummy_policy = {"Version": "2012-10-17", 125 | "Statement": [ 126 | { 127 | "Sid": "", 128 | "Effect": "Allow", 129 | "Principal": {"AWS": "*"}, 130 | "Action": "s3:GetBucketLocation", 131 | "Resource": f"arn:aws:s3:::django-backend-dev-private" 132 | }, 133 | { 134 | "Sid": "", 135 | "Effect": "Allow", 136 | "Principal": {"AWS": "*"}, 137 | "Action": "s3:ListBucket", 138 | "Resource": f"arn:aws:s3:::django-backend-dev-private" 139 | }, 140 | { 141 | "Sid": "", 142 | "Effect": "Allow", 143 | "Principal": {"AWS": "*"}, 144 | "Action": "s3:GetObject", 145 | "Resource": f"arn:aws:s3:::django-backend-dev-private/*" 146 | } 147 | ]} 148 | 149 | MINIO_ENDPOINT = os.getenv("GH_MINIO_ENDPOINT", "play.min.io") 150 | MINIO_EXTERNAL_ENDPOINT = os.getenv("GH_MINIO_EXTERNAL_ENDPOINT", "externalplay.min.io") 151 | MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", "true"))) 152 | MINIO_ACCESS_KEY = os.getenv("GH_MINIO_ACCESS_KEY", "Q3AM3UQ867SPQQA43P2F") 153 | MINIO_SECRET_KEY = os.getenv("GH_MINIO_SECRET_KEY", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG") 154 | MINIO_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_USE_HTTPS", "true"))) 155 | MINIO_REGION = os.getenv("GH_MINIO_REGION", "us-east-1") 156 | MINIO_PRIVATE_BUCKETS = [ 157 | 'django-backend-dev-private', 158 | 'my-media-files-bucket', 159 | ] 160 | MINIO_PUBLIC_BUCKETS = [ 161 | 'django-backend-dev-public', 162 | 't5p2g08k31', 163 | '7xi7lx9rjh', 164 | 'my-static-files-bucket', 165 | ] 166 | MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined 167 | MINIO_CONSISTENCY_CHECK_ON_START = True 168 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [ 169 | # ('django-backend-dev-private', dummy_policy) 170 | ] 171 | MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for STATIC_ROOT 172 | MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for MEDIA_ROOT 173 | MINIO_BUCKET_CHECK_ON_SAVE = False # Create bucket if missing, then save 174 | 175 | STORAGES = { # -- ADDED IN Django 5.1 176 | "default": { 177 | "BACKEND": "django_minio_backend.models.MinioBackend", 178 | }, 179 | "staticfiles": { 180 | "BACKEND": "django_minio_backend.models.MinioBackendStatic", 181 | }, 182 | } 183 | -------------------------------------------------------------------------------- /DjangoExampleProject/urls.py: -------------------------------------------------------------------------------- 1 | """DjangoExampleProject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /DjangoExampleProject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for DjangoExampleProject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3 3 | ENV PYTHONUNBUFFERED=1 4 | WORKDIR /code 5 | 6 | # Copy Demo Project 7 | COPY ./manage.py /code/manage.py 8 | COPY ./django_minio_backend /code/django_minio_backend 9 | COPY ./DjangoExampleProject /code/DjangoExampleProject 10 | COPY ./DjangoExampleApplication /code/DjangoExampleApplication 11 | 12 | # Copy and install requirements.txt 13 | COPY requirements.txt /code/ 14 | RUN pip install -r /code/requirements.txt 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2025 github.com/theriverman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README*.md 3 | include RELEASE-VERSION 4 | include version.py 5 | # recursive-include docs * 6 | recursive-include django_minio_backend/management * 7 | -------------------------------------------------------------------------------- /README.Docker.md: -------------------------------------------------------------------------------- 1 | # Docker Compose Description for django-minio-backend 2 | Execute the following step to start a demo environment using Docker Compose: 3 | 4 | **Start the Docker Compose services:** 5 | ```shell 6 | docker compose up -d 7 | docker compose exec web python manage.py createsuperuser --noinput 8 | docker compose exec web python manage.py collectstatic --noinput 9 | ``` 10 | 11 | ## About docker-compose.yml 12 | Note the following lines in `docker-compose.yml`: 13 | ```yaml 14 | environment: 15 | GH_MINIO_ENDPOINT: "nginx:9000" 16 | GH_MINIO_USE_HTTPS: "false" 17 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000" 18 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false" 19 | ``` 20 | 21 | MinIO is load balanced by nginx, so all connections made from Django towards MinIO happens through the internal `nginx` FQDN.
22 | Therefore, the value of `GH_MINIO_ENDPOINT` is `nginx:9000`. 23 | 24 | # Web Access 25 | Both Django(:8000) and MinIO(:9001) expose a Web GUI and their ports are mapped to the host machine. 26 | 27 | ## Django Admin 28 | Open your browser at http://localhost:8000/admin to access the Django admin portal: 29 | * username: `admin` 30 | * password: `123123` 31 | 32 | ## MinIO Console 33 | Open your browser at http://localhost:9001 to access the MiniIO Console: 34 | * username: `minio` 35 | * password: `minio123` 36 | 37 | # Developer Environment 38 | An alternative docker-compose file is available for **django-minio-backend** which does not copy the source files into the container, but maps them as a volume. 39 | **Input file**: `docker-compose.develop.yml` 40 | 41 | If you would like to develop in a Docker Compose environment, execute the following commands: 42 | ```shell 43 | docker compose -f docker-compose.develop.yml up -d 44 | docker compose -f docker-compose.develop.yml exec web python manage.py createsuperuser --noinput 45 | docker compose -f docker-compose.develop.yml exec web python manage.py collectstatic --noinput 46 | ``` 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![django-app-tests](https://github.com/theriverman/django-minio-backend/actions/workflows/django-tests.yml/badge.svg)](https://github.com/theriverman/django-minio-backend/actions/workflows/django-tests.yml) 2 | [![publish-py-dist-to-pypi](https://github.com/theriverman/django-minio-backend/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/theriverman/django-minio-backend/actions/workflows/publish-to-pypi.yml) 3 | [![PYPI](https://img.shields.io/pypi/v/django-minio-backend.svg)](https://pypi.python.org/pypi/django-minio-backend) 4 | 5 | # django-minio-backend 6 | The **django-minio-backend** provides a wrapper around the 7 | [MinIO Python SDK](https://docs.min.io/docs/python-client-quickstart-guide.html). 8 | See [minio/minio-py](https://github.com/minio/minio-py) for the source. 9 | 10 | ## Requirements & Compatibility 11 | * Django 4.2 or later 12 | * Python 3.11.0 or later 13 | * MinIO SDK 7.2.8 or later (installed automatically) 14 | 15 | ## What's in the box? 16 | The following set of features are available in **django-minio-backend**: 17 | * Django File Storage System Integration 18 | * Compliance with the `django.core.files.storage.Storage` class 19 | * Static Files Support 20 | * Utilise/manage private and public buckets 21 | * Create buckets with custom policy hooks (`MINIO_POLICY_HOOKS`) 22 | * Consistency Check on Start (`MINIO_CONSISTENCY_CHECK_ON_START`) 23 | * Bucket Check on Upload (`MINIO_BUCKET_CHECK_ON_SAVE`) 24 | * Health Check (`MinioBackend.is_minio_available()`) 25 | * Docker Networking Support 26 | * Management Commands: 27 | * initialize_buckets 28 | * is_minio_available 29 | 30 | ## Integration 31 | 1. Get and install the package: 32 | ```bash 33 | pip install django-minio-backend 34 | ``` 35 | 36 | 2. Add `django_minio_backend` to `INSTALLED_APPS`: 37 | ```python 38 | INSTALLED_APPS = [ 39 | # '...' 40 | 'django_minio_backend', # https://github.com/theriverman/django-minio-backend 41 | ] 42 | ``` 43 | 44 | If you would like to enable on-start consistency check, install via `DjangoMinioBackendConfig`: 45 | ```python 46 | INSTALLED_APPS = [ 47 | # '...' 48 | 'django_minio_backend.apps.DjangoMinioBackendConfig', # https://github.com/theriverman/django-minio-backend 49 | ] 50 | ``` 51 | 52 | Then add the following parameter to your settings file: 53 | ```python 54 | MINIO_CONSISTENCY_CHECK_ON_START = True 55 | ``` 56 | 57 | **Note:** The on-start consistency check equals to manually calling `python manage.py initialize_buckets`.
58 | It is recommended to turn *off* this feature during development by setting `MINIO_CONSISTENCY_CHECK_ON_START` to `False`, 59 | because this operation can noticeably slow down Django's boot time when many buckets are configured. 60 | 61 | 3. Add the following parameters to your `settings.py`: 62 | ```python 63 | from datetime import timedelta 64 | from typing import List, Tuple 65 | 66 | STORAGES = { # -- ADDED IN Django 5.1 67 | "default": { 68 | "BACKEND": "django_minio_backend.models.MinioBackend", 69 | }, 70 | # "staticfiles": { # -- OPTIONAL 71 | # "BACKEND": "django_minio_backend.models.MinioBackendStatic", 72 | # }, 73 | } 74 | 75 | MINIO_ENDPOINT = 'minio.your-company.co.uk' 76 | MINIO_EXTERNAL_ENDPOINT = "external-minio.your-company.co.uk" # Default is same as MINIO_ENDPOINT 77 | MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = True # Default is same as MINIO_USE_HTTPS 78 | MINIO_REGION = 'us-east-1' # Default is set to None 79 | MINIO_ACCESS_KEY = 'yourMinioAccessKey' 80 | MINIO_SECRET_KEY = 'yourVeryS3cr3tP4ssw0rd' 81 | MINIO_USE_HTTPS = True 82 | MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined 83 | MINIO_CONSISTENCY_CHECK_ON_START = True 84 | MINIO_PRIVATE_BUCKETS = [ 85 | 'django-backend-dev-private', 86 | ] 87 | MINIO_PUBLIC_BUCKETS = [ 88 | 'django-backend-dev-public', 89 | ] 90 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [] 91 | # MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT 92 | # MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT 93 | MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save 94 | 95 | # Custom HTTP Client (OPTIONAL) 96 | import os 97 | import certifi 98 | import urllib3 99 | timeout = timedelta(minutes=5).seconds 100 | ca_certs = os.environ.get('SSL_CERT_FILE') or certifi.where() 101 | MINIO_HTTP_CLIENT: urllib3.poolmanager.PoolManager = urllib3.PoolManager( 102 | timeout=urllib3.util.Timeout(connect=timeout, read=timeout), 103 | maxsize=10, 104 | cert_reqs='CERT_REQUIRED', 105 | ca_certs=ca_certs, 106 | retries=urllib3.Retry( 107 | total=5, 108 | backoff_factor=0.2, 109 | status_forcelist=[500, 502, 503, 504] 110 | ) 111 | ) 112 | ``` 113 | 114 | 4. Implement your own Attachment handler and integrate **django-minio-backend**: 115 | ```python 116 | from django.db import models 117 | from django_minio_backend import MinioBackend, iso_date_prefix 118 | 119 | class PrivateAttachment(models.Model): 120 | file = models.FileField(verbose_name="Object Upload", 121 | storage=MinioBackend(bucket_name='django-backend-dev-private'), 122 | upload_to=iso_date_prefix) 123 | ``` 124 | 125 | 5. Initialize the buckets & set their public policy (OPTIONAL):
126 | This `django-admin` command creates both the private and public buckets in case one of them does not exist, 127 | and sets the *public* bucket's privacy policy from `private`(default) to `public`.
128 | ```bash 129 | python manage.py initialize_buckets 130 | ``` 131 | 132 | Code reference: [initialize_buckets.py](django_minio_backend/management/commands/initialize_buckets.py). 133 | 134 | ### Static Files Support 135 | **django-minio-backend** allows serving static files from MinIO. 136 | To learn more about Django static files, see [Managing static files](https://docs.djangoproject.com/en/5.1/howto/static-files/), [STATICFILES_STORAGE](https://docs.djangoproject.com/en/5.1/ref/settings/#static-files) and [STORAGES](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STORAGES). 137 | 138 | To enable static files support, update your `settings.py`: 139 | ```python 140 | STORAGES = { # -- ADDED IN Django 5.1 141 | "default": { 142 | "BACKEND": "django_minio_backend.models.MinioBackend", 143 | }, 144 | "staticfiles": { # -- ADD THESE LINES FOR STATIC FILES SUPPORT 145 | "BACKEND": "django_minio_backend.models.MinioBackendStatic", 146 | }, 147 | } 148 | MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT 149 | # Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. e.g.: 150 | # MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) 151 | # MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) 152 | ``` 153 | 154 | The value of `STATIC_URL` is ignored, but it must be defined otherwise Django will throw an error. 155 | 156 | **IMPORTANT**
157 | The value set in `MINIO_STATIC_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`, 158 | otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private. 159 | 160 | **Note:** If `MINIO_STATIC_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-static-files`) will be used. Policy setting for default buckets is **private**. 161 | 162 | ### Default File Storage Support 163 | **django-minio-backend** can be configured as a default file storage. 164 | To learn more, see [STORAGES](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STORAGES). 165 | 166 | To configure **django-minio-backend** as the default file storage, update your `settings.py`: 167 | ```python 168 | STORAGES = { # -- ADDED IN Django 5.1 169 | "default": { 170 | "BACKEND": "django_minio_backend.models.MinioBackend", 171 | } 172 | } 173 | MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT 174 | # Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. e.g.: 175 | # MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) 176 | # MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) 177 | ``` 178 | 179 | The value of `MEDIA_URL` is ignored, but it must be defined otherwise Django will throw an error. 180 | 181 | **IMPORTANT**
182 | The value set in `MINIO_MEDIA_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`, 183 | otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private. 184 | 185 | **Note:** If `MINIO_MEDIA_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-media-files`) will be used. Policy setting for default buckets is **private**. 186 | 187 | ### Health Check 188 | To check the connection link between Django and MinIO, use the provided `MinioBackend.is_minio_available()` method.
189 | It returns a `MinioServerStatus` instance which can be quickly evaluated as boolean.
190 | 191 | **Example:** 192 | ```python 193 | from django_minio_backend import MinioBackend 194 | 195 | minio_available = MinioBackend().is_minio_available() # An empty string is fine this time 196 | if minio_available: 197 | print("OK") 198 | else: 199 | print("NOK") 200 | print(minio_available.details) 201 | ``` 202 | 203 | ### Policy Hooks 204 | You can configure **django-minio-backend** to automatically execute a set of pre-defined policy hooks.
205 | Policy hooks can be defined in `settings.py` by adding `MINIO_POLICY_HOOKS` which must be a list of tuples.
206 | Policy hooks are automatically picked up by the `initialize_buckets` management command. 207 | 208 | For an exemplary policy, see the implementation of `set_bucket_to_public()` 209 | in [django_minio_backend/models.py](django_minio_backend/models.py) or the contents 210 | of [examples/policy_hook.example.py](examples/policy_hook.example.py). 211 | 212 | ### Consistency Check On Start 213 | When enabled, the `initialize_buckets` management command gets called automatically when Django starts.
214 | This command connects to the configured MinIO server and checks if all buckets defined in `settings.py`.
215 | In case a bucket is missing or its configuration differs, it gets created and corrected. 216 | 217 | ### Reference Implementation 218 | For a reference implementation, see [Examples](examples). 219 | 220 | ## Behaviour 221 | The following list summarises the key characteristics of **django-minio-backend**: 222 | * Bucket existence is **not** checked on a save by default. 223 | To enable this guard, set `MINIO_BUCKET_CHECK_ON_SAVE = True` in your `settings.py`. 224 | * Bucket existences are **not** checked on Django start by default. 225 | To enable this guard, set `MINIO_CONSISTENCY_CHECK_ON_START = True` in your `settings.py`. 226 | * Many configuration errors are validated through `AppConfig` but not every error can be captured there. 227 | * Files with the same name in the same bucket are **not** replaced on save by default. Django will store the newer file with an altered file name 228 | To allow replacing existing files, pass the `replace_existing=True` kwarg to `MinioBackend`. 229 | For example: 230 | ```python 231 | image = models.ImageField(storage=MinioBackend(bucket_name='images-public', replace_existing=True)) 232 | ``` 233 | * Depending on your configuration, **django-minio-backend** may communicate over two kind of interfaces: internal and external. 234 | If your `settings.py` defines a different value for `MINIO_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT`, then the former will be used for internal communication 235 | between Django and MinIO, and the latter for generating URLs for users. This behaviour optimises the network communication. 236 | See **Networking** below for a thorough explanation 237 | * The uploaded object's content-type is guessed during save. If `mimetypes.guess_type` fails to determine the correct content-type, then it falls back to `application/octet-stream`. 238 | 239 | ## Networking and Docker 240 | If your Django application is running on a shared host with your MinIO instance, you should consider using the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters. 241 | This way most traffic will happen internally between Django and MinIO. The external endpoint parameters are required for external pre-signed URL generation. 242 | 243 | If your Django application and MinIO instance are running on different hosts, you can omit the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters, 244 | and **django-minio-backend** will default to the value of `MINIO_ENDPOINT`. 245 | 246 | Setting up and configuring custom networks in Docker is not in the scope of this document.
247 | To learn more about Docker networking, see [Networking overview](https://docs.docker.com/network/) and [Networking in Compose](https://docs.docker.com/compose/networking/). 248 | 249 | See [README.Docker.md](README.Docker.md) for a real-life Docker Compose demonstration. 250 | 251 | ## Contribution 252 | Please find the details in [CONTRIBUTE.md](CONTRIBUTE.md) 253 | 254 | ## Copyright 255 | * theriverman/django-minio-backend licensed under the MIT License 256 | * minio/minio-py is licensed under the Apache License 2.0 257 | -------------------------------------------------------------------------------- /django_minio_backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .apps import * 2 | from .models import * 3 | -------------------------------------------------------------------------------- /django_minio_backend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from .utils import get_setting, ConfigurationError 3 | from .models import MinioBackend, MinioBackendStatic 4 | 5 | 6 | __all__ = ['DjangoMinioBackendConfig', ] 7 | 8 | 9 | class DjangoMinioBackendConfig(AppConfig): 10 | name = 'django_minio_backend' 11 | 12 | def ready(self): 13 | # Validate configuration for Django 5.1=< projects 14 | if STATICFILES_STORAGE := get_setting('STATICFILES_STORAGE'): 15 | if STATICFILES_STORAGE.endswith(MinioBackendStatic.__name__): 16 | raise ConfigurationError("STATICFILES_STORAGE and DEFAULT_FILE_STORAGE were replaced by STORAGES. " 17 | "See django-minio-backend's README for more information.") 18 | 19 | mb = MinioBackend() 20 | mb.validate_settings() 21 | 22 | consistency_check_on_start = get_setting('MINIO_CONSISTENCY_CHECK_ON_START', False) 23 | if consistency_check_on_start: 24 | from django.core.management import call_command 25 | print("Executing consistency checks...") 26 | call_command('initialize_buckets', silenced=True) 27 | 28 | # Validate configuration combinations for EXTERNAL ENDPOINT 29 | external_address = bool(get_setting('MINIO_EXTERNAL_ENDPOINT')) 30 | external_use_https = get_setting('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS') 31 | if (external_address and external_use_https is None) or (not external_address and external_use_https): 32 | raise ConfigurationError('MINIO_EXTERNAL_ENDPOINT must be configured together with MINIO_EXTERNAL_ENDPOINT_USE_HTTPS') 33 | 34 | # Validate static storage and default storage configurations 35 | storages = get_setting('STORAGES') 36 | staticfiles_backend = storages["staticfiles"]["BACKEND"] 37 | if staticfiles_backend.endswith(MinioBackendStatic.__name__): 38 | mbs = MinioBackendStatic() 39 | mbs.check_bucket_existence() 40 | -------------------------------------------------------------------------------- /django_minio_backend/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/django_minio_backend/management/__init__.py -------------------------------------------------------------------------------- /django_minio_backend/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/django_minio_backend/management/commands/__init__.py -------------------------------------------------------------------------------- /django_minio_backend/management/commands/initialize_buckets.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from django.core.management.base import BaseCommand 3 | from django_minio_backend.models import MinioBackend 4 | from django_minio_backend.utils import get_setting 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Helps initializing Minio buckets by creating them and setting their policies.' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('--silenced', action='store_true', default=False, help='No console messages') 12 | 13 | def handle(self, *args, **options): 14 | silenced = options.get('silenced') 15 | self.stdout.write(f"Initializing Minio buckets...\n") if not silenced else None 16 | private_buckets: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", []) 17 | public_buckets: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", []) 18 | 19 | for bucket in [*public_buckets, *private_buckets]: 20 | m = MinioBackend(bucket) 21 | m.check_bucket_existence() 22 | self.stdout.write(f"Bucket ({bucket}) OK", ending='\n') if not silenced else None 23 | if m.is_bucket_public: # Based on settings.py configuration 24 | m.set_bucket_to_public() 25 | self.stdout.write( 26 | f"Bucket ({m.bucket}) policy has been set to public", ending='\n') if not silenced else None 27 | 28 | c = MinioBackend() # Client 29 | for policy_tuple in get_setting('MINIO_POLICY_HOOKS', []): 30 | bucket, policy = policy_tuple 31 | c.set_bucket_policy(bucket, policy) 32 | self.stdout.write( 33 | f"Bucket ({m.bucket}) policy has been set via policy hook", ending='\n') if not silenced else None 34 | 35 | self.stdout.write('\nAll private & public buckets have been verified.\n', ending='\n') if not silenced else None 36 | self.stdout.flush() 37 | -------------------------------------------------------------------------------- /django_minio_backend/management/commands/is_minio_available.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from django_minio_backend.models import MinioBackend 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Checks if the configured MinIO service is available.' 7 | 8 | def add_arguments(self, parser): 9 | parser.add_argument('--silenced', action='store_true', default=False, help='No console messages') 10 | 11 | def handle(self, *args, **options): 12 | m = MinioBackend() # use default storage 13 | silenced = options.get('silenced') 14 | self.stdout.write(f"Checking the availability of MinIO at {m.base_url}\n") if not silenced else None 15 | 16 | available = m.is_minio_available() 17 | if not available: 18 | self.stdout.flush() 19 | raise CommandError(f'MinIO is NOT available at {m.base_url}\n' 20 | f'Reason: {available.details}') 21 | 22 | self.stdout.write(f'MinIO is available at {m.base_url}', ending='\n') if not silenced else None 23 | self.stdout.flush() 24 | -------------------------------------------------------------------------------- /django_minio_backend/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-minio-backend 3 | A MinIO-compatible custom storage backend for Django 4 | 5 | References: 6 | * https://github.com/minio/minio-py 7 | * https://docs.djangoproject.com/en/3.2/howto/custom-file-storage/ 8 | """ 9 | import io 10 | import json 11 | import logging 12 | import mimetypes 13 | import ssl 14 | import datetime 15 | from pathlib import Path 16 | from typing import Union, List 17 | 18 | # noinspection PyPackageRequirements MinIO_requirement 19 | import certifi 20 | import minio 21 | import minio.datatypes 22 | import minio.error 23 | import minio.helpers 24 | # noinspection PyPackageRequirements MinIO_requirement 25 | import urllib3 26 | from django.core.files import File 27 | from django.core.files.storage import Storage 28 | from django.core.files.uploadedfile import InMemoryUploadedFile 29 | from django.utils.deconstruct import deconstructible 30 | 31 | from .utils import MinioServerStatus, PrivatePublicMixedError, ConfigurationError, get_setting 32 | 33 | 34 | __all__ = ['MinioBackend', 'MinioBackendStatic', 'get_iso_date', 'iso_date_prefix', ] 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | def get_iso_date() -> str: 39 | """Get current date in ISO8601 format [year-month-day] as string""" 40 | now = datetime.datetime.now(datetime.UTC) 41 | return f"{now.year}-{now.month}-{now.day}" 42 | 43 | 44 | def iso_date_prefix(_, file_name_ext: str) -> str: 45 | """ 46 | Get filename prepended with current date in ISO8601 format [year-month-day] as string 47 | The date prefix will be the folder's name storing the object e.g.: 2020-12-31/cat.png 48 | """ 49 | return f"{get_iso_date()}/{file_name_ext}" 50 | 51 | 52 | class S3File(File): 53 | """A file returned from the Minio server""" 54 | 55 | def __init__(self, file, name, storage): 56 | super().__init__(file, name) 57 | self._storage = storage 58 | 59 | def open(self, mode=None, *args, **kwargs): 60 | if self.closed: 61 | self.file = self._storage.open(self.name, mode or "rb").file 62 | return super().open(mode, *args, **kwargs) 63 | 64 | 65 | @deconstructible 66 | class MinioBackend(Storage): 67 | """ 68 | :param bucket_name (str): The bucket's name where file(s) will be stored 69 | :arg *args: An arbitrary number of arguments. Stored in the self._META_ARGS class field 70 | :arg **kwargs: An arbitrary number of key-value arguments. 71 | Stored in the self._META_KWARGS class field 72 | Through self._META_KWARGS, the "metadata", "sse" and "progress" fields can be set 73 | for the underlying put_object() MinIO SDK method 74 | """ 75 | DEFAULT_MEDIA_FILES_BUCKET = 'auto-generated-bucket-media-files' 76 | DEFAULT_STATIC_FILES_BUCKET = 'auto-generated-bucket-static-files' 77 | DEFAULT_PRIVATE_BUCKETS = [DEFAULT_MEDIA_FILES_BUCKET, DEFAULT_STATIC_FILES_BUCKET] 78 | MINIO_MEDIA_FILES_BUCKET = get_setting("MINIO_MEDIA_FILES_BUCKET", default=DEFAULT_MEDIA_FILES_BUCKET) 79 | MINIO_STATIC_FILES_BUCKET = get_setting("MINIO_STATIC_FILES_BUCKET", default=DEFAULT_STATIC_FILES_BUCKET) 80 | 81 | def __init__(self, 82 | bucket_name: str = '', 83 | *args, 84 | **kwargs): 85 | 86 | # If bucket_name is not provided, MinioBackend acts as a DEFAULT_FILE_STORAGE 87 | # The automatically selected bucket is MINIO_MEDIA_FILES_BUCKET from settings.py 88 | # See https://docs.djangoproject.com/en/3.2/ref/settings/#default-file-storage 89 | if not bucket_name or bucket_name == '': 90 | self.__CONFIGURED_AS_DEFAULT_STORAGE = True 91 | self._BUCKET_NAME: str = self.MINIO_MEDIA_FILES_BUCKET 92 | else: 93 | self.__CONFIGURED_AS_DEFAULT_STORAGE = False 94 | self._BUCKET_NAME: str = bucket_name 95 | 96 | self._META_ARGS = args 97 | self._META_KWARGS = kwargs 98 | 99 | self._REPLACE_EXISTING = kwargs.get('replace_existing', False) 100 | 101 | self.__CLIENT: Union[minio.Minio, None] = None # This client is used for internal communication only. Communication this way should not leave the host network's perimeter 102 | self.__CLIENT_EXT: Union[minio.Minio, None] = None # This client is used for external communication. This client is necessary for creating region-aware pre-signed URLs 103 | self.__MINIO_ENDPOINT: str = get_setting("MINIO_ENDPOINT", "") 104 | self.__MINIO_EXTERNAL_ENDPOINT: str = get_setting("MINIO_EXTERNAL_ENDPOINT", self.__MINIO_ENDPOINT) 105 | self.__MINIO_ACCESS_KEY: str = get_setting("MINIO_ACCESS_KEY") 106 | self.__MINIO_SECRET_KEY: str = get_setting("MINIO_SECRET_KEY") 107 | self.__MINIO_USE_HTTPS: bool = get_setting("MINIO_USE_HTTPS") 108 | self.__MINIO_REGION: str = get_setting("MINIO_REGION", "us-east-1") # MINIO defaults to "us-east-1" when region is set to None 109 | self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: bool = get_setting("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", self.__MINIO_USE_HTTPS) 110 | self.__MINIO_BUCKET_CHECK_ON_SAVE: bool = get_setting("MINIO_BUCKET_CHECK_ON_SAVE", False) 111 | 112 | self.__BASE_URL = ("https://" if self.__MINIO_USE_HTTPS else "http://") + self.__MINIO_ENDPOINT 113 | self.__BASE_URL_EXTERNAL = ("https://" if self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS else "http://") + self.__MINIO_EXTERNAL_ENDPOINT 114 | self.__SAME_ENDPOINTS = self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT 115 | 116 | self.PRIVATE_BUCKETS: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", []) 117 | self.PUBLIC_BUCKETS: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", []) 118 | 119 | # Configure storage type 120 | self.__STORAGE_TYPE = 'custom' 121 | if self.bucket == self.MINIO_MEDIA_FILES_BUCKET: 122 | self.__STORAGE_TYPE = 'media' 123 | if self.bucket == self.MINIO_STATIC_FILES_BUCKET: 124 | self.__STORAGE_TYPE = 'static' 125 | 126 | # Enforce good bucket security (private vs public) 127 | if (self.bucket in self.DEFAULT_PRIVATE_BUCKETS) and (self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]): 128 | self.PRIVATE_BUCKETS.extend(self.DEFAULT_PRIVATE_BUCKETS) # policy for default buckets is PRIVATE 129 | # Require custom buckets to be declared explicitly 130 | if self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]: 131 | raise ConfigurationError(f'The configured bucket ({self.bucket}) must be declared either in MINIO_PRIVATE_BUCKETS or MINIO_PUBLIC_BUCKETS') 132 | 133 | # https://docs.min.io/docs/python-client-api-reference.html 134 | http_client_from_kwargs = self._META_KWARGS.get("http_client", None) 135 | http_client_from_settings = get_setting("MINIO_HTTP_CLIENT") 136 | self.HTTP_CLIENT: urllib3.poolmanager.PoolManager = http_client_from_kwargs or http_client_from_settings 137 | 138 | bucket_name_intersection: List[str] = list(set(self.PRIVATE_BUCKETS) & set(self.PUBLIC_BUCKETS)) 139 | if bucket_name_intersection: 140 | raise PrivatePublicMixedError( 141 | f'One or more buckets have been declared both private and public: {bucket_name_intersection}' 142 | ) 143 | 144 | """ 145 | django.core.files.storage.Storage 146 | """ 147 | 148 | def _save(self, file_path_name: str, content: InMemoryUploadedFile) -> str: 149 | """ 150 | Saves file to Minio by implementing Minio.put_object() 151 | :param file_path_name (str): Path to file + file name + file extension | i.e.: images/2018-12-31/cat.png 152 | :param content (InMemoryUploadedFile): File object 153 | :return: 154 | """ 155 | if self.__MINIO_BUCKET_CHECK_ON_SAVE: 156 | # Create bucket if not exists 157 | self.check_bucket_existence() 158 | 159 | # Check if object with name already exists; delete if so 160 | try: 161 | if self._REPLACE_EXISTING and self.stat(file_path_name): 162 | self.delete(file_path_name) 163 | except AttributeError: 164 | pass 165 | 166 | # Upload object 167 | file_path: Path = Path(file_path_name) # app name + file.suffix 168 | content_bytes: io.BytesIO = io.BytesIO(content.read()) 169 | content_length: int = len(content_bytes.getvalue()) 170 | 171 | self.client.put_object( 172 | bucket_name=self.bucket, 173 | object_name=file_path.as_posix(), 174 | data=content_bytes, 175 | length=content_length, 176 | content_type=self._guess_content_type(file_path_name, content), 177 | metadata=self._META_KWARGS.get('metadata', None), 178 | sse=self._META_KWARGS.get('sse', None), 179 | progress=self._META_KWARGS.get('progress', None), 180 | ) 181 | return file_path.as_posix() 182 | 183 | def get_available_name(self, name, max_length=None): 184 | """ 185 | Return a filename that's free on the target storage system and 186 | available for new content to be written to. 187 | """ 188 | if self._REPLACE_EXISTING: 189 | return name 190 | return super(MinioBackend, self).get_available_name(name, max_length) 191 | 192 | def _open(self, object_name, mode='rb', **kwargs) -> S3File: 193 | """ 194 | Implements the Storage._open(name,mode='rb') method 195 | :param name (str): object_name [path to file excluding bucket name which is implied] 196 | :kwargs (dict): passed on to the underlying MinIO client's get_object() method 197 | """ 198 | resp: urllib3.response.HTTPResponse = urllib3.response.HTTPResponse() 199 | 200 | if mode != 'rb': 201 | raise ValueError('Files retrieved from MinIO are read-only. Use save() method to override contents') 202 | try: 203 | resp = self.client.get_object(self.bucket, object_name, **kwargs) 204 | file = S3File(file=io.BytesIO(resp.read()), name=object_name, storage=self) 205 | finally: 206 | resp.close() 207 | resp.release_conn() 208 | return file 209 | 210 | def stat(self, name: str) -> Union[minio.datatypes.Object, bool]: 211 | """Get object information and metadata of an object""" 212 | object_name = Path(name).as_posix() 213 | try: 214 | obj = self.client.stat_object(self.bucket, object_name=object_name) 215 | return obj 216 | except (minio.error.S3Error, minio.error.ServerError, urllib3.exceptions.MaxRetryError): 217 | raise AttributeError(f'Could not stat object ({name}) in bucket ({self.bucket})') 218 | 219 | def delete(self, name: str): 220 | """ 221 | Deletes an object in Django and MinIO. 222 | This method is called only when an object is deleted from its own `change view` i.e.: 223 | http://django.test/admin/upload/privateattachment/13/change/ 224 | This method is NOT called during a bulk_delete order! 225 | :param name: File object name 226 | """ 227 | object_name = Path(name).as_posix() 228 | self.client.remove_object(bucket_name=self.bucket, object_name=object_name) 229 | 230 | def exists(self, name: str) -> bool: 231 | """Check if an object with name already exists""" 232 | object_name = Path(name).as_posix() 233 | try: 234 | if self.stat(object_name): 235 | return True 236 | return False 237 | except AttributeError as e: 238 | logger.info(e) 239 | return False 240 | 241 | def listdir(self, bucket_name: str): 242 | """List all objects in a bucket""" 243 | objects = self.client.list_objects(bucket_name=bucket_name, recursive=True) 244 | return [(obj.object_name, obj) for obj in objects] 245 | 246 | def size(self, name: str) -> int: 247 | """Get an object's size""" 248 | object_name = Path(name).as_posix() 249 | try: 250 | obj = self.stat(object_name) 251 | return obj.size if obj else 0 252 | except AttributeError: 253 | return 0 254 | 255 | def url(self, name: str): 256 | """ 257 | Returns url to object. 258 | If bucket is public, direct link is provided. 259 | if bucket is private, a pre-signed link is provided. 260 | :param name: (str) file path + file name + suffix 261 | :return: (str) URL to object 262 | """ 263 | client = self.client if self.same_endpoints else self.client_external 264 | 265 | if self.is_bucket_public: 266 | # noinspection PyProtectedMember 267 | base_url = client._base_url.build("GET", self.__MINIO_REGION).geturl() 268 | return f'{base_url}{self.bucket}/{name}' 269 | 270 | # private bucket 271 | try: 272 | u: str = client.presigned_get_object( 273 | bucket_name=self.bucket, 274 | object_name=name, 275 | expires=get_setting("MINIO_URL_EXPIRY_HOURS", datetime.timedelta(days=7)) # Default is 7 days 276 | ) 277 | return u 278 | except urllib3.exceptions.MaxRetryError: 279 | raise ConnectionError("Couldn't connect to Minio. Check django_minio_backend parameters in Django-Settings") 280 | 281 | def path(self, name): 282 | """The MinIO storage system doesn't support absolute paths""" 283 | raise NotImplementedError("The MinIO storage system doesn't support absolute paths.") 284 | 285 | def get_accessed_time(self, name: str) -> datetime: 286 | """ 287 | Return the last accessed time (as a datetime) of the file specified by 288 | name. The datetime will be timezone-aware if USE_TZ=True. 289 | """ 290 | raise NotImplementedError('MinIO does not store last accessed time') 291 | 292 | def get_created_time(self, name: str) -> datetime: 293 | """ 294 | Return the creation time (as a datetime) of the file specified by name. 295 | The datetime will be timezone-aware if USE_TZ=True. 296 | """ 297 | raise NotImplementedError('MinIO does not store creation time') 298 | 299 | def get_modified_time(self, name: str) -> datetime: 300 | """ 301 | Return the last modified time (as a datetime) of the file specified by 302 | name. The datetime will be timezone-aware if USE_TZ=True. 303 | """ 304 | if get_setting("USE_TZ"): 305 | return self.stat(name).last_modified 306 | return self.stat(name).last_modified.replace(tzinfo=None) # remove timezone info 307 | 308 | @staticmethod 309 | def _guess_content_type(file_path_name: str, content: InMemoryUploadedFile): 310 | if hasattr(content, 'content_type'): 311 | return content.content_type 312 | guess = mimetypes.guess_type(file_path_name)[0] 313 | if guess is None: 314 | return 'application/octet-stream' # default 315 | return guess 316 | 317 | """ 318 | MinioBackend 319 | """ 320 | 321 | @property 322 | def same_endpoints(self) -> bool: 323 | """ 324 | Returns True if (self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT) 325 | """ 326 | return self.__SAME_ENDPOINTS 327 | 328 | @property 329 | def bucket(self) -> str: 330 | """Get the configured bucket's [self.bucket] name""" 331 | return self._BUCKET_NAME 332 | 333 | @property 334 | def is_bucket_public(self) -> bool: 335 | """Check if configured bucket [self.bucket] is public""" 336 | return True if self.bucket in self.PUBLIC_BUCKETS else False 337 | 338 | def is_minio_available(self) -> MinioServerStatus: 339 | """Check if configured MinIO server is available""" 340 | if not self.__MINIO_ENDPOINT: 341 | mss = MinioServerStatus(None) 342 | mss.add_message('MINIO_ENDPOINT is not configured in Django settings') 343 | return mss 344 | 345 | with urllib3.PoolManager(cert_reqs=ssl.CERT_REQUIRED, ca_certs=certifi.where()) as http: 346 | try: 347 | r = http.request('GET', f'{self.__BASE_URL}/minio/index.html') 348 | return MinioServerStatus(r) 349 | except urllib3.exceptions.MaxRetryError as e: 350 | mss = MinioServerStatus(None) 351 | mss.add_message(f'Could not open connection to {self.__BASE_URL}/minio/index.html\n' 352 | f'Reason: {e}') 353 | return mss 354 | except Exception as e: 355 | mss = MinioServerStatus(None) 356 | mss.add_message(repr(e)) 357 | return mss 358 | 359 | @property 360 | def client(self) -> minio.Minio: 361 | """ 362 | Get handle to an (already) instantiated minio.Minio instance. This is the default Client. 363 | If "MINIO_EXTERNAL_ENDPOINT" != MINIO_ENDPOINT, this client is used for internal communication only 364 | """ 365 | return self.__CLIENT or self._create_new_client() 366 | 367 | @property 368 | def client_external(self) -> minio.Minio: 369 | """Get handle to an (already) instantiated EXTERNAL minio.Minio instance for generating pre-signed URLs for external access""" 370 | return self.__CLIENT_EXT or self._create_new_client(external=True) 371 | 372 | @property 373 | def base_url(self) -> str: 374 | """Get internal base URL to MinIO""" 375 | return self.__BASE_URL 376 | 377 | @property 378 | def base_url_external(self) -> str: 379 | """Get external base URL to MinIO""" 380 | return self.__BASE_URL_EXTERNAL 381 | 382 | def _create_new_client(self, external: bool = False) -> minio.Minio: 383 | """ 384 | Instantiates a new Minio client and assigns it to their respective class variable 385 | :param external: If True, the returned value is self.__CLIENT_EXT instead of self.__CLIENT 386 | """ 387 | self.__CLIENT = minio.Minio( 388 | endpoint=self.__MINIO_ENDPOINT, 389 | access_key=self.__MINIO_ACCESS_KEY, 390 | secret_key=self.__MINIO_SECRET_KEY, 391 | secure=self.__MINIO_USE_HTTPS, 392 | http_client=self.HTTP_CLIENT, 393 | region=self.__MINIO_REGION, 394 | ) 395 | self.__CLIENT_EXT = minio.Minio( 396 | endpoint=self.__MINIO_EXTERNAL_ENDPOINT, 397 | access_key=self.__MINIO_ACCESS_KEY, 398 | secret_key=self.__MINIO_SECRET_KEY, 399 | secure=self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS, 400 | http_client=self.HTTP_CLIENT, 401 | region=self.__MINIO_REGION, 402 | ) 403 | return self.__CLIENT_EXT if external else self.__CLIENT 404 | 405 | # MAINTENANCE 406 | def check_bucket_existence(self): 407 | """Check if configured bucket [self.bucket] exists""" 408 | if not self.client.bucket_exists(self.bucket): 409 | self.client.make_bucket(bucket_name=self.bucket) 410 | 411 | def check_bucket_existences(self): # Execute this handler upon starting Django to make sure buckets exist 412 | """Check if all buckets configured in settings.py do exist. If not, create them""" 413 | for bucket in [*self.PUBLIC_BUCKETS, *self.PRIVATE_BUCKETS]: 414 | if not self.client.bucket_exists(bucket): 415 | self.client.make_bucket(bucket_name=bucket) 416 | 417 | def set_bucket_policy(self, bucket: str, policy: dict): 418 | """Set a custom bucket policy""" 419 | self.client.set_bucket_policy(bucket_name=bucket, policy=json.dumps(policy)) 420 | 421 | def set_bucket_to_public(self): 422 | """Set bucket policy to be public. It can be then accessed via public URLs""" 423 | policy_public_read_only = {"Version": "2012-10-17", 424 | "Statement": [ 425 | { 426 | "Sid": "", 427 | "Effect": "Allow", 428 | "Principal": {"AWS": "*"}, 429 | "Action": "s3:GetBucketLocation", 430 | "Resource": f"arn:aws:s3:::{self.bucket}" 431 | }, 432 | { 433 | "Sid": "", 434 | "Effect": "Allow", 435 | "Principal": {"AWS": "*"}, 436 | "Action": "s3:ListBucket", 437 | "Resource": f"arn:aws:s3:::{self.bucket}" 438 | }, 439 | { 440 | "Sid": "", 441 | "Effect": "Allow", 442 | "Principal": {"AWS": "*"}, 443 | "Action": "s3:GetObject", 444 | "Resource": f"arn:aws:s3:::{self.bucket}/*" 445 | } 446 | ]} 447 | self.set_bucket_policy(self.bucket, policy_public_read_only) 448 | 449 | def validate_settings(self): 450 | """ 451 | validate_settings raises a ConfigurationError exception when one of the following conditions is met: 452 | * Neither MINIO_PRIVATE_BUCKETS nor MINIO_PUBLIC_BUCKETS have been declared and configured with at least 1 bucket 453 | * A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been declared and configured properly 454 | """ 455 | # minimum 1 bucket has to be declared 456 | if not (get_setting("MINIO_PRIVATE_BUCKETS") or get_setting("MINIO_PUBLIC_BUCKETS")): 457 | raise ConfigurationError( 458 | 'Either ' 459 | 'MINIO_PRIVATE_BUCKETS' 460 | ' or ' 461 | 'MINIO_PUBLIC_BUCKETS ' 462 | 'must be configured in your settings.py (can be both)' 463 | ) 464 | # mandatory parameters must be configured 465 | mandatory_parameters = (self.__MINIO_ENDPOINT, self.__MINIO_ACCESS_KEY, self.__MINIO_SECRET_KEY) 466 | if any([bool(x) is False for x in mandatory_parameters]) or (get_setting("MINIO_USE_HTTPS") is None): 467 | raise ConfigurationError( 468 | "A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been configured properly" 469 | ) 470 | 471 | 472 | @deconstructible 473 | class MinioBackendStatic(MinioBackend): 474 | """ 475 | MinIO-compatible Django custom storage system for Django static files. 476 | The used bucket can be configured in settings.py through `STORAGES.staticfiles.BACKEND` 477 | :arg *args: Should not be used for static files. It's here for compatibility only 478 | :arg **kwargs: Should not be used for static files. It's here for compatibility only 479 | """ 480 | def __init__(self, *args, **kwargs): 481 | super().__init__(self.MINIO_STATIC_FILES_BUCKET, *args, **kwargs) 482 | self.check_bucket_existence() # make sure the `MINIO_STATIC_FILES_BUCKET` exists 483 | self.set_bucket_to_public() # the static files bucket must be publicly available 484 | 485 | def path(self, name): 486 | """The MinIO storage system doesn't support absolute paths""" 487 | raise NotImplementedError("The MinIO storage system doesn't support absolute paths.") 488 | 489 | def get_accessed_time(self, name: str): 490 | """MinIO does not store last accessed time""" 491 | raise NotImplementedError('MinIO does not store last accessed time') 492 | 493 | def get_created_time(self, name: str): 494 | """MinIO does not store creation time""" 495 | raise NotImplementedError('MinIO does not store creation time') 496 | -------------------------------------------------------------------------------- /django_minio_backend/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /django_minio_backend/utils.py: -------------------------------------------------------------------------------- 1 | # noinspection PyPackageRequirements minIO_requirement 2 | import urllib3 3 | from typing import Union, List 4 | from django.conf import settings 5 | 6 | 7 | __all__ = ['MinioServerStatus', 'PrivatePublicMixedError', 'ConfigurationError', 'get_setting', ] 8 | 9 | 10 | class MinioServerStatus: 11 | """ 12 | MinioServerStatus is a simple status info wrapper for checking the availability of a remote MinIO server. 13 | MinioBackend.is_minio_available() returns a MinioServerStatus instance 14 | 15 | MinioServerStatus can be evaluated with the bool() method: 16 | ``` 17 | minio_available = MinioBackend.is_minio_available() 18 | if bool(minio_available): # bool() can be omitted 19 | print("OK") 20 | ``` 21 | """ 22 | def __init__(self, request: Union[urllib3.response.BaseHTTPResponse, None]): 23 | self._request = request 24 | self._bool = False 25 | self._details: List[str] = [] 26 | self.status = None 27 | self.data = None 28 | 29 | self.__OK = 'MinIO is available' 30 | self.___NOK = 'MinIO is NOT available' 31 | 32 | if not self._request: 33 | self.add_message('There was no HTTP request provided for MinioServerStatus upon initialisation.') 34 | else: 35 | self.status = self._request.status 36 | self.data = self._request.data.decode() if self._request.data else 'No data available' 37 | if self.status == 403: # Request was a legal, but the server refuses to respond to it -> it's running fine 38 | self._bool = True 39 | else: 40 | self._details.append(self.__OK) 41 | self._details.append('Reason: ' + self.data) 42 | 43 | def __bool__(self): 44 | return self._bool 45 | 46 | def add_message(self, text: str): 47 | self._details.append(text) 48 | 49 | @property 50 | def is_available(self): 51 | return self._bool 52 | 53 | @property 54 | def details(self): 55 | return '\n'.join(self._details) 56 | 57 | def __repr__(self): 58 | if self.is_available: 59 | return self.__OK 60 | return self.___NOK 61 | 62 | 63 | class PrivatePublicMixedError(Exception): 64 | """Raised on public|private bucket configuration collisions""" 65 | pass 66 | 67 | 68 | class ConfigurationError(Exception): 69 | """Raised on django-minio-backend configuration errors""" 70 | pass 71 | 72 | 73 | def get_setting(name, default=None): 74 | """Get setting from settings.py. Return a default value if not defined""" 75 | return getattr(settings, name, default) 76 | -------------------------------------------------------------------------------- /docker-compose.develop.yml: -------------------------------------------------------------------------------- 1 | # ORIGINAL SOURCE 2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html 3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true 4 | 5 | # IN THIS CONFIGURATION, THE PROJECT FILES ARE VOLUME MAPPED INTO THE CONTAINER FROM THE HOST 6 | 7 | version: "3.9" 8 | 9 | # Settings and configurations that are common for all containers 10 | x-minio-common: &minio-common 11 | image: minio/minio:RELEASE.2021-07-30T00-02-00Z 12 | command: server --console-address ":9001" http://minio{1...4}/data{1...2} 13 | expose: 14 | - "9000" 15 | - "9001" 16 | environment: 17 | MINIO_ROOT_USER: minio 18 | MINIO_ROOT_PASSWORD: minio123 19 | healthcheck: 20 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] 21 | interval: 30s 22 | timeout: 20s 23 | retries: 3 24 | 25 | services: 26 | # starts Django from DjangoExampleProject + DjangoExampleApplication 27 | web: 28 | image: python:3 29 | command: bash -c " 30 | pip install -r /code/requirements.txt 31 | && python manage.py migrate 32 | && python manage.py runserver 0.0.0.0:8000 33 | " 34 | volumes: 35 | - .:/code 36 | working_dir: /code 37 | environment: 38 | PYTHONUNBUFFERED: "1" 39 | GH_MINIO_ENDPOINT: "nginx:9000" 40 | GH_MINIO_USE_HTTPS: "false" 41 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000" 42 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false" 43 | GH_MINIO_ACCESS_KEY: "minio" 44 | GH_MINIO_SECRET_KEY: "minio123" 45 | # CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY! 46 | DJANGO_SUPERUSER_USERNAME: "admin" 47 | DJANGO_SUPERUSER_PASSWORD: "123123" 48 | DJANGO_SUPERUSER_EMAIL: "admin@local.test" 49 | ports: 50 | - "8000:8000" 51 | depends_on: 52 | - nginx 53 | # starts 4 docker containers running minio server instances. 54 | # using nginx reverse proxy, load balancing, you can access 55 | # it through port 9000. 56 | minio1: 57 | <<: *minio-common 58 | hostname: minio1 59 | volumes: 60 | - data1-1:/data1 61 | - data1-2:/data2 62 | 63 | minio2: 64 | <<: *minio-common 65 | hostname: minio2 66 | volumes: 67 | - data2-1:/data1 68 | - data2-2:/data2 69 | 70 | minio3: 71 | <<: *minio-common 72 | hostname: minio3 73 | volumes: 74 | - data3-1:/data1 75 | - data3-2:/data2 76 | 77 | minio4: 78 | <<: *minio-common 79 | hostname: minio4 80 | volumes: 81 | - data4-1:/data1 82 | - data4-2:/data2 83 | 84 | nginx: 85 | image: nginx:1.19.2-alpine 86 | hostname: nginx 87 | volumes: 88 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 89 | ports: 90 | - "9000:9000" 91 | - "9001:9001" 92 | depends_on: 93 | - minio1 94 | - minio2 95 | - minio3 96 | - minio4 97 | 98 | ## By default this config uses default local driver, 99 | ## For custom volumes replace with volume driver configuration. 100 | volumes: 101 | data1-1: 102 | data1-2: 103 | data2-1: 104 | data2-2: 105 | data3-1: 106 | data3-2: 107 | data4-1: 108 | data4-2: 109 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # ORIGINAL SOURCE 2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html 3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true 4 | 5 | # IN THIS CONFIGURATION, THE PROJECT FILES ARE COPIED INTO THE CONTAINER 6 | 7 | version: "3.9" 8 | 9 | # Settings and configurations that are common for all containers 10 | x-minio-common: &minio-common 11 | image: minio/minio:RELEASE.2021-07-30T00-02-00Z 12 | command: server --console-address ":9001" http://minio{1...4}/data{1...2} 13 | expose: 14 | - "9000" 15 | - "9001" 16 | environment: 17 | MINIO_ROOT_USER: minio 18 | MINIO_ROOT_PASSWORD: minio123 19 | healthcheck: 20 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] 21 | interval: 30s 22 | timeout: 20s 23 | retries: 3 24 | 25 | services: 26 | # starts Django from DjangoExampleProject + DjangoExampleApplication 27 | web: 28 | build: . 29 | command: bash -c " 30 | python manage.py migrate 31 | && python manage.py runserver 0.0.0.0:8000 32 | " 33 | environment: 34 | GH_MINIO_ENDPOINT: "nginx:9000" 35 | GH_MINIO_USE_HTTPS: "false" 36 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000" 37 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false" 38 | GH_MINIO_ACCESS_KEY: "minio" 39 | GH_MINIO_SECRET_KEY: "minio123" 40 | # CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY! 41 | DJANGO_SUPERUSER_USERNAME: "admin" 42 | DJANGO_SUPERUSER_PASSWORD: "123123" 43 | DJANGO_SUPERUSER_EMAIL: "admin@local.test" 44 | ports: 45 | - "8000:8000" 46 | depends_on: 47 | - nginx 48 | # starts 4 docker containers running minio server instances. 49 | # using nginx reverse proxy, load balancing, you can access 50 | # it through port 9000. 51 | minio1: 52 | <<: *minio-common 53 | hostname: minio1 54 | volumes: 55 | - data1-1:/data1 56 | - data1-2:/data2 57 | 58 | minio2: 59 | <<: *minio-common 60 | hostname: minio2 61 | volumes: 62 | - data2-1:/data1 63 | - data2-2:/data2 64 | 65 | minio3: 66 | <<: *minio-common 67 | hostname: minio3 68 | volumes: 69 | - data3-1:/data1 70 | - data3-2:/data2 71 | 72 | minio4: 73 | <<: *minio-common 74 | hostname: minio4 75 | volumes: 76 | - data4-1:/data1 77 | - data4-2:/data2 78 | 79 | nginx: 80 | image: nginx:1.19.2-alpine 81 | hostname: nginx 82 | volumes: 83 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 84 | ports: 85 | - "9000:9000" 86 | - "9001:9001" 87 | depends_on: 88 | - minio1 89 | - minio2 90 | - minio3 91 | - minio4 92 | 93 | ## By default this config uses default local driver, 94 | ## For custom volumes replace with volume driver configuration. 95 | volumes: 96 | data1-1: 97 | data1-2: 98 | data2-1: 99 | data2-2: 100 | data3-1: 101 | data3-2: 102 | data4-1: 103 | data4-2: 104 | -------------------------------------------------------------------------------- /examples/policy_hook.example.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | 4 | bucket_name: str = 'my-very-public-bucket' 5 | 6 | 7 | # policy sets the appropriate bucket as world readable (no write) 8 | # See the following good summary of Bucket Policies 9 | # https://gist.github.com/krishnasrinivas/2f5a9affe6be6aff42fe723f02c86d6a 10 | policy = {"Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Sid": "", 14 | "Effect": "Allow", 15 | "Principal": {"AWS": "*"}, 16 | "Action": "s3:GetBucketLocation", 17 | "Resource": f"arn:aws:s3:::{bucket_name}" 18 | }, 19 | { 20 | "Sid": "", 21 | "Effect": "Allow", 22 | "Principal": {"AWS": "*"}, 23 | "Action": "s3:ListBucket", 24 | "Resource": f"arn:aws:s3:::{bucket_name}" 25 | }, 26 | { 27 | "Sid": "", 28 | "Effect": "Allow", 29 | "Principal": {"AWS": "*"}, 30 | "Action": "s3:GetObject", 31 | "Resource": f"arn:aws:s3:::{bucket_name}/*" 32 | } 33 | ]} 34 | 35 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [ # This array of (bucket_name, policy) tuples belong to Django settings 36 | (bucket_name, policy), 37 | ] 38 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | # ORIGINAL SOURCE 2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html 3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/nginx.conf?raw=true 4 | 5 | user nginx; 6 | worker_processes auto; 7 | 8 | error_log /var/log/nginx/error.log warn; 9 | pid /var/run/nginx.pid; 10 | 11 | events { 12 | worker_connections 4096; 13 | } 14 | 15 | http { 16 | include /etc/nginx/mime.types; 17 | default_type application/octet-stream; 18 | 19 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 20 | '$status $body_bytes_sent "$http_referer" ' 21 | '"$http_user_agent" "$http_x_forwarded_for"'; 22 | 23 | access_log /var/log/nginx/access.log main; 24 | sendfile on; 25 | keepalive_timeout 65; 26 | 27 | # include /etc/nginx/conf.d/*.conf; 28 | 29 | upstream minio { 30 | server minio1:9000; 31 | server minio2:9000; 32 | server minio3:9000; 33 | server minio4:9000; 34 | } 35 | 36 | upstream console { 37 | ip_hash; 38 | server minio1:9001; 39 | server minio2:9001; 40 | server minio3:9001; 41 | server minio4:9001; 42 | } 43 | 44 | server { 45 | listen 9000; 46 | listen [::]:9000; 47 | server_name localhost; 48 | 49 | # To allow special characters in headers 50 | ignore_invalid_headers off; 51 | # Allow any size file to be uploaded. 52 | # Set to a value such as 1000m; to restrict file size to a specific value 53 | client_max_body_size 0; 54 | # To disable buffering 55 | proxy_buffering off; 56 | 57 | location / { 58 | proxy_set_header Host $http_host; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Forwarded-Proto $scheme; 62 | 63 | proxy_connect_timeout 300; 64 | # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 65 | proxy_http_version 1.1; 66 | proxy_set_header Connection ""; 67 | chunked_transfer_encoding off; 68 | 69 | proxy_pass http://minio; 70 | } 71 | } 72 | 73 | server { 74 | listen 9001; 75 | listen [::]:9001; 76 | server_name localhost; 77 | 78 | # To allow special characters in headers 79 | ignore_invalid_headers off; 80 | # Allow any size file to be uploaded. 81 | # Set to a value such as 1000m; to restrict file size to a specific value 82 | client_max_body_size 0; 83 | # To disable buffering 84 | proxy_buffering off; 85 | 86 | location / { 87 | proxy_set_header Host $http_host; 88 | proxy_set_header X-Real-IP $remote_addr; 89 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 90 | proxy_set_header X-Forwarded-Proto $scheme; 91 | proxy_set_header X-NginX-Proxy true; 92 | 93 | # This is necessary to pass the correct IP to be hashed 94 | real_ip_header X-Real-IP; 95 | 96 | proxy_connect_timeout 300; 97 | # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 98 | proxy_http_version 1.1; 99 | proxy_set_header Connection ""; 100 | chunked_transfer_encoding off; 101 | 102 | proxy_pass http://console; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | minio>=7.2.8 3 | Pillow 4 | setuptools 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from setuptools import find_packages, setup 4 | 5 | from version import get_git_version 6 | 7 | with open("README.md", "r") as readme_file: 8 | long_description = readme_file.read() 9 | 10 | # allow setup.py to be run from any path 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | 14 | setup( 15 | name='django-minio-backend', 16 | version=get_git_version(), 17 | packages=find_packages(), 18 | include_package_data=True, 19 | license=f'MIT License | Copyright (c) {datetime.now().year} Kristof Daja', 20 | description='The django-minio-backend provides a wrapper around the MinIO Python Library.', 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | url='https://github.com/theriverman/django-minio-backend', 24 | author='Kristof Daja (theriverman)', 25 | author_email='kristof@daja.hu', 26 | install_requires=[ 27 | 'Django>=4.2', 28 | 'minio>=7.2.8' 29 | ], 30 | classifiers=[ 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Framework :: Django :: 4.2', 34 | 'Framework :: Django :: 5.0', 35 | 'Framework :: Django :: 5.1', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3.11', 41 | 'Programming Language :: Python :: 3.12', 42 | 'Programming Language :: Python :: 3.13', 43 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 44 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System', 45 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Author: Douglas Creager 3 | # Modifier: Kristof Daja 4 | # This file is placed into the public domain. 5 | 6 | # Calculates the current version number. If possible, this is the 7 | # output of “git describe”, modified to conform to the versioning 8 | # scheme that setuptools uses. If “git describe” returns an error 9 | # (most likely because we're in an unpacked copy of a release tarball, 10 | # rather than in a git working copy), then we fall back on reading the 11 | # contents of the RELEASE-VERSION file. 12 | # 13 | # To use this script, simply import it your setup.py file, and use the 14 | # results of get_git_version() as your package version: 15 | # 16 | # from version import * 17 | # 18 | # setup( 19 | # version=get_git_version(), 20 | # . 21 | # . 22 | # . 23 | # ) 24 | # 25 | # 26 | # This will automatically update the RELEASE-VERSION file, if 27 | # necessary. Note that the RELEASE-VERSION file should *not* be 28 | # checked into git; please add it to your top-level .gitignore file. 29 | # 30 | # You'll probably want to distribute the RELEASE-VERSION file in your 31 | # sdist tarballs; to do this, just create a MANIFEST.in file that 32 | # contains the following line: 33 | # 34 | # include RELEASE-VERSION 35 | # 36 | # Change History: 37 | # 2020-12-12 - Updated for Python 3. Changed git describe --abbrev=7 to git describe --tags 38 | # 39 | 40 | __all__ = ["get_git_version"] 41 | 42 | from subprocess import Popen, PIPE 43 | 44 | 45 | def call_git_describe(): 46 | # noinspection PyBroadException 47 | try: 48 | p = Popen(['git', 'describe', '--tags'], 49 | stdout=PIPE, stderr=PIPE) 50 | p.stderr.close() 51 | line = p.stdout.readlines()[0] 52 | return line.strip().decode('utf-8') 53 | 54 | except Exception: 55 | return None 56 | 57 | 58 | def is_dirty(): 59 | # noinspection PyBroadException 60 | try: 61 | p = Popen(["git", "diff-index", "--name-only", "HEAD"], 62 | stdout=PIPE, stderr=PIPE) 63 | p.stderr.close() 64 | lines = p.stdout.readlines() 65 | return len(lines) > 0 66 | except Exception: 67 | return False 68 | 69 | 70 | def read_release_version(): 71 | # noinspection PyBroadException 72 | try: 73 | f = open("RELEASE-VERSION", "r") 74 | 75 | try: 76 | version = f.readlines()[0] 77 | return version.strip() 78 | 79 | finally: 80 | f.close() 81 | 82 | except Exception: 83 | return None 84 | 85 | 86 | def write_release_version(version): 87 | f = open("RELEASE-VERSION", "w") 88 | f.write("%s\n" % version) 89 | f.close() 90 | 91 | 92 | def get_git_version(): 93 | # Read in the version that's currently in RELEASE-VERSION. 94 | release_version = read_release_version() 95 | 96 | # First try to get the current version using “git describe”. 97 | version = call_git_describe() 98 | if is_dirty(): 99 | version += "-dirty" 100 | 101 | # If that doesn't work, fall back on the value that's in 102 | # RELEASE-VERSION. 103 | if version is None: 104 | version = release_version 105 | 106 | # If we still don't have anything, that's an error. 107 | if version is None: 108 | raise ValueError("Cannot find the version number!") 109 | 110 | # If the current version is different from what's in the 111 | # RELEASE-VERSION file, update the file to be current. 112 | if version != release_version: 113 | write_release_version(version) 114 | 115 | # Finally, return the current version. 116 | return version 117 | 118 | 119 | if __name__ == "__main__": 120 | print(get_git_version()) 121 | --------------------------------------------------------------------------------