From 4c21a964a8aa55691a6638d4bec6687de0781695 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Fri, 19 Jun 2026 19:57:35 -0300 Subject: [PATCH] feat: separate MinIO write path from public URL --- ...ation_public_read_write_prefix_and_more.py | 37 ++++++++++++ files_storage/minio.py | 34 +++++++++-- files_storage/models.py | 16 +++++- files_storage/test_minio.py | 56 ++++++++++++++++--- files_storage/wagtail_hooks.py | 5 +- 5 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 files_storage/migrations/0005_minioconfiguration_public_read_write_prefix_and_more.py diff --git a/files_storage/migrations/0005_minioconfiguration_public_read_write_prefix_and_more.py b/files_storage/migrations/0005_minioconfiguration_public_read_write_prefix_and_more.py new file mode 100644 index 000000000..69be79c94 --- /dev/null +++ b/files_storage/migrations/0005_minioconfiguration_public_read_write_prefix_and_more.py @@ -0,0 +1,37 @@ +# Generated by Codex on 2026-06-19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("files_storage", "0004_remove_minioconfiguration_bucket_app_subdir_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="minioconfiguration", + name="public_base_url", + field=models.URLField( + blank=True, + max_length=500, + null=True, + verbose_name="Public base URL", + ), + ), + migrations.AddField( + model_name="minioconfiguration", + name="write_prefix", + field=models.CharField( + blank=True, + max_length=128, + null=True, + verbose_name="Write prefix", + ), + ), + migrations.AlterField( + model_name="filelocation", + name="uri", + field=models.URLField(blank=True, max_length=500, null=True, verbose_name="URI"), + ), + ] diff --git a/files_storage/minio.py b/files_storage/minio.py index 0cd8139ed..e30f59cc8 100644 --- a/files_storage/minio.py +++ b/files_storage/minio.py @@ -3,8 +3,10 @@ import json import logging import os +import posixpath from mimetypes import types_map from tempfile import NamedTemporaryFile, TemporaryDirectory +from urllib.parse import urljoin from minio import Minio from minio.error import S3Error @@ -69,9 +71,12 @@ def __init__( minio_access_key, minio_secret_key, bucket_root, - location, + location=None, minio_secure=True, minio_http_client=None, + write_prefix="", + public_base_url="", + bucket_subdir=None, ): self.bucket_root = bucket_root self.POLICY_READ_ONLY = { @@ -97,7 +102,22 @@ def __init__( self.minio_secure = minio_secure self.http_client = minio_http_client self._client_instance = None - self.location = location + self.location = location or bucket_subdir + self.write_prefix = self._normalize_path(write_prefix) + self.public_base_url = (public_base_url or "").strip() + + def _normalize_path(self, path): + return (path or "").strip("/") + + def _object_name_for_storage(self, object_name): + object_name = self._normalize_path(object_name) + if not self.write_prefix: + return object_name + return posixpath.join(self.write_prefix, object_name) + + def _public_uri(self, object_name): + object_name = self._normalize_path(object_name) + return urljoin(f"{self.public_base_url.rstrip('/')}/", object_name) @property def _client(self): @@ -173,7 +193,12 @@ def get_uri(self, object_name: str) -> str: MinioStorageGetUriError """ try: - url = self._client.presigned_get_object(self.bucket_root, object_name) + if self.public_base_url: + return self._public_uri(object_name) + + url = self._client.presigned_get_object( + self.bucket_root, self._object_name_for_storage(object_name) + ) return url.split("?")[0] except Exception as e: raise MinioStorageGetUriError( @@ -257,10 +282,11 @@ def _fput_object(self, file_path, object_name, mimetype) -> str: ------ MinioStorageNoSuchBucketError """ + storage_object_name = self._object_name_for_storage(object_name) try: self._client.fput_object( self.bucket_root, - object_name=object_name, + object_name=storage_object_name, file_path=file_path, content_type=mimetype, ) diff --git a/files_storage/models.py b/files_storage/models.py index d1edda553..d09091c5e 100644 --- a/files_storage/models.py +++ b/files_storage/models.py @@ -38,6 +38,12 @@ class MinioConfiguration(CommonControlField): bucket_root = models.CharField( _("Bucket root"), max_length=32, null=True, blank=True ) + write_prefix = models.CharField( + _("Write prefix"), max_length=128, null=True, blank=True + ) + public_base_url = models.URLField( + _("Public base URL"), max_length=500, null=True, blank=True + ) location = models.CharField( _("Location"), max_length=16, @@ -62,6 +68,8 @@ class Meta: FieldPanel("name"), FieldPanel("host"), FieldPanel("bucket_root"), + FieldPanel("write_prefix"), + FieldPanel("public_base_url"), # FieldPanel("location"), FieldPanel("access_key"), FieldPanel("secret_key"), @@ -85,6 +93,8 @@ def get_or_create( secret_key=None, secure=None, bucket_root=None, + write_prefix=None, + public_base_url=None, location=None, user=None, ): @@ -98,6 +108,8 @@ def get_or_create( files_storage.access_key = access_key files_storage.secret_key = secret_key files_storage.bucket_root = bucket_root + files_storage.write_prefix = write_prefix + files_storage.public_base_url = public_base_url files_storage.location = location files_storage.creator = user files_storage.save() @@ -125,12 +137,14 @@ def get_files_storage(cls, name, minio_http_client=None): location=obj.location, minio_secure=obj.secure, minio_http_client=minio_http_client, + write_prefix=obj.write_prefix, + public_base_url=obj.public_base_url, ) class FileLocation(CommonControlField): basename = models.CharField(_("Basename"), max_length=100, null=True, blank=True) - uri = models.URLField(_("URI"), null=True, blank=True, max_length=200) + uri = models.URLField(_("URI"), null=True, blank=True, max_length=500) autocomplete_search_field = "uri" diff --git a/files_storage/test_minio.py b/files_storage/test_minio.py index ae603d810..08a63901a 100644 --- a/files_storage/test_minio.py +++ b/files_storage/test_minio.py @@ -1,6 +1,6 @@ # Create your tests here. import json -from unittest.mock import Mock, patch +from unittest.mock import ANY, patch from django.test import TestCase from minio.error import S3Error @@ -90,6 +90,21 @@ def test_get_uri(self, mock_presigned_get_object): "app_name", ) + def test_get_uri_with_public_base_url(self): + minio_storage = MinioStorage( + minio_host="s3.wasabisys.com", + minio_access_key="minio_access_key", + minio_secret_key="minio_secret_key", + bucket_root="scielo", + location="sa-east-1", + write_prefix="upload", + public_base_url="https://minio.scielo.br/upload", + ) + + uri = minio_storage.get_uri("journal/article.xml") + + self.assertEqual("https://minio.scielo.br/upload/journal/article.xml", uri) + @patch("files_storage.minio.MinioStorage.fput") def test_register(self, mock_fput): metadata = self.minio_storage.register( @@ -120,6 +135,35 @@ def test__fput_object(self, mock_client_fput_object, mock_get_uri): "subdir1/subdir2/filename.xml", ) + @patch("files_storage.minio.MinioStorage.get_uri") + @patch("files_storage.minio.Minio.fput_object") + def test__fput_object_uses_write_prefix(self, mock_client_fput_object, mock_get_uri): + minio_storage = MinioStorage( + minio_host="s3.wasabisys.com", + minio_access_key="minio_access_key", + minio_secret_key="minio_secret_key", + bucket_root="scielo", + location="sa-east-1", + write_prefix="upload", + public_base_url="https://minio.scielo.br/upload", + ) + + minio_storage._fput_object( + "/root/folder1/folder2/filename.xml", + "subdir1/subdir2/filename.xml", + mimetype="mimetype_informado", + ) + + mock_client_fput_object.assert_called_with( + "scielo", + object_name="upload/subdir1/subdir2/filename.xml", + file_path="/root/folder1/folder2/filename.xml", + content_type="mimetype_informado", + ) + mock_get_uri.assert_called_with( + "subdir1/subdir2/filename.xml", + ) + @patch("files_storage.minio.MinioStorage.get_uri") @patch("files_storage.minio.Minio.fput_object") def test__fput_object_raises_exception( @@ -212,25 +256,23 @@ def test_remove(self, mock_remove): def test_fput_content(self, mock_fput_object): mock_fput_object.return_value = "uri" uri = self.minio_storage.fput_content( - content="
", + content=b"
", mimetype="text/xml", object_name="object_name", ) self.assertEqual("uri", uri) - @patch("files_storage.minio.MinioStorage._create_tmp_file") @patch("files_storage.minio.MinioStorage._fput_object") def test_fput_content_calls_fput_object( - self, mock_fput_object, mock_create_tmp_file + self, mock_fput_object ): - mock_create_tmp_file.return_value = "/tmp/file.xml" uri = self.minio_storage.fput_content( - content="
", + content=b"
", mimetype="text/xml", object_name="object_name", ) mock_fput_object.assert_called_with( - file_path="/tmp/file.xml", + file_path=ANY, object_name="object_name", mimetype="text/xml", ) diff --git a/files_storage/wagtail_hooks.py b/files_storage/wagtail_hooks.py index bfd68bd44..38209df8c 100644 --- a/files_storage/wagtail_hooks.py +++ b/files_storage/wagtail_hooks.py @@ -18,10 +18,13 @@ class MinioConfigurationViewSet(CommonControlFieldViewSet): "name", "host", "bucket_root", + "write_prefix", + "public_base_url", ) search_fields = ( "name", "host", "bucket_root", + "write_prefix", + "public_base_url", ) -