From a1db6e0b2f19e14d1165156d16ed412c8fa2d286 Mon Sep 17 00:00:00 2001
From: Thomas NOEL <tnoel@entrouvert.com>
Date: Thu, 15 Oct 2020 15:45:18 +0200
Subject: [PATCH] misc: accept non-local storage as attachments in evolution
 (#47704)

---
 tests/test_upload_storage.py | 65 ++++++++++++++++++++++++++++++++++
 wcs/qommon/upload_storage.py |  2 +-
 wcs/workflows.py             | 68 +++++++++++++++++++++++++-----------
 3 files changed, 113 insertions(+), 22 deletions(-)

diff --git a/tests/test_upload_storage.py b/tests/test_upload_storage.py
index 73ac4c88..334927db 100644
--- a/tests/test_upload_storage.py
+++ b/tests/test_upload_storage.py
@@ -11,6 +11,7 @@ from wcs.qommon.ident.password_accounts import PasswordAccount
 from wcs.formdef import FormDef
 from wcs.categories import Category
 from wcs import fields
+from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
 
 from utilities import get_app, login, create_temporary_pub, clean_temporary_pub
 
@@ -221,3 +222,67 @@ def test_form_file_field_upload_storage(wscall, pub):
         'redirect_url': 'https://crypto.example.net/',
         'file_size': 1834
     }
+
+
+@mock.patch('wcs.wscalls.call_webservice')
+def test_remoteopaque_in_attachmentevolutionpart(wscall, pub):
+    create_user_and_admin(pub)
+    formdef = create_formdef()
+    formdef.fields[1].storage = 'remote-bo'
+    formdef.store()
+    formdef.data_class().wipe()
+
+    wscall.return_value = None, 200, json.dumps(
+            {"err": 0, "data": {"redirect_url": "https://crypto.example.net/"}})
+
+    image_content = open(os.path.join(os.path.dirname(__file__),
+        'image-with-gps-data.jpeg'), 'rb').read()
+    upload_0 = Upload('local-file.jpg', image_content, 'image/jpeg')
+    upload_1 = Upload('remote-file.jpg', image_content, 'image/jpeg')
+
+    user_app = login(get_app(pub), username='foo', password='foo')
+    admin_app = login(get_app(pub), username='admin', password='admin')
+
+    resp = user_app.get('/test/')
+    resp.forms[0]['f0$file'] = upload_0
+    resp.forms[0]['f1$file'] = upload_1
+    resp = resp.forms[0].submit('submit')
+    assert 'Check values then click submit.' in resp.text
+    resp = resp.forms[0].submit('submit')
+    assert resp.status_int == 302
+    resp = resp.follow()
+    assert 'The form has been recorded' in resp.text
+
+    # register a comment = create a AttachmentEvolutionPart
+    formdata = formdef.data_class().select()[0]
+    item = RegisterCommenterWorkflowStatusItem()
+    item.attachments = ['form_var_file_raw', 'form_var_remote_file_raw']
+    item.comment = 'text in form history'
+    item.perform(formdata)
+
+    # links on frontoffice: no link to remote file
+    resp = user_app.get('/test/%s/' % formdata.id)
+    assert resp.text.count('<p class="wf-attachment"><a href="attachment?f=') == 1
+    assert resp.text.count('<p class="wf-attachment"><a href="attachment?f=uuid-') == 0
+    # links on backoffice: links to local and remote file
+    resp = admin_app.get('/backoffice/management/test/%s/' % formdata.id)
+    assert resp.text.count('<p class="wf-attachment"><a href="attachment?f=') == 2
+    assert resp.text.count('<p class="wf-attachment"><a href="attachment?f=uuid-') == 1
+
+    local_file = formdata.evolution[-1].parts[0]
+    local_file_id = os.path.basename(local_file.filename)
+    remote_file = formdata.evolution[-1].parts[1]
+    remote_file_id = remote_file.filename
+    assert not local_file_id.startswith('uuid-')
+    assert remote_file_id.startswith('uuid-')
+
+    # clic on remote file in frontoffice: redirect... but forbidden
+    resp = user_app.get('/test/%s/attachment?f=%s' % (formdata.id, remote_file_id))
+    assert resp.status_int == 302
+    resp = resp.follow(status=404)
+    # clic in backoffice, redirect to decryption system
+    resp = admin_app.get('/backoffice/management/test/%s/attachment?f=%s' % (formdata.id, remote_file_id))
+    assert resp.status_int == 302
+    resp = resp.follow()
+    assert resp.location.startswith('https://crypto.example.net/')
+    assert '&signature=' in resp.location
diff --git a/wcs/qommon/upload_storage.py b/wcs/qommon/upload_storage.py
index 3005fbe7..5c0716a8 100644
--- a/wcs/qommon/upload_storage.py
+++ b/wcs/qommon/upload_storage.py
@@ -41,7 +41,7 @@ class PicklableUpload(Upload):
     def get_file_pointer(self):
         if 'fp' in self.__dict__ and self.__dict__.get('fp') is not None:
             return self.__dict__.get('fp')
-        elif hasattr(self, 'qfilename'):
+        elif getattr(self, 'qfilename', None):
             basedir = os.path.join(get_publisher().app_dir, 'uploads')
             self.fp = open(os.path.join(basedir, self.qfilename), 'rb')
             return self.fp
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 4d4fc6e1..60a320a0 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -39,6 +39,7 @@ from .qommon import emails, get_cfg, get_logger
 from quixote.html import htmltext
 from .qommon import errors
 from .qommon.template import Template, TemplateError
+from .qommon.upload_storage import PicklableUpload, get_storage_object
 
 from .conditions import Condition
 from .roles import Role, logged_users_role, get_user_roles
@@ -117,7 +118,10 @@ class AttachmentSubstitutionProxy(object):
 
     @property
     def content(self):
-        return self.attachment_evolution_part.get_file_pointer().read()
+        fp = self.attachment_evolution_part.get_file_pointer()
+        if fp:
+            return fp.read()
+        return b''
 
     @property
     def b64_content(self):
@@ -168,32 +172,45 @@ class AttachmentEvolutionPart: #pylint: disable=C1001
     content_type = None
     charset = None
     varname = None
+    storage = None
+    storage_attrs = None
 
     def __init__(self, base_filename, fp, orig_filename=None, content_type=None,
-            charset=None, varname=None):
+            charset=None, varname=None, storage=None, storage_attrs=None):
         self.base_filename = base_filename
         self.orig_filename = orig_filename or base_filename
         self.content_type = content_type
         self.charset = charset
         self.fp = fp
         self.varname = varname
+        self.storage = storage
+        self.storage_attrs = storage_attrs
 
     @classmethod
     def from_upload(cls, upload, varname=None):
         return AttachmentEvolutionPart(
                 upload.base_filename,
-                upload.fp,
+                getattr(upload, 'fp', None),
                 upload.orig_filename,
                 upload.content_type,
                 upload.charset,
-                varname=varname)
+                varname=varname,
+                storage=getattr(upload, 'storage', None),
+                storage_attrs=getattr(upload, 'storage_attrs', None))
 
     def get_file_pointer(self):
+        if self.filename.startswith('uuid-'):
+            return None
         return open(self.filename, 'rb')
 
     def __getstate__(self):
         odict = self.__dict__.copy()
-        if not 'fp' in odict:
+        if not odict.get('fp'):
+            if 'filename' not in odict:
+                # we need a filename as an identifier: create one from nothing
+                # instead of file_digest(self.fp) (see below)
+                odict['filename'] = 'uuid-%s' % uuid.uuid4()
+                self.filename = odict['filename']
             return odict
 
         del odict['fp']
@@ -201,7 +218,8 @@ class AttachmentEvolutionPart: #pylint: disable=C1001
         if not os.path.exists(dirname):
             os.mkdir(dirname)
 
-        if not 'filename' in odict:
+        # there is not filename, or it was a temporary one: create it
+        if not 'filename' in odict or odict['filename'].startswith('uuid-'):
             filename = file_digest(self.fp)
             dirname = os.path.join(dirname, filename[:4])
             if not os.path.exists(dirname):
@@ -213,8 +231,15 @@ class AttachmentEvolutionPart: #pylint: disable=C1001
         return odict
 
     def view(self):
-        return htmltext('<p class="wf-attachment"><a href="attachment?f=%s">%s</a>' % (
-                    os.path.basename(self.filename), self.orig_filename))
+        show_link = True
+        if self.has_redirect_url():
+            is_in_backoffice = bool(get_request() and get_request().is_in_backoffice())
+            show_link = bool(self.get_redirect_url(backoffice=is_in_backoffice))
+        if show_link:
+            return htmltext('<p class="wf-attachment"><a href="attachment?f=%s">%s</a></p>' % (
+                        os.path.basename(self.filename), self.orig_filename))
+        else:
+            return htmltext('<p class="wf-attachment">%s</p>' % self.orig_filename)
 
     @classmethod
     def get_substitution_variables(cls, formdata):
@@ -224,14 +249,14 @@ class AttachmentEvolutionPart: #pylint: disable=C1001
     # mimic PicklableUpload methods:
 
     def can_thumbnail(self):
-        return True
+        return get_storage_object(getattr(self, 'storage', None)).can_thumbnail(self)
 
     def has_redirect_url(self):
-        return False
+        return get_storage_object(getattr(self, 'storage', None)).has_redirect_url(self)
 
-    def get_redirect_url(self, upload, backoffice=False):
-        # should never be called, has_redirect_url is False
-        raise AssertionError('no get_redirect_url on AttachmentEvolutionPart object')
+    def get_redirect_url(self, backoffice=False):
+        return get_storage_object(getattr(self, 'storage', None)).get_redirect_url(self,
+                backoffice=backoffice)
 
 
 class DuplicateGlobalActionNameError(Exception):
@@ -2179,14 +2204,15 @@ class WorkflowStatusItem(XmlSerialisable):
                 if not picklableupload:
                     continue
 
-                try:
-                    # convert any value to a PicklableUpload; it will ususally
-                    # be a dict like one provided by qommon/evalutils:attachment()
-                    picklableupload = FileField.convert_value_from_anything(picklableupload)
-                except ValueError:
-                    get_publisher().notify_of_exception(sys.exc_info(),
-                                                        context='[workflow/attachments]')
-                    continue
+                if not isinstance(picklableupload, PicklableUpload):
+                    try:
+                        # convert any value to a PicklableUpload; it will ususally
+                        # be a dict like one provided by qommon/evalutils:attachment()
+                        picklableupload = FileField.convert_value_from_anything(picklableupload)
+                    except ValueError:
+                        get_publisher().notify_of_exception(sys.exc_info(),
+                                                            context='[workflow/attachments]')
+                        continue
 
                 uploads.append(picklableupload)
 
-- 
2.28.0

