From 704f7db53a52bf60b1e8e3a5d55dd0ce692b9489 Mon Sep 17 00:00:00 2001
From: Chris Caron <lead2gold@gmail.com>
Date: Fri, 17 Feb 2023 16:29:22 -0500
Subject: [PATCH] Added attach-as option to form:// for upstream filename
 over-ride (#827)

---
 apprise/plugins/NotifyForm.py   | 84 +++++++++++++++++++++++++++++++--
 apprise/plugins/NotifyJSON.py   |  6 ++-
 apprise/plugins/NotifyVoipms.py |  7 +--
 apprise/plugins/NotifyXML.py    |  6 ++-
 test/helpers/rest.py            | 12 ++++-
 test/test_plugin_custom_form.py | 70 +++++++++++++++++++++++++++
 test/test_plugin_custom_json.py |  3 ++
 test/test_plugin_custom_xml.py  |  3 ++
 8 files changed, 178 insertions(+), 13 deletions(-)

diff --git a/apprise/plugins/NotifyForm.py b/apprise/plugins/NotifyForm.py
index ccf7e3a5..b14ae5ef 100644
--- a/apprise/plugins/NotifyForm.py
+++ b/apprise/plugins/NotifyForm.py
@@ -30,6 +30,7 @@
 # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 # POSSIBILITY OF SUCH DAMAGE.
 
+import re
 import requests
 
 from .NotifyBase import NotifyBase
@@ -45,7 +46,8 @@ METHODS = (
     'GET',
     'DELETE',
     'PUT',
-    'HEAD'
+    'HEAD',
+    'PATCH'
 )
 
 
@@ -54,6 +56,27 @@ class NotifyForm(NotifyBase):
     A wrapper for Form Notifications
     """
 
+    # Support
+    # - file*
+    # - file?
+    # - file*name
+    # - file?name
+    # - ?file
+    # - *file
+    # - file
+    # The code will convert the ? or * to the digit increments
+    __attach_as_re = re.compile(
+        r'((?P<match1>(?P<id1a>[a-z0-9_-]+)?'
+        r'(?P<wc1>[*?+$:.%]+)(?P<id1b>[a-z0-9_-]+))'
+        r'|(?P<match2>(?P<id2>[a-z0-9_-]+)(?P<wc2>[*?+$:.%]?)))',
+        re.IGNORECASE)
+
+    # Our count
+    attach_as_count = '{:02d}'
+
+    # the default attach_as value
+    attach_as_default = f'file{attach_as_count}'
+
     # The default descriptive name associated with the Notification
     service_name = 'Form'
 
@@ -118,6 +141,12 @@ class NotifyForm(NotifyBase):
             'values': METHODS,
             'default': METHODS[0],
         },
+        'attach-as': {
+            'name': _('Attach File As'),
+            'type': 'string',
+            'default': 'file*',
+            'map_to': 'attach_as',
+        },
     })
 
     # Define any kwargs we're using
@@ -137,7 +166,7 @@ class NotifyForm(NotifyBase):
     }
 
     def __init__(self, headers=None, method=None, payload=None, params=None,
-                 **kwargs):
+                 attach_as=None, **kwargs):
         """
         Initialize Form Object
 
@@ -159,6 +188,36 @@ class NotifyForm(NotifyBase):
             self.logger.warning(msg)
             raise TypeError(msg)
 
+        # Custom File Attachment Over-Ride Support
+        if not isinstance(attach_as, str):
+            # Default value
+            self.attach_as = self.attach_as_default
+            self.attach_multi_support = True
+
+        else:
+            result = self.__attach_as_re.match(attach_as.strip())
+            if not result:
+                msg = 'The attach-as specified ({}) is invalid.'.format(
+                    attach_as)
+                self.logger.warning(msg)
+                raise TypeError(msg)
+
+            self.attach_as = ''
+            self.attach_multi_support = False
+            if result.group('match1'):
+                if result.group('id1a'):
+                    self.attach_as += result.group('id1a')
+
+                self.attach_as += self.attach_as_count
+                self.attach_multi_support = True
+                self.attach_as += result.group('id1b')
+
+            else:  # result.group('match2'):
+                self.attach_as += result.group('id2')
+                if result.group('wc2'):
+                    self.attach_as += self.attach_as_count
+                    self.attach_multi_support = True
+
         self.params = {}
         if params:
             # Store our extra headers
@@ -199,6 +258,10 @@ class NotifyForm(NotifyBase):
         params.update(
             {':{}'.format(k): v for k, v in self.payload_extras.items()})
 
+        if self.attach_as != self.attach_as_default:
+            # Provide Attach-As extension details
+            params['attach-as'] = self.attach_as
+
         # Determine Authentication
         auth = ''
         if self.user and self.password:
@@ -254,7 +317,8 @@ class NotifyForm(NotifyBase):
 
                 try:
                     files.append((
-                        'file{:02d}'.format(no), (
+                        self.attach_as.format(no)
+                        if self.attach_multi_support else self.attach_as, (
                             attachment.name,
                             open(attachment.path, 'rb'),
                             attachment.mimetype)
@@ -267,6 +331,11 @@ class NotifyForm(NotifyBase):
                     self.logger.debug('I/O Exception: %s' % str(e))
                     return False
 
+            if not self.attach_multi_support and no > 1:
+                self.logger.warning(
+                    'Multiple attachments provided while '
+                    'form:// Multi-Attachment Support not enabled')
+
         # prepare Form Object
         payload = {
             # Version: Major.Minor,  Major is only updated if the entire
@@ -309,6 +378,9 @@ class NotifyForm(NotifyBase):
         elif self.method == 'PUT':
             method = requests.put
 
+        elif self.method == 'PATCH':
+            method = requests.patch
+
         elif self.method == 'DELETE':
             method = requests.delete
 
@@ -397,6 +469,12 @@ class NotifyForm(NotifyBase):
         results['params'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
                              for x, y in results['qsd-'].items()}
 
+        # Allow Attach-As Support which over-rides the name of the filename
+        # posted with the form://
+        # the default is file01, file02, file03, etc
+        if 'attach-as' in results['qsd'] and len(results['qsd']['attach-as']):
+            results['attach_as'] = results['qsd']['attach-as']
+
         # Set method if not otherwise set
         if 'method' in results['qsd'] and len(results['qsd']['method']):
             results['method'] = NotifyForm.unquote(results['qsd']['method'])
diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py
index 7af4939b..509c7627 100644
--- a/apprise/plugins/NotifyJSON.py
+++ b/apprise/plugins/NotifyJSON.py
@@ -47,7 +47,8 @@ METHODS = (
     'GET',
     'DELETE',
     'PUT',
-    'HEAD'
+    'HEAD',
+    'PATCH'
 )
 
 
@@ -315,6 +316,9 @@ class NotifyJSON(NotifyBase):
         elif self.method == 'PUT':
             method = requests.put
 
+        elif self.method == 'PATCH':
+            method = requests.patch
+
         elif self.method == 'DELETE':
             method = requests.delete
 
diff --git a/apprise/plugins/NotifyVoipms.py b/apprise/plugins/NotifyVoipms.py
index 8580ce50..42379b6b 100644
--- a/apprise/plugins/NotifyVoipms.py
+++ b/apprise/plugins/NotifyVoipms.py
@@ -316,12 +316,7 @@ class NotifyVoipms(NotifyBase):
         """
 
         # Define any URL parameters
-        params = {
-            'method': 'sendSMS'
-        }
-
-        # Extend our parameters
-        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+        params = self.url_parameters(privacy=privacy, *args, **kwargs)
 
         schemaStr =  \
             '{schema}://{password}:{email}/{from_phone}/{targets}/?{params}'
diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py
index 8f71892e..bbb3046a 100644
--- a/apprise/plugins/NotifyXML.py
+++ b/apprise/plugins/NotifyXML.py
@@ -47,7 +47,8 @@ METHODS = (
     'GET',
     'DELETE',
     'PUT',
-    'HEAD'
+    'HEAD',
+    'PATCH'
 )
 
 
@@ -367,6 +368,9 @@ class NotifyXML(NotifyBase):
         elif self.method == 'PUT':
             method = requests.put
 
+        elif self.method == 'PATCH':
+            method = requests.patch
+
         elif self.method == 'DELETE':
             method = requests.delete
 
diff --git a/test/helpers/rest.py b/test/helpers/rest.py
index 624dfcd1..672a6b7e 100644
--- a/test/helpers/rest.py
+++ b/test/helpers/rest.py
@@ -268,8 +268,9 @@ class AppriseURLTester:
     @mock.patch('requests.head')
     @mock.patch('requests.put')
     @mock.patch('requests.delete')
-    def __notify(self, url, obj, meta, asset, mock_del, mock_put, mock_head,
-                 mock_post, mock_get):
+    @mock.patch('requests.patch')
+    def __notify(self, url, obj, meta, asset, mock_patch, mock_del, mock_put,
+                 mock_head, mock_post, mock_get):
         """
         Perform notification testing against object specified
         """
@@ -326,6 +327,7 @@ class AppriseURLTester:
         mock_get.return_value = robj
         mock_post.return_value = robj
         mock_head.return_value = robj
+        mock_patch.return_value = robj
         mock_del.return_value = robj
         mock_put.return_value = robj
 
@@ -336,6 +338,7 @@ class AppriseURLTester:
             mock_del.return_value.status_code = requests_response_code
             mock_post.return_value.status_code = requests_response_code
             mock_get.return_value.status_code = requests_response_code
+            mock_patch.return_value.status_code = requests_response_code
 
             # Handle our default text response
             mock_get.return_value.content = requests_response_content
@@ -343,12 +346,14 @@ class AppriseURLTester:
             mock_del.return_value.content = requests_response_content
             mock_put.return_value.content = requests_response_content
             mock_head.return_value.content = requests_response_content
+            mock_patch.return_value.content = requests_response_content
 
             mock_get.return_value.text = requests_response_text
             mock_post.return_value.text = requests_response_text
             mock_put.return_value.text = requests_response_text
             mock_del.return_value.text = requests_response_text
             mock_head.return_value.text = requests_response_text
+            mock_patch.return_value.text = requests_response_text
 
             # Ensure there is no side effect set
             mock_post.side_effect = None
@@ -356,6 +361,7 @@ class AppriseURLTester:
             mock_put.side_effect = None
             mock_head.side_effect = None
             mock_get.side_effect = None
+            mock_patch.side_effect = None
 
         else:
             # Handle exception testing; first we turn the boolean flag
@@ -454,6 +460,7 @@ class AppriseURLTester:
                     mock_del.side_effect = _exception
                     mock_put.side_effect = _exception
                     mock_get.side_effect = _exception
+                    mock_patch.side_effect = _exception
 
                     try:
                         assert obj.notify(
@@ -498,6 +505,7 @@ class AppriseURLTester:
                     mock_put.side_effect = _exception
                     mock_head.side_effect = _exception
                     mock_get.side_effect = _exception
+                    mock_patch.side_effect = _exception
 
                     try:
                         assert obj.notify(
diff --git a/test/test_plugin_custom_form.py b/test/test_plugin_custom_form.py
index bee440ff..138a0196 100644
--- a/test/test_plugin_custom_form.py
+++ b/test/test_plugin_custom_form.py
@@ -91,6 +91,9 @@ apprise_url_tests = (
     ('form://user@localhost?method=delete', {
         'instance': NotifyForm,
     }),
+    ('form://user@localhost?method=patch', {
+        'instance': NotifyForm,
+    }),
 
     # Custom payload options
     ('form://localhost:8080?:key=value&:key2=value2', {
@@ -230,6 +233,73 @@ def test_plugin_custom_form_attachments(mock_post):
         body='body', title='title', notify_type=NotifyType.INFO,
         attach=attach) is False
 
+    #
+    # Test attach-as
+    #
+
+    # Assign our mock object our return value
+    mock_post.return_value = okay_response
+    mock_post.side_effect = None
+
+    obj = Apprise.instantiate(
+        'form://user@localhost.localdomain/?attach-as=file')
+    assert isinstance(obj, NotifyForm)
+
+    # Test Single Valid Attachment
+    path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
+    attach = AppriseAttachment(path)
+    assert obj.notify(
+        body='body', title='title', notify_type=NotifyType.INFO,
+        attach=attach) is True
+
+    # Test Valid Attachment (load 3) (produces a warning)
+    path = (
+        os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+        os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+        os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+    )
+    attach = AppriseAttachment(path)
+    assert obj.notify(
+        body='body', title='title', notify_type=NotifyType.INFO,
+        attach=attach) is True
+
+    # Test our other variations of accepted values
+    # we support *, :, ?, ., +, %, and $
+    for attach_as in (
+            'file*', '*file', 'file*file',
+            'file:', ':file', 'file:file',
+            'file?', '?file', 'file?file',
+            'file.', '.file', 'file.file',
+            'file+', '+file', 'file+file',
+            'file$', '$file', 'file$file'):
+
+        obj = Apprise.instantiate(
+            f'form://user@localhost.localdomain/?attach-as={attach_as}')
+        assert isinstance(obj, NotifyForm)
+
+        # Test Single Valid Attachment
+        path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
+        attach = AppriseAttachment(path)
+        assert obj.notify(
+            body='body', title='title', notify_type=NotifyType.INFO,
+            attach=attach) is True
+
+        # Test Valid Attachment (load 3) (produces a warning)
+        path = (
+            os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+            os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+            os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+        )
+        attach = AppriseAttachment(path)
+        assert obj.notify(
+            body='body', title='title', notify_type=NotifyType.INFO,
+            attach=attach) is True
+
+    # Test invalid attach-as input
+    obj = Apprise.instantiate(
+        'form://user@localhost.localdomain/?attach-as={')
+    assert obj is None
+
 
 @mock.patch('requests.post')
 @mock.patch('requests.get')
diff --git a/test/test_plugin_custom_json.py b/test/test_plugin_custom_json.py
index ccbb2135..459776ef 100644
--- a/test/test_plugin_custom_json.py
+++ b/test/test_plugin_custom_json.py
@@ -93,6 +93,9 @@ apprise_url_tests = (
     ('json://user@localhost?method=delete', {
         'instance': NotifyJSON,
     }),
+    ('json://user@localhost?method=patch', {
+        'instance': NotifyJSON,
+    }),
 
     # Continue testing other cases
     ('json://localhost:8080', {
diff --git a/test/test_plugin_custom_xml.py b/test/test_plugin_custom_xml.py
index ea0e7b07..69fc14bf 100644
--- a/test/test_plugin_custom_xml.py
+++ b/test/test_plugin_custom_xml.py
@@ -92,6 +92,9 @@ apprise_url_tests = (
     ('xml://user@localhost?method=delete', {
         'instance': NotifyXML,
     }),
+    ('xml://user@localhost?method=patch', {
+        'instance': NotifyXML,
+    }),
 
     # Continue testing other cases
     ('xml://localhost:8080', {