Repository: oppia/oppia

Issue:
[BUG]: Strip non-standard characters from filenames when downloading explorations.
### Describe the bug

When downloading an exploration as a zip file, the YAML file in it is named based on the exploration's title, but this can cause issues if there is punctuation in the filename (e.g. `What is a Fraction?.yaml`). 

The aim of this issue is to strip all non-standard from the filename in the yaml file bundled within the zip download.

### Steps To Reproduce

1. Create a new exploration with punctuation in its title, e.g. "What is a Fraction?"
2. Go to the History tab in the exploration editor, and click the three-dot menu on the right of the latest version.
3. Select "Download".
4. Open the zip file, and observe that the yaml file name contains the punctuation entered in step 1.

### Expected Behavior

The name of the YAML file in the downloaded zip should only have [A-Za-z0-9_-] and spaces. If the name is empty after stripping, the yaml file should just be named `exploration.yaml`.

### Screenshots/Videos

_No response_

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Chrome

### Tips for developers

If this is your first issue, please make sure to follow https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue and https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#setting-things-up before claiming it. Thanks!

[BUG]: Strip non-standard characters from filenames when downloading explorations.
### Describe the bug

When downloading an exploration as a zip file, the YAML file in it is named based on the exploration's title, but this can cause issues if there is punctuation in the filename (e.g. `What is a Fraction?.yaml`). 

The aim of this issue is to strip all non-standard from the filename in the yaml file bundled within the zip download.

### Steps To Reproduce

1. Create a new exploration with punctuation in its title, e.g. "What is a Fraction?"
2. Go to the History tab in the exploration editor, and click the three-dot menu on the right of the latest version.
3. Select "Download".
4. Open the zip file, and observe that the yaml file name contains the punctuation entered in step 1.

### Expected Behavior

The name of the YAML file in the downloaded zip should only have [A-Za-z0-9_-] and spaces. If the name is empty after stripping, the yaml file should just be named `exploration.yaml`.

### Screenshots/Videos

_No response_

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Chrome

### Tips for developers

If this is your first issue, please make sure to follow https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue and https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#setting-things-up before claiming it. Thanks!



Discussion:

Resolving patch:
diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py
--- a/core/domain/exp_services.py
+++ b/core/domain/exp_services.py
@@ -31,6 +31,7 @@
 import math
 import os
 import pprint
+import re
 import zipfile
 
 from core import android_validation_constants
@@ -395,7 +396,14 @@ def export_to_zip_file(
         if not exploration.title:
             zfile.writestr('Unpublished_exploration.yaml', yaml_repr)
         else:
-            zfile.writestr('%s.yaml' % exploration.title, yaml_repr)
+            exploration_file_name = re.sub(
+                r'[^A-Za-z0-9_ -]+', '', exploration.title)
+            # Trim whitespace when checking to handle potential
+            # whitespace-only 'exploration_file_name'.
+            if not exploration_file_name.strip():
+                zfile.writestr('exploration.yaml', yaml_repr)
+            else:
+                zfile.writestr('%s.yaml' % exploration_file_name, yaml_repr)
 
         fs = fs_services.GcsFileSystem(
             feconf.ENTITY_TYPE_EXPLORATION, exploration_id)


Test patch:
diff --git a/core/controllers/editor_test.py b/core/controllers/editor_test.py
--- a/core/controllers/editor_test.py
+++ b/core/controllers/editor_test.py
@@ -723,7 +723,7 @@ def test_exploration_download_handler_for_default_exploration(self) -> None:
         zf_saved = zipfile.ZipFile(io.BytesIO(response.body))
         self.assertEqual(
             zf_saved.namelist(),
-            ['The title for ZIP download handler test!.yaml'])
+            ['The title for ZIP download handler test.yaml'])
 
         # Load golden zip file.
         golden_zip_filepath = os.path.join(
@@ -736,7 +736,7 @@ def test_exploration_download_handler_for_default_exploration(self) -> None:
         # Compare saved with golden file.
         self.assertEqual(
             zf_saved.open(
-                'The title for ZIP download handler test!.yaml').read(),
+                'The title for ZIP download handler test.yaml').read(),
             zf_gold.open(
                 'The title for ZIP download handler test!.yaml').read())
 
@@ -791,7 +791,7 @@ def test_exploration_download_handler_with_unicode_title(self) -> None:
             'attachment; filename=%s' % filename)
 
         zf_saved = zipfile.ZipFile(io.BytesIO(response.body))
-        self.assertEqual(zf_saved.namelist(), [u'¡Hola!.yaml'])
+        self.assertEqual(zf_saved.namelist(), [u'Hola.yaml'])
 
         self.logout()
 
diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py
--- a/core/domain/exp_services_test.py
+++ b/core/domain/exp_services_test.py
@@ -2841,6 +2841,26 @@ def test_export_to_zip_file_with_unpublished_exploration(self) -> None:
 
         self.assertEqual(zf.namelist(), ['Unpublished_exploration.yaml'])
 
+    def test_export_to_zip_file_with_a_nonstandard_char(self) -> None:
+        """Test the export_to_zip_file() method with a nonstandard char."""
+        self.save_new_default_exploration(
+            self.EXP_0_ID, self.owner_id, title='What is a Fraction?')
+
+        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
+        zf = zipfile.ZipFile(zip_file_output)
+
+        self.assertEqual(zf.namelist(), ['What is a Fraction.yaml'])
+
+    def test_export_to_zip_file_with_all_nonstandard_chars(self) -> None:
+        """Test the export_to_zip_file() method with all nonstandard chars."""
+        self.save_new_default_exploration(
+            self.EXP_0_ID, self.owner_id, title='?!!!!!?')
+
+        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
+        zf = zipfile.ZipFile(zip_file_output)
+
+        self.assertEqual(zf.namelist(), ['exploration.yaml'])
+
     def test_export_to_zip_file_with_assets(self) -> None:
         """Test exporting an exploration with assets to a zip file."""
         exploration = self.save_new_valid_exploration(