Repository: Archmonger/django-dbbackup

Issue:
MediaRestore restores files in incorrect paths
### Current Situation

When restoring media files, if the path of the file contains the word "media" anywhere in the file path then this leads to the file getting extracted to the wrong path.

The culprit is the following line of code in mediarestore.py which replaces all occurences of "media/" in the path:
` name = media_file_info.path.replace("media/", "")`

The interesting thing is, when backing up the media files, there is no "media" root directory in the .tar file. So maybe this line of code isn't required at all?

I tried forking the code and removing this specific line of code. All tests passed when running runtests.py I don't know if this is sufficient.


### Proposed Actions

_No response_

### System Information

_No response_


Discussion:

Patch:
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 71ddec6c..6e6eeb13 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -90,11 +90,14 @@ Modern development process using Hatch:
 6. **Auto-format code**: `hatch run lint:format` (2 seconds)
 7. **Test documentation**: `hatch run docs:build` (2 seconds)
 8. **Update documentation** when making changes to Python source code (required)
+9. **Add changelog entry** for all significant changes (bug fixes, new features, breaking changes) to `CHANGELOG.md` under the "Unreleased" section
 
 Always run `hatch run lint:check` before committing. The CI (.github/workflows/build.yml) includes comprehensive checks across all supported Python/Django combinations.
 
 **IMPORTANT**: Documentation must be updated whenever changes are made to Python source code. This is enforced as part of the development workflow.
 
+**IMPORTANT**: Significant changes must always include a changelog entry in `CHANGELOG.md` under the appropriate category (Added, Changed, Deprecated, Removed, Fixed, Security) in the "Unreleased" section.
+
 ## Repository Structure and Navigation
 
 Key directories and files:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91d23b87..4c71b060 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ Don't forget to remove deprecated code on each major release!
 - Fix issues with parsing excess whitespace within `dbbackup -d "<COMMA_SEPARATED_ARGS>"`
 - Fix encryption support when using `gnupg==5.x`.
 - Resolve SQLite backup temporary file locking issues on Windows.
+- Fix MediaRestore path corruption for files containing "media" in their paths.
 
 ### Security
 
diff --git a/dbbackup/checks.py b/dbbackup/checks.py
index d46aa1cb..8d537062 100644
--- a/dbbackup/checks.py
+++ b/dbbackup/checks.py
@@ -32,8 +32,7 @@
 )
 W006 = Warning(
     "FAILURE_RECIPIENTS has been deprecated",
-    hint="settings.DBBACKUP_FAILURE_RECIPIENTS is replaced by "
-    "settings.DBBACKUP_ADMINS",
+    hint="settings.DBBACKUP_FAILURE_RECIPIENTS is replaced by settings.DBBACKUP_ADMINS",
     id="dbbackup.W006",
 )
 W007 = Warning(
@@ -97,16 +96,10 @@ def check_settings(app_configs, **kwargs):
     if not settings.STORAGE or not isinstance(settings.STORAGE, str):
         errors.append(W002)
 
-    if (
-        not callable(settings.FILENAME_TEMPLATE)
-        and "{datetime}" not in settings.FILENAME_TEMPLATE
-    ):
+    if not callable(settings.FILENAME_TEMPLATE) and "{datetime}" not in settings.FILENAME_TEMPLATE:
         errors.append(W003)
 
-    if (
-        not callable(settings.MEDIA_FILENAME_TEMPLATE)
-        and "{datetime}" not in settings.MEDIA_FILENAME_TEMPLATE
-    ):
+    if not callable(settings.MEDIA_FILENAME_TEMPLATE) and "{datetime}" not in settings.MEDIA_FILENAME_TEMPLATE:
         errors.append(W004)
 
     if re.search(r"[^A-Za-z0-9%_-]", settings.DATE_FORMAT):
diff --git a/dbbackup/db/postgresql.py b/dbbackup/db/postgresql.py
index 69a7b559..b3ffaefb 100644
--- a/dbbackup/db/postgresql.py
+++ b/dbbackup/db/postgresql.py
@@ -70,9 +70,7 @@ def _restore_dump(self, dump):
 
         cmd += " {}".format(self.settings["NAME"])
         cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
-        stdout, stderr = self.run_command(
-            cmd, stdin=dump, env={**self.restore_env, **pg_env}
-        )
+        stdout, stderr = self.run_command(cmd, stdin=dump, env={**self.restore_env, **pg_env})
         return stdout, stderr
 
 
@@ -148,25 +146,13 @@ def _restore_dump(self, dump: str):
 
         # Flatten optional values
         if self.restore_prefix:
-            cmd.extend(
-                self.restore_prefix
-                if isinstance(self.restore_prefix, list)
-                else [self.restore_prefix]
-            )
+            cmd.extend(self.restore_prefix if isinstance(self.restore_prefix, list) else [self.restore_prefix])
 
         if self.restore_cmd:
-            cmd.extend(
-                self.restore_cmd
-                if isinstance(self.restore_cmd, list)
-                else [self.restore_cmd]
-            )
+            cmd.extend(self.restore_cmd if isinstance(self.restore_cmd, list) else [self.restore_cmd])
 
         if self.pg_options:
-            cmd.extend(
-                self.pg_options
-                if isinstance(self.pg_options, list)
-                else [self.pg_options]
-            )
+            cmd.extend(self.pg_options if isinstance(self.pg_options, list) else [self.pg_options])
 
         cmd.extend([dbname])
 
@@ -184,15 +170,9 @@ def _restore_dump(self, dump: str):
             cmd.extend(["--if-exists"])
 
         if self.restore_suffix:
-            cmd.extend(
-                self.restore_suffix
-                if isinstance(self.restore_suffix, list)
-                else [self.restore_suffix]
-            )
+            cmd.extend(self.restore_suffix if isinstance(self.restore_suffix, list) else [self.restore_suffix])
 
         cmd_str = " ".join(cmd)
-        stdout, _ = self.run_command(
-            cmd_str, stdin=dump, env={**self.dump_env, **pg_env}
-        )
+        stdout, _ = self.run_command(cmd_str, stdin=dump, env={**self.dump_env, **pg_env})
 
         return stdout
diff --git a/dbbackup/log.py b/dbbackup/log.py
index a7566b05..c1f23b6c 100644
--- a/dbbackup/log.py
+++ b/dbbackup/log.py
@@ -16,9 +16,7 @@ def emit(self, record):
     def send_mail(self, subject, message, *args, **kwargs):
         from . import utils
 
-        utils.mail_admins(
-            subject, message, *args, connection=self.connection(), **kwargs
-        )
+        utils.mail_admins(subject, message, *args, connection=self.connection(), **kwargs)
 
 
 class MailEnabledFilter(logging.Filter):
diff --git a/dbbackup/management/commands/_base.py b/dbbackup/management/commands/_base.py
index f5c956ef..b22c913f 100644
--- a/dbbackup/management/commands/_base.py
+++ b/dbbackup/management/commands/_base.py
@@ -63,21 +63,14 @@ class BaseDbBackupCommand(BaseCommand):
     def __init__(self, *args, **kwargs):
         self.option_list = self.base_option_list + self.option_list
         if django.VERSION < (1, 10):
-            options = tuple(
-                optparse_make_option(*_args, **_kwargs)
-                for _args, _kwargs in self.option_list
-            )
+            options = tuple(optparse_make_option(*_args, **_kwargs) for _args, _kwargs in self.option_list)
 
             self.option_list = options + BaseCommand.option_list
         super().__init__(*args, **kwargs)
 
     def add_arguments(self, parser):
         for args, kwargs in self.option_list:
-            kwargs = {
-                k: v
-                for k, v in kwargs.items()
-                if not k.startswith("_") and k not in USELESS_ARGS
-            }
+            kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_") and k not in USELESS_ARGS}
             parser.add_argument(*args, **kwargs)
 
     def _set_logger_level(self):
diff --git a/dbbackup/management/commands/dbrestore.py b/dbbackup/management/commands/dbrestore.py
index 17585cfb..14177429 100644
--- a/dbbackup/management/commands/dbrestore.py
+++ b/dbbackup/management/commands/dbrestore.py
@@ -21,14 +21,11 @@ class Command(BaseDbBackupCommand):
     option_list = BaseDbBackupCommand.option_list + (
         make_option("-d", "--database", help="Database to restore"),
         make_option("-i", "--input-filename", help="Specify filename to backup from"),
-        make_option(
-            "-I", "--input-path", help="Specify path on local filesystem to backup from"
-        ),
+        make_option("-I", "--input-path", help="Specify path on local filesystem to backup from"),
         make_option(
             "-s",
             "--servername",
-            help="If backup file is not specified, filter the "
-            "existing ones with the given servername",
+            help="If backup file is not specified, filter the existing ones with the given servername",
         ),
         make_option(
             "-c",
@@ -37,9 +34,7 @@ class Command(BaseDbBackupCommand):
             action="store_true",
             help="Decrypt data before restoring",
         ),
-        make_option(
-            "-p", "--passphrase", help="Passphrase for decrypt file", default=None
-        ),
+        make_option("-p", "--passphrase", help="Passphrase for decrypt file", default=None),
         make_option(
             "-z",
             "--uncompress",
@@ -85,9 +80,7 @@ def handle(self, *args, **options):
             self.passphrase = options.get("passphrase")
             self.interactive = options.get("interactive")
             self.input_database_name = options.get("database")
-            self.database_name, self.database = self._get_database(
-                self.input_database_name
-            )
+            self.database_name, self.database = self._get_database(self.input_database_name)
             self.storage = get_storage()
             self.no_drop = options.get("no_drop")
             self.pg_options = options.get("pg_options", "")
@@ -100,10 +93,7 @@ def _get_database(self, database_name: str):
         """Get the database to restore."""
         if not database_name:
             if len(settings.DATABASES) > 1:
-                errmsg = (
-                    "Because this project contains more than one database, you"
-                    " must specify the --database option."
-                )
+                errmsg = "Because this project contains more than one database, you must specify the --database option."
                 raise CommandError(errmsg)
             database_name = list(settings.DATABASES.keys())[0]
         if database_name not in settings.DATABASES:
@@ -128,15 +118,11 @@ def _restore_backup(self):
         self.logger.info(f"Restoring: {input_filename}")
 
         if self.decrypt:
-            unencrypted_file, input_filename = utils.unencrypt_file(
-                input_file, input_filename, self.passphrase
-            )
+            unencrypted_file, input_filename = utils.unencrypt_file(input_file, input_filename, self.passphrase)
             input_file.close()
             input_file = unencrypted_file
         if self.uncompress:
-            uncompressed_file, input_filename = utils.uncompress_file(
-                input_file, input_filename
-            )
+            uncompressed_file, input_filename = utils.uncompress_file(input_file, input_filename)
             input_file.close()
             input_file = uncompressed_file
 
diff --git a/dbbackup/management/commands/listbackups.py b/dbbackup/management/commands/listbackups.py
index a1dea78a..6030e75b 100644
--- a/dbbackup/management/commands/listbackups.py
+++ b/dbbackup/management/commands/listbackups.py
@@ -45,9 +45,7 @@ class Command(BaseDbBackupCommand):
             default=None,
             dest="encrypted",
         ),
-        make_option(
-            "-c", "--content-type", help="Filter by content type 'db' or 'media'"
-        ),
+        make_option("-c", "--content-type", help="Filter by content type 'db' or 'media'"),
     )
 
     def handle(self, **options):
diff --git a/dbbackup/management/commands/mediabackup.py b/dbbackup/management/commands/mediabackup.py
index 8492da2f..ea6fdd54 100644
--- a/dbbackup/management/commands/mediabackup.py
+++ b/dbbackup/management/commands/mediabackup.py
@@ -44,9 +44,7 @@ class Command(BaseDbBackupCommand):
             action="store_true",
             default=False,
         ),
-        make_option(
-            "-o", "--output-filename", default=None, help="Specify filename on storage"
-        ),
+        make_option("-o", "--output-filename", default=None, help="Specify filename on storage"),
         make_option(
             "-O",
             "--output-path",
@@ -111,9 +109,7 @@ def backup_mediafiles(self):
             filename = self.filename
         else:
             extension = f"tar{'.gz' if self.compress else ''}"
-            filename = utils.filename_generate(
-                extension, servername=self.servername, content_type=self.content_type
-            )
+            filename = utils.filename_generate(extension, servername=self.servername, content_type=self.content_type)
 
         tarball = self._create_tar(filename)
         # Apply trans
diff --git a/dbbackup/management/commands/mediarestore.py b/dbbackup/management/commands/mediarestore.py
index fb1355a2..b66289d0 100644
--- a/dbbackup/management/commands/mediarestore.py
+++ b/dbbackup/management/commands/mediarestore.py
@@ -21,14 +21,11 @@ class Command(BaseDbBackupCommand):
             action="store",
             help="Specify filename to backup from",
         ),
-        make_option(
-            "-I", "--input-path", help="Specify path on local filesystem to backup from"
-        ),
+        make_option("-I", "--input-path", help="Specify path on local filesystem to backup from"),
         make_option(
             "-s",
             "--servername",
-            help="If backup file is not specified, filter the existing ones with the "
-            "given servername",
+            help="If backup file is not specified, filter the existing ones with the given servername",
         ),
         make_option(
             "-e",
@@ -37,18 +34,14 @@ class Command(BaseDbBackupCommand):
             action="store_true",
             help="Decrypt data before restoring",
         ),
-        make_option(
-            "-p", "--passphrase", default=None, help="Passphrase for decrypt file"
-        ),
+        make_option("-p", "--passphrase", default=None, help="Passphrase for decrypt file"),
         make_option(
             "-z",
             "--uncompress",
             action="store_true",
             help="Uncompress gzip data before restoring",
         ),
-        make_option(
-            "-r", "--replace", help="Replace existing files", action="store_true"
-        ),
+        make_option("-r", "--replace", help="Replace existing files", action="store_true"),
     )
 
     def handle(self, *args, **options):
@@ -87,9 +80,7 @@ def _restore_backup(self):
         self.logger.info("Restoring: %s", input_filename)
 
         if self.decrypt:
-            unencrypted_file, input_filename = utils.unencrypt_file(
-                input_file, input_filename, self.passphrase
-            )
+            unencrypted_file, input_filename = utils.unencrypt_file(input_file, input_filename, self.passphrase)
             input_file.close()
             input_file = unencrypted_file
 
@@ -110,5 +101,5 @@ def _restore_backup(self):
             media_file = tar_file.extractfile(media_file_info)
             if media_file is None:
                 continue  # Skip directories
-            name = media_file_info.path.replace("media/", "")
+            name = media_file_info.path
             self._upload_file(name, media_file)
diff --git a/dbbackup/settings.py b/dbbackup/settings.py
index abe7c9ce..94a5e5dc 100644
--- a/dbbackup/settings.py
+++ b/dbbackup/settings.py
@@ -28,9 +28,7 @@
     "DBBACKUP_FILENAME_TEMPLATE",
     "{databasename}-{servername}-{datetime}.{extension}",
 )
-MEDIA_FILENAME_TEMPLATE = getattr(
-    settings, "DBBACKUP_MEDIA_FILENAME_TEMPLATE", "{servername}-{datetime}.{extension}"
-)
+MEDIA_FILENAME_TEMPLATE = getattr(settings, "DBBACKUP_MEDIA_FILENAME_TEMPLATE", "{servername}-{datetime}.{extension}")
 
 GPG_ALWAYS_TRUST = getattr(settings, "DBBACKUP_GPG_ALWAYS_TRUST", False)
 GPG_RECIPIENT = GPG_ALWAYS_TRUST = getattr(settings, "DBBACKUP_GPG_RECIPIENT", None)
diff --git a/dbbackup/storage.py b/dbbackup/storage.py
index 642f8918..f08b33c8 100644
--- a/dbbackup/storage.py
+++ b/dbbackup/storage.py
@@ -27,9 +27,7 @@ def get_storage(path=None, options=None):
     path = path or settings.STORAGE
     options = options or settings.STORAGE_OPTIONS
     if not path:
-        raise ImproperlyConfigured(
-            'You must specify a storage class using settings.STORAGES["dbbackup"] settings.'
-        )
+        raise ImproperlyConfigured('You must specify a storage class using settings.STORAGES["dbbackup"] settings.')
     return Storage(path, **options)
 
 
@@ -265,11 +263,7 @@ def clean_old_backups(
         :type keep_number: ``int`` or ``None``
         """
         if keep_number is None:
-            keep_number = (
-                settings.CLEANUP_KEEP
-                if content_type == "db"
-                else settings.CLEANUP_KEEP_MEDIA
-            )
+            keep_number = settings.CLEANUP_KEEP if content_type == "db" else settings.CLEANUP_KEEP_MEDIA
         keep_filter = settings.CLEANUP_KEEP_FILTER
         files = self.list_backups(
             encrypted=encrypted,
diff --git a/dbbackup/utils.py b/dbbackup/utils.py
index c2277add..7ff33516 100644
--- a/dbbackup/utils.py
+++ b/dbbackup/utils.py
@@ -86,9 +86,7 @@ def handle_size(filehandle):
     return bytes_to_str(filehandle.tell())
 
 
-def mail_admins(
-    subject, message, fail_silently=False, connection=None, html_message=None
-):
+def mail_admins(subject, message, fail_silently=False, connection=None, html_message=None):
     """Sends a message to the admins, as defined by the DBBACKUP_ADMINS setting."""
     if not settings.ADMINS:
         return
@@ -144,9 +142,7 @@ def create_spooled_temporary_file(filepath=None, fileobj=None):
     :returns: Spooled temporary file
     :rtype: :class:`tempfile.SpooledTemporaryFile`
     """
-    spooled_file = tempfile.SpooledTemporaryFile(
-        max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR
-    )
+    spooled_file = tempfile.SpooledTemporaryFile(max_size=settings.TMP_FILE_MAX_SIZE, dir=settings.TMP_DIR)
     if filepath:
         fileobj = open(filepath, "r+b")
     if fileobj is not None:
@@ -175,9 +171,7 @@ def encrypt_file(inputfile, filename):
         filename = f"{filename}.gpg"
         filepath = os.path.join(tempdir, filename)
         if "b" not in inputfile.mode:
-            raise ValueError(
-                "Input file must be opened in binary mode."
-            )
+            raise ValueError("Input file must be opened in binary mode.")
         try:
             inputfile.seek(0)
             always_trust = settings.GPG_ALWAYS_TRUST
@@ -391,9 +385,7 @@ def filename_to_date(filename, datefmt=None):
         return datetime.strptime(datestring, datefmt)
 
 
-def filename_generate(
-    extension, database_name="", servername=None, content_type="db", wildcard=None
-):
+def filename_generate(extension, database_name="", servername=None, content_type="db", wildcard=None):
     """
     Create a new backup filename.
 


Test patch:
diff --git a/tests/functional/test_commands.py b/tests/functional/test_commands.py
index 41edf836..0d02eae0 100644
--- a/tests/functional/test_commands.py
+++ b/tests/functional/test_commands.py
@@ -201,7 +201,7 @@ def setUp(self):
 
     def tearDown(self):
         clean_gpg_keys()
-        self._emtpy_media()
+        self._empty_media()
 
     def _create_file(self, name=None):
         name = name or tempfile._RandomNameSequence().next()
@@ -209,9 +209,12 @@ def _create_file(self, name=None):
         with open(path, "a+b") as fd:
             fd.write(b"foo")
 
-    def _emtpy_media(self):
-        for fi in os.listdir(settings.MEDIA_ROOT):
-            os.remove(os.path.join(settings.MEDIA_ROOT, fi))
+    def _empty_media(self):
+        import shutil
+
+        if os.path.exists(settings.MEDIA_ROOT):
+            shutil.rmtree(settings.MEDIA_ROOT)
+        os.makedirs(settings.MEDIA_ROOT, exist_ok=True)
 
     def _is_restored(self):
         return bool(os.listdir(settings.MEDIA_ROOT))
@@ -220,7 +223,7 @@ def test_restore(self, *args):
         # Create backup
         self._create_file("foo")
         execute_from_command_line(["", "mediabackup"])
-        self._emtpy_media()
+        self._empty_media()
         # Restore
         execute_from_command_line(["", "mediarestore"])
         self.assertTrue(self._is_restored())
@@ -230,7 +233,7 @@ def test_encrypted(self, *args):
         # Create backup
         self._create_file("foo")
         execute_from_command_line(["", "mediabackup", "--encrypt"])
-        self._emtpy_media()
+        self._empty_media()
         # Restore
         execute_from_command_line(["", "mediarestore", "--decrypt"])
         self.assertTrue(self._is_restored())
@@ -239,7 +242,7 @@ def test_compressed(self, *args):
         # Create backup
         self._create_file("foo")
         execute_from_command_line(["", "mediabackup", "--compress"])
-        self._emtpy_media()
+        self._empty_media()
         # Restore
         execute_from_command_line(["", "mediarestore", "--uncompress"])
         self.assertTrue(self._is_restored())
@@ -262,3 +265,29 @@ def test_available_but_not_compressed(self, *args):
         # Restore
         with self.assertRaises(SystemExit):
             execute_from_command_line(["", "mediarestore", "--uncompress"])
+
+    def test_restore_file_with_media_in_path(self, *args):
+        """Test that files with 'media' in their path are restored correctly."""
+        # Create a file with 'media' in the path - this was the bug
+        media_subdir = os.path.join(settings.MEDIA_ROOT, "uploads", "media", "images")
+        os.makedirs(media_subdir, exist_ok=True)
+        test_file = os.path.join(media_subdir, "test.jpg")
+        with open(test_file, "w") as f:
+            f.write("test image content")
+
+        # Create backup
+        execute_from_command_line(["", "mediabackup"])
+
+        # Remove the file and directory structure
+        os.remove(test_file)
+        shutil.rmtree(media_subdir)
+
+        # Restore backup
+        execute_from_command_line(["", "mediarestore"])
+
+        # Verify file was restored to correct location (not corrupted path)
+        self.assertTrue(os.path.exists(test_file), "File should be restored to original path with 'media' in it")
+
+        # Verify content is correct
+        with open(test_file, "r") as f:
+            self.assertEqual(f.read(), "test image content")