]> git.ipfire.org Git - thirdparty/python-fints.git/commitdiff
Allow pausing and resuming of dialogs
authorHenryk Plötz <henryk@ploetzli.ch>
Thu, 23 Aug 2018 23:33:35 +0000 (01:33 +0200)
committerRaphael Michel <mail@raphaelmichel.de>
Mon, 3 Dec 2018 18:34:29 +0000 (19:34 +0100)
fints/client.py
fints/dialog.py
fints/utils.py

index f0fb811b187ea15a4fffcdc8539d4b09d8700460..53b3a0a1edbc29e0524130159b57132a31b89aeb 100644 (file)
@@ -1,9 +1,7 @@
 import datetime
 import logging
-import base64
-import zlib
-import json
 from decimal import Decimal
+from contextlib import contextmanager
 from collections import OrderedDict
 
 from fints.segments.debit import HKDME, HKDSE
@@ -35,7 +33,7 @@ from .segments.saldo import HKSAL5, HKSAL6, HKSAL7
 from .segments.statement import HKKAZ5, HKKAZ6, HKKAZ7
 from .segments.transfer import HKCCM, HKCCS
 from .types import SegmentSequence
-from .utils import MT535_Miniparser, Password, mt940_to_array
+from .utils import MT535_Miniparser, Password, mt940_to_array, compress_datablob, decompress_datablob
 from .parser import FinTS3Serializer
 
 logger = logging.getLogger(__name__)
@@ -106,25 +104,7 @@ class FinTS3Client:
 
         return self._new_dialog(lazy_init=lazy_init)
 
-    @staticmethod
-    def _compress_data_v1(data):
-        data = dict(data)
-        for k, v in data.items():
-            if k.endswith("_bin"):
-                if v:
-                    data[k] = base64.b64encode(v).decode("us-ascii")
-        serialized = json.dumps(data).encode('utf-8')
-        compressed = zlib.compress(serialized, 9)
-        return DATA_BLOB_MAGIC + b';1;' + compressed
-
-    def _set_data_v1(self, blob):
-        decompressed = zlib.decompress(blob)
-        data = json.loads(decompressed.decode('utf-8'))
-        for k, v in data.items():
-            if k.endswith("_bin"):
-                if v:
-                    data[k] = base64.b64decode(v.encode('us-ascii'))
-
+    def _set_data_v1(self, data):
         self.system_id = data.get('system_id', self.system_id)
 
         if all(x in data for x in ('bpd_bin', 'bpa_bin', 'bpd_version')):
@@ -161,24 +141,11 @@ class FinTS3Client:
                 "allowed_security_functions": self.allowed_security_functions,
             })
 
-        return self._compress_data_v1(data)
+        return compress_datablob(DATA_BLOB_MAGIC, 1, data)
 
     def set_data(self, blob):
         # FIXME Test, document
-        if not blob.startswith(DATA_BLOB_MAGIC):
-            raise ValueError("Incorrect data blob")
-        s = blob.split(b';', 2)
-        if len(s) != 3:
-            raise ValueError("Incorrect data blob")
-        if not s[1].isdigit():
-            raise ValueError("Incorrect data blob")
-        version = int(s[1].decode('us-ascii'), 10)
-
-        setfunc = getattr(self, "_set_data_v{}".format(version), None)
-        if not setfunc:
-            raise ValueError("Unknown data blob version")
-
-        setfunc(s[2])
+        decompress_datablob(DATA_BLOB_MAGIC, self, blob)
 
     def process_institute_response(self, message):
         bpa = message.find_segment_first(HIBPA3)
@@ -591,6 +558,57 @@ class FinTS3Client:
             for resp in response.response_segments(seg, 'HITAB'):
                 return resp.tan_usage_option, list(resp.tan_media_list)
 
+    def pause_dialog(self):
+        """Pause a standing dialog and return the saved dialog state.
+
+        Sometimes, for example in a web app, it's not possible to keep a context open
+        during user input. In some cases, though, it's required to send a response
+        within the same dialog that issued the original task (f.e. TAN with TANTimeDialogAssociation.NOT_ALLOWED).
+        This method freezes the current standing dialog (started with FinTS3Client.__enter__()) and
+        returns the frozen state.
+
+        Commands MUST NOT be issued in the dialog after calling this method.
+
+        MUST be used in conjunction with get_data()/set_data().
+
+        Caller SHOULD ensure that the dialog is resumed (and properly ended) within a reasonable amount of time.
+
+        :Example:
+            client = FinTS3PinTanClient(..., set_data=None)
+            with client:
+                challenge = client.start_sepa_transfer(...)
+
+                dialog_data = client.pause_dialog()
+
+                # dialog is now frozen, no new commands may be issued
+                # exiting the context does not end the dialog
+
+            client_data = client.get_data()
+
+            # Store dialog_data and client_data out-of-band somewhere
+            # ... Some time passes ...
+            # Later, possibly in a different process, restore the state
+
+            client = FinTS3PinTanClient(..., set_data=client_data)
+            with client.resume_dialog(dialog_data):
+                client.send_tan(...)
+
+                # Exiting the context here ends the dialog, unless frozen with pause_dialog() again.
+        """
+        if not self._standing_dialog:
+            raise Error("Cannot pause dialog, no standing dialog exists")
+        return self._standing_dialog.pause()
+
+    @contextmanager
+    def resume_dialog(self, dialog_data):
+        # FIXME document, test,    NOTE NO UNTRUSTED SOURCES
+        if self._standing_dialog:
+            raise Error("Cannot resume dialog, existing standing dialog")
+        self._standing_dialog = FinTSDialog.create_resume(self, dialog_data)
+        with self._standing_dialog:
+            yield self
+        self._standing_dialog = None
+
 
 class FinTS3PinTanClient(FinTS3Client):
 
index 80bc02556fcbdea87e168a36fc89cd04bc0d2a69..b528bb44e522aaea7d9aab61e2f887f1520b3070 100644 (file)
@@ -1,4 +1,6 @@
 import logging
+import pickle
+import io
 
 from .formals import (
     BankIdentifier, Language2, SynchronisationMode, SystemIDStatus,
@@ -9,10 +11,12 @@ from .message import (
 from .segments.auth import HKIDN, HKIDN2, HKSYN, HKVVB, HKVVB3
 from .segments.dialog import HKEND, HKEND1
 from .segments.message import HNHBK3, HNHBS1
+from .utils import compress_datablob, decompress_datablob
 
 logger = logging.getLogger(__name__)
 
 DIALOGUE_ID_UNASSIGNED = '0'
+DATA_BLOB_MAGIC = b'python-fints_DIALOG_DATABLOB'
 
 class FinTSDialogError(Exception):
     pass
@@ -29,6 +33,7 @@ class FinTSDialog:
         self.need_init = True
         self.lazy_init = lazy_init
         self.dialogue_id = DIALOGUE_ID_UNASSIGNED
+        self.paused = False
         self._context_count = 0
 
     def __enter__(self):
@@ -40,10 +45,14 @@ class FinTSDialog:
     
     def __exit__(self, exc_type, exc_value, traceback):
         self._context_count -= 1
-        if self._context_count == 0:
-            self.end()
+        if not self.paused:
+            if self._context_count == 0:
+                self.end()
 
     def init(self, *extra_segments):
+        if self.paused:
+            raise Error("Cannot init() a paused dialog")
+
         if self.need_init and not self.open:
             segments = [
                 HKIDN2(
@@ -75,11 +84,17 @@ class FinTSDialog:
                 self.lazy_init = False
 
     def end(self):
+        if self.paused:
+            raise Error("Cannot end() on a paused dialog")
+
         if self.open:
             self.send(HKEND1(self.dialogue_id))
             self.open = False
 
     def send(self, *segments):
+        if self.paused:
+            raise Error("Cannot send() on a paused dialog")
+
         if not self.open:
             if self.lazy_init and self.need_init:
                 self.init()
@@ -120,6 +135,9 @@ class FinTSDialog:
         return response
 
     def new_customer_message(self):
+        if self.paused:
+            raise Error("Cannot call new_customer_message() on a paused dialog")
+
         message = FinTSCustomerMessage(self)
         message += HNHBK3(0, 300, self.dialogue_id, self.next_message_number[message.DIRECTION])
         
@@ -129,6 +147,9 @@ class FinTSDialog:
         return message
 
     def finish_message(self, message):
+        if self.paused:
+            raise Error("Cannot call finish_message() on a paused dialog")
+
         # Create signature(s) in reverse order: from inner to outer
         for auth_mech in reversed(self.auth_mechanisms):
             auth_mech.sign_commit(message)
@@ -140,6 +161,64 @@ class FinTSDialog:
 
         message.segments[0].message_size = len(message.render_bytes())
 
+    def pause(self):
+        # FIXME Document, test
+        if self.paused:
+            raise Error("Cannot pause a paused dialog")
+
+        external_dialog = self
+        external_client = self.client
+        class SmartPickler(pickle.Pickler):
+            def persistent_id(self, obj):
+                if obj is external_dialog:
+                    return "dialog"
+                if obj is external_client:
+                    return "client"
+                return None
+
+        pickle_out = io.BytesIO()
+        SmartPickler(pickle_out, protocol=4).dump({
+            k: getattr(self, k) for k in [
+                'next_message_number',
+                'messages',
+                'auth_mechanisms',
+                'enc_mechanism',
+                'open',
+                'need_init',
+                'lazy_init',
+                'dialogue_id',
+            ]
+        })
+
+        data_pickled = pickle_out.getvalue()
+
+        self.paused = True
+
+        return compress_datablob(DATA_BLOB_MAGIC, 1, {'data_bin': data_pickled})
+
+    @classmethod
+    def create_resume(cls, client, blob):
+        retval = cls(client=client)
+        decompress_datablob(DATA_BLOB_MAGIC, retval, blob)
+        return retval
+
+    def _set_data_v1(self, data):
+        external_dialog = self
+        external_client = self.client
+        class SmartUnpickler(pickle.Unpickler):
+            def persistent_load(self, pid):
+                if pid == 'dialog':
+                    return external_dialog
+                if pid == 'client':
+                    return external_client
+                raise pickle.UnpicklingError("unsupported persistent object")
+
+        pickle_in = io.BytesIO(data['data_bin'])
+        data_unpickled = SmartUnpickler(pickle_in).load()
+
+        for k, v in data_unpickled.items():
+            setattr(self, k, v)
+
 
 class FinTSDialogOLD:
     def __init__(self, blz, username, pin, systemid, connection):
index 20a370ea7f0b03cdf500e98038a92a68999e4011..2a0fe39e852874d214db2690c64f3b66322540ea 100644 (file)
@@ -1,5 +1,8 @@
 import inspect
 import re
+import base64
+import json
+import zlib
 from contextlib import contextmanager
 from datetime import datetime
 from enum import Enum
@@ -57,6 +60,45 @@ def fints_unescape(content):
     return content.replace('??', '?').replace("?'", "'").replace('?+', '+').replace('?:', ':').replace('?@', '@')
 
 
+def compress_datablob(magic: bytes, version: int, data: dict):
+    data = dict(data)
+    for k, v in data.items():
+        if k.endswith("_bin"):
+            if v:
+                data[k] = base64.b64encode(v).decode("us-ascii")
+    serialized = json.dumps(data).encode('utf-8')
+    compressed = zlib.compress(serialized, 9)
+    return b';'.join([magic, b'1', str(version).encode('us-ascii'), compressed])
+
+
+def decompress_datablob(magic: bytes, obj: object, blob: bytes):
+    if not blob.startswith(magic):
+        raise ValueError("Incorrect data blob")
+    s = blob.split(b';', 3)
+    if len(s) != 4:
+        raise ValueError("Incorrect data blob")
+    if not s[1].isdigit() or not s[2].isdigit():
+        raise ValueError("Incorrect data blob")
+    encoding_version = int(s[1].decode('us-ascii'), 10)
+    blob_version = int(s[2].decode('us-ascii'), 10)
+
+    if encoding_version != 1:
+        raise ValueError("Unsupported encoding version {}".format(encoding_version))
+
+    setfunc = getattr(obj, "_set_data_v{}".format(blob_version), None)
+    if not setfunc:
+        raise ValueError("Unknown data blob version")
+
+    decompressed = zlib.decompress(s[3])
+    data = json.loads(decompressed.decode('utf-8'))
+    for k, v in data.items():
+        if k.endswith("_bin"):
+            if v:
+                data[k] = base64.b64decode(v.encode('us-ascii'))
+
+    setfunc(data)
+
+
 class SubclassesMixin:
     @classmethod
     def _all_subclasses(cls):