From ac21c0f8ca39c24f271b92a2851ced063c17f060 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Fri, 26 Nov 2021 12:11:31 +0100 Subject: [PATCH 01/29] Introduced the Lock API in all storage interfaces The CS3API version is not implemented yet, only the xrootd one is complete. --- src/core/localiface.py | 2 ++ src/core/xrootiface.py | 21 ++++++++++++++++++++ test/test_storageiface.py | 42 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/core/localiface.py b/src/core/localiface.py index 1399ba90..44d1e70a 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -13,6 +13,8 @@ import json import core.commoniface as common +LOCKKEY = 'user.iop.lock' # this is to be compatible with the (future) Lock API in Reva + # module-wide state config = None log = None diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index d508a412..f87273cf 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -23,6 +23,8 @@ EXCL_XATTR_MSG = 'exclusive set for existing attribute' +LOCKKEY = 'iop.lock' # this is to be compatible with the (future) Lock API in Reva + # module-wide state config = None log = None @@ -326,6 +328,25 @@ def unlock(endpoint, filepath, userid, _appname): rmxattr(endpoint, filepath, userid, common.LOCKKEY) +def setlock(endpoint, filepath, userid, value): + '''Set the lock as an xattr with the special option "c" (create-if-not-exists) on behalf of the given userid''' + try: + setxattr(endpoint, filepath, userid, LOCKKEY, str(value) + '&mgm.option=c') + except IOError as e: + if 'exclusive set for exsisting attribute' in str(e): + raise IOError('File exists and islock flag requested') + + +def getlock(endpoint, filepath, userid): + '''Get the lock metadata as an xattr on behalf of the given userid''' + return getxattr(endpoint, filepath, userid, LOCKKEY) + + +def unlock(endpoint, filepath, userid): + '''Remove the lock as an xattr on behalf of the given userid''' + rmxattr(endpoint, filepath, userid, LOCKKEY) + + def readfile(endpoint, filepath, userid): '''Read a file via xroot on behalf of the given userid. Note that the function is a generator, managed by Flask.''' log.debug('msg="Invoking readFile" filepath="%s"' % filepath) diff --git a/test/test_storageiface.py b/test/test_storageiface.py index a737222c..a0bee83b 100644 --- a/test/test_storageiface.py +++ b/test/test_storageiface.py @@ -14,7 +14,7 @@ from threading import Thread sys.path.append('src/core') # for tests out of the git repo sys.path.append('/app') # for tests within the Docker image - +from wopiutils import EXCL_ERROR class TestStorage(unittest.TestCase): '''Simple tests for the storage layers of the WOPI server. See README for how to run the tests for each storage provider''' @@ -168,7 +168,7 @@ def test_write_islock(self): self.assertIsInstance(statInfo, dict) with self.assertRaises(IOError) as context: self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, buf, islock=True) - self.assertIn('File exists and islock flag requested', str(context.exception)) + self.assertIn(EXCL_ERROR, str(context.exception)) self.storage.removefile(self.endpoint, self.homepath + '/testoverwrite', self.userid) def test_write_race(self): @@ -183,10 +183,46 @@ def test_write_race(self): t.start() with self.assertRaises(IOError) as context: self.storage.writefile(self.endpoint, self.homepath + '/testwriterace', self.userid, buf, islock=True) - self.assertIn('File exists and islock flag requested', str(context.exception)) + self.assertIn(EXCL_ERROR, str(context.exception)) t.join() self.storage.removefile(self.endpoint, self.homepath + '/testwriterace', self.userid) + def test_lock(self): + '''Test setting locks''' + buf = b'ebe5tresbsrdthbrdhvdtr' + try: + self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid) + except IOError: + pass + self.storage.writefile(self.endpoint, self.homepath + '/testlock', self.userid, buf) + statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlock', self.userid) + self.assertIsInstance(statInfo, dict) + self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock') + l = self.storage.getlock(self.endpoint, self.homepath + '/testlock', self.userid) + self.assertEqual(l, 'testlock') + with self.assertRaises(IOError) as context: + self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock2') + self.assertIn(EXCL_ERROR, str(context.exception)) + self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid) + + def test_lock_race(self): + '''Test multithreaded setting locks''' + buf = b'ebe5tresbsrdthbrdhvdtr' + try: + self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid) + except IOError: + pass + self.storage.writefile(self.endpoint, self.homepath + '/testlockrace', self.userid, buf) + statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlockrace', self.userid) + self.assertIsInstance(statInfo, dict) + t = Thread(target=self.storage.setlock, + args=[self.endpoint, self.homepath + '/testlockrace', self.userid, 'testlock']) + t.start() + with self.assertRaises(IOError) as context: + self.storage.setlock(self.endpoint, self.homepath + '/testlockrace', self.userid, 'testlock2') + self.assertIn(EXCL_ERROR, str(context.exception)) + self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid) + def test_remove_nofile(self): '''Test removal of a non-existing file''' with self.assertRaises(IOError): From e8901428fc6330a72d236f044c476fb1fccb784c Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Thu, 2 Dec 2021 15:06:06 +0100 Subject: [PATCH 02/29] Refactoring: introduced a common storage interface module Also included some fixes and reworked the test suite to take into account the new common module --- src/core/xrootiface.py | 21 ------------ test/test_storageiface.py | 72 +++++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index f87273cf..d508a412 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -23,8 +23,6 @@ EXCL_XATTR_MSG = 'exclusive set for existing attribute' -LOCKKEY = 'iop.lock' # this is to be compatible with the (future) Lock API in Reva - # module-wide state config = None log = None @@ -328,25 +326,6 @@ def unlock(endpoint, filepath, userid, _appname): rmxattr(endpoint, filepath, userid, common.LOCKKEY) -def setlock(endpoint, filepath, userid, value): - '''Set the lock as an xattr with the special option "c" (create-if-not-exists) on behalf of the given userid''' - try: - setxattr(endpoint, filepath, userid, LOCKKEY, str(value) + '&mgm.option=c') - except IOError as e: - if 'exclusive set for exsisting attribute' in str(e): - raise IOError('File exists and islock flag requested') - - -def getlock(endpoint, filepath, userid): - '''Get the lock metadata as an xattr on behalf of the given userid''' - return getxattr(endpoint, filepath, userid, LOCKKEY) - - -def unlock(endpoint, filepath, userid): - '''Remove the lock as an xattr on behalf of the given userid''' - rmxattr(endpoint, filepath, userid, LOCKKEY) - - def readfile(endpoint, filepath, userid): '''Read a file via xroot on behalf of the given userid. Note that the function is a generator, managed by Flask.''' log.debug('msg="Invoking readFile" filepath="%s"' % filepath) diff --git a/test/test_storageiface.py b/test/test_storageiface.py index a0bee83b..2e06e98d 100644 --- a/test/test_storageiface.py +++ b/test/test_storageiface.py @@ -12,16 +12,16 @@ import sys import os from threading import Thread -sys.path.append('src/core') # for tests out of the git repo +sys.path.append('src') # for tests out of the git repo sys.path.append('/app') # for tests within the Docker image -from wopiutils import EXCL_ERROR +from core.commoniface import EXCL_ERROR, ENOENT_MSG class TestStorage(unittest.TestCase): '''Simple tests for the storage layers of the WOPI server. See README for how to run the tests for each storage provider''' - def __init__(self, *args, **kwargs): + @classmethod + def globalinit(cls): '''One-off initialization of the test environment: create mock logging and import the library''' - super(TestStorage, self).__init__(*args, **kwargs) loghandler = logging.FileHandler('/tmp/wopiserver-test.log') loghandler.setFormatter(logging.Formatter(fmt='%(asctime)s %(name)s[%(process)d] %(levelname)-8s %(message)s', datefmt='%Y-%m-%dT%H:%M:%S')) @@ -35,30 +35,40 @@ def __init__(self, *args, **kwargs): storagetype = os.environ.get('WOPI_STORAGE') if not storagetype: storagetype = config.get('general', 'storagetype') - self.userid = config.get(storagetype, 'userid') - self.endpoint = config.get(storagetype, 'endpoint') + cls.userid = config.get(storagetype, 'userid') + cls.endpoint = config.get(storagetype, 'endpoint') except (KeyError, configparser.NoOptionError): print("Missing option or missing configuration, check the wopiserver-test.conf file") raise - # this is taken from wopiserver.py::storage_layer_import + # this is adapted from wopiserver.py::storage_layer_import if storagetype in ['local', 'xroot', 'cs3']: storagetype += 'iface' else: raise ImportError('Unsupported/Unknown storage type %s' % storagetype) try: - self.storage = __import__(storagetype, globals(), locals()) - self.storage.init(config, log) - self.homepath = '' - if storagetype == 'cs3iface': + cls.storage = __import__('core.' + storagetype, globals(), locals(), [storagetype]) + cls.storage.init(config, log) + cls.homepath = '' + cls.username = '' + if 'cs3' in storagetype: # we need to login for this case - self.username = self.userid - self.userid = self.storage.authenticate_for_test(self.userid, config.get('cs3', 'userpwd')) - self.homepath = config.get('cs3', 'storagehomepath') + cls.username = cls.userid + cls.userid = cls.storage.authenticate_for_test(cls.userid, config.get('cs3', 'userpwd')) + cls.homepath = config.get('cs3', 'storagehomepath') except ImportError: - print("Missing module when attempting to import {}. Please make sure dependencies are met.", storagetype) + print("Missing module when attempting to import %s. Please make sure dependencies are met." % storagetype) raise + print('Global initialization succeded for storage interface %s, starting unit tests' % storagetype) + def __init__(self, *args, **kwargs): + '''Initialization of a test''' + super(TestStorage, self).__init__(*args, **kwargs) + self.userid = TestStorage.userid + self.endpoint = TestStorage.endpoint + self.storage = TestStorage.storage + self.homepath = TestStorage.homepath + self.username = TestStorage.username def test_stat(self): '''Call stat() and assert the path matches''' @@ -100,13 +110,13 @@ def test_stat_nofile(self): '''Call stat() and assert the exception is as expected''' with self.assertRaises(IOError) as context: self.storage.stat(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid) - self.assertIn('No such file or directory', str(context.exception)) + self.assertIn(ENOENT_MSG, str(context.exception)) def test_statx_nofile(self): '''Call statx() and assert the exception is as expected''' with self.assertRaises(IOError) as context: self.storage.statx(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid) - self.assertIn('No such file or directory', str(context.exception)) + self.assertIn(ENOENT_MSG, str(context.exception)) def test_readfile_bin(self): '''Writes a binary file and reads it back, validating that the content matches''' @@ -144,7 +154,7 @@ def test_read_nofile(self): '''Test reading of a non-existing file''' readex = next(self.storage.readfile(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid)) self.assertIsInstance(readex, IOError, 'readfile returned %s' % readex) - self.assertEqual(str(readex), 'No such file or directory', 'readfile returned %s' % readex) + self.assertEqual(str(readex), ENOENT_MSG, 'readfile returned %s' % readex) def test_write_remove_specialchars(self): '''Test write and removal of a file with special chars''' @@ -203,8 +213,33 @@ def test_lock(self): with self.assertRaises(IOError) as context: self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock2') self.assertIn(EXCL_ERROR, str(context.exception)) + self.storage.unlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp') self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid) + def test_refresh_lock(self): + '''Test refreshing lock''' + try: + self.storage.removefile(self.endpoint, self.homepath + '/testrlock', self.userid) + except IOError: + pass + self.storage.writefile(self.endpoint, self.homepath + '/testrlock', self.userid, databuf) + statInfo = self.storage.stat(self.endpoint, self.homepath + '/testrlock', self.userid) + self.assertIsInstance(statInfo, dict) + with self.assertRaises(IOError) as context: + self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock') + self.assertIn('File was not locked', str(context.exception)) + self.storage.setlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock') + self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock2') + l = self.storage.getlock(self.endpoint, self.homepath + '/testrlock', self.userid) + self.assertIsInstance(l, dict) + self.assertEqual(l['md'], 'testlock2') + self.assertEqual(l['h'], 'myapp') + with self.assertRaises(IOError) as context: + self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp2', 'testlock2') + self.assertIn('File is locked by myapp', str(context.exception)) + self.storage.removefile(self.endpoint, self.homepath + '/testrlock', self.userid) +>>>>>>> Refactoring: introduced a common storage interface module + def test_lock_race(self): '''Test multithreaded setting locks''' buf = b'ebe5tresbsrdthbrdhvdtr' @@ -254,4 +289,5 @@ def test_rename_statx(self): if __name__ == '__main__': + TestStorage.globalinit() unittest.main() From a12136af03b5dcf583f97795bbd36e0fd35cf934 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Sun, 5 Dec 2021 11:36:19 +0100 Subject: [PATCH 03/29] Removed temporary code for the transparent migration --- src/core/xrootiface.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index d508a412..60ee6f8e 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -270,11 +270,6 @@ def rmxattr(endpoint, filepath, _userid, key): _xrootcmd(endpoint, 'attr', 'rm', '0:0', 'mgm.attr.key=user.' + key + '&mgm.path=' + _getfilepath(filepath, encodeamp=True)) -def _getLegacyLockName(filename): - '''Generates the legacy hidden filename used to store the WOPI locks''' - return os.path.dirname(filename) + os.path.sep + '.sys.wopilock.' + os.path.basename(filename) + '.' - - def setlock(endpoint, filepath, userid, appname, value): '''Set a lock as an xattr with the given value metadata and appname as holder. The special option "c" (create-if-not-exists) is used to be atomic''' @@ -292,14 +287,7 @@ def getlock(endpoint, filepath, userid): l = getxattr(endpoint, filepath, userid, common.LOCKKEY) if l: return json.loads(urlsafe_b64decode(l).decode()) - # try and read it from the legacy lock file for the time being - l = b'' - for line in readfile(endpoint, _getLegacyLockName(filepath), '0:0'): - if isinstance(line, IOError): - return None # no pre-existing lock found, or error attempting to read it: assume it does not exist - # the following check is necessary as it happens to get a str instead of bytes - l += line if isinstance(line, type(l)) else line.encode() - return {'h': 'wopi', 'md' : l} # this is temporary + return None # no pre-existing lock found, or error attempting to read it: assume it does not exist def refreshlock(endpoint, filepath, userid, appname, value): @@ -318,11 +306,6 @@ def refreshlock(endpoint, filepath, userid, appname, value): def unlock(endpoint, filepath, userid, _appname): '''Remove a lock as an xattr''' log.debug('msg="Invoked unlock" filepath="%s"' % filepath) - try: - # try and remove the legacy lock file as well for the time being - removefile(endpoint, _getLegacyLockName(filepath), userid, force=True) - except IOError: - pass rmxattr(endpoint, filepath, userid, common.LOCKKEY) From 4e5617bd6f76668ca8405a0a11df7119884b1c3e Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Mon, 6 Dec 2021 09:29:36 +0100 Subject: [PATCH 04/29] Moved the lock related code to commoniface and adapted to the ref implementation See https://github.com/cs3org/cs3apis/blob/main/cs3/storage/provider/v1beta1/resources.proto And https://github.com/cs3org/cs3apis/pull/160 --- src/core/commoniface.py | 19 ++++++++++++++++--- src/core/localiface.py | 9 ++++----- src/core/wopiutils.py | 6 ++++-- src/core/xrootiface.py | 10 +++------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index d203b330..b13b773d 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -1,13 +1,16 @@ ''' commoniface.py -common entities used by all storage interfaces for the IOP WOPI server +Common entities used by all storage interfaces for the IOP WOPI server. +Includes functions to store and retrieve Reva-compatible locks. Author: Giuseppe.LoPresti@cern.ch, CERN/IT-ST ''' import time import json +from base64 import urlsafe_b64encode, urlsafe_b64decode + # standard file missing message ENOENT_MSG = 'No such file or directory' @@ -22,5 +25,15 @@ LOCKKEY = 'user.iop.lock' def genrevalock(appname, value): - '''Return a JSON-formatted lock compatible with the Reva implementation of the CS3 Lock API''' - return json.dumps({'h': appname if appname else 'wopi', 't': int(time.time()), 'md': value}) + '''Return a base64-encoded lock compatible with the Reva implementation of the CS3 Lock API + cf. https://github.com/cs3org/cs3apis/blob/main/cs3/storage/provider/v1beta1/resources.proto''' + return urlsafe_b64encode(json.dumps( + {'type': 'LOCK_TYPE_SHARED', + 'h': appname if appname else 'wopi', + 'md': value, + 'mtime': int(time.time()), + }).encode()).decode() + +def retrieverevalock(rawlock): + '''Restores the JSON payload from a base64-encoded Reva lock''' + return json.loads(urlsafe_b64decode(rawlock).decode()) diff --git a/src/core/localiface.py b/src/core/localiface.py index 44d1e70a..e34a586c 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -10,7 +10,6 @@ import os import warnings from stat import S_ISDIR -import json import core.commoniface as common LOCKKEY = 'user.iop.lock' # this is to be compatible with the (future) Lock API in Reva @@ -81,7 +80,7 @@ def setxattr(_endpoint, filepath, _userid, key, value): '''Set the extended attribute to on behalf of the given userid''' try: os.setxattr(_getfilepath(filepath), 'user.' + key, str(value).encode()) - except (FileNotFoundError, PermissionError, OSError) as e: + except OSError as e: log.error('msg="Failed to setxattr" filepath="%s" key="%s" exception="%s"' % (filepath, key, e)) raise IOError(e) @@ -91,7 +90,7 @@ def getxattr(_endpoint, filepath, _userid, key): try: filepath = _getfilepath(filepath) return os.getxattr(filepath, 'user.' + key).decode('UTF-8') - except (FileNotFoundError, PermissionError, OSError) as e: + except OSError as e: log.error('msg="Failed to getxattr" filepath="%s" key="%s" exception="%s"' % (filepath, key, e)) return None @@ -100,7 +99,7 @@ def rmxattr(_endpoint, filepath, _userid, key): '''Remove the extended attribute on behalf of the given userid''' try: os.removexattr(_getfilepath(filepath), 'user.' + key) - except (FileNotFoundError, PermissionError, OSError) as e: + except OSError as e: log.error('msg="Failed to rmxattr" filepath="%s" key="%s" exception="%s"' % (filepath, key, e)) raise IOError(e) @@ -118,7 +117,7 @@ def getlock(endpoint, filepath, _userid): '''Get the lock metadata as an xattr on behalf of the given userid''' l = getxattr(endpoint, filepath, '0:0', common.LOCKKEY) if l: - return json.loads(l) + return common.retrieverevalock(l) return None def refreshlock(endpoint, filepath, _userid, appname, value): diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index f45e1033..7f325ace 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -159,7 +159,8 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): - '''Retrieves and logs a lock for a given file: returns the lock and its holder, or (None, None) if missing''' + '''Retrieves and logs a lock for a given file: returns the lock and its holder, or (None, None) if missing + TODO use the native lock metadata (breaking change) such as the `mtime` and return the whole lock''' encacctok = flask.request.args['access_token'][-20:] if 'access_token' in flask.request.args else 'N/A' lockcontent = st.getlock(acctok['endpoint'], overridefilename if overridefilename else acctok['filename'], acctok['userid']) if not lockcontent: @@ -203,7 +204,8 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): def _makeLock(lock): - '''Generates the lock payload given the raw data''' + '''Generates the lock payload given the raw data + TODO drop the expiration time in favour of the lock native metadata''' lockcontent = {} lockcontent['wopilock'] = lock # append or overwrite the expiration time diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index 60ee6f8e..0a834124 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -12,8 +12,6 @@ from stat import S_ISDIR from base64 import b64encode from pwd import getpwnam -from base64 import urlsafe_b64encode, urlsafe_b64decode -import json from XRootD import client as XrdClient from XRootD.client.flags import OpenFlags, QueryCode, MkDirFlags, StatInfoFlags @@ -275,8 +273,7 @@ def setlock(endpoint, filepath, userid, appname, value): The special option "c" (create-if-not-exists) is used to be atomic''' try: log.debug('msg="Invoked setlock" filepath="%s"' % filepath) - setxattr(endpoint, filepath, userid, common.LOCKKEY, \ - urlsafe_b64encode(common.genrevalock(appname, value).encode()).decode() + '&mgm.option=c') + setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value) + '&mgm.option=c') except IOError as e: if EXCL_XATTR_MSG in str(e): raise IOError(common.EXCL_ERROR) @@ -286,7 +283,7 @@ def getlock(endpoint, filepath, userid): '''Get the lock metadata as an xattr''' l = getxattr(endpoint, filepath, userid, common.LOCKKEY) if l: - return json.loads(urlsafe_b64decode(l).decode()) + return common.retrieverevalock(l) return None # no pre-existing lock found, or error attempting to read it: assume it does not exist @@ -299,8 +296,7 @@ def refreshlock(endpoint, filepath, userid, appname, value): if l['h'] != appname and l['h'] != 'wopi': raise IOError('File is locked by %s' % l['h']) # this is non-atomic, but the lock was already held - setxattr(endpoint, filepath, userid, common.LOCKKEY, \ - urlsafe_b64encode(common.genrevalock(appname, value).encode()).decode()) + setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value)) def unlock(endpoint, filepath, userid, _appname): From 18964756799931d51a52480e432691e55f20192b Mon Sep 17 00:00:00 2001 From: Gianmaria Del Monte Date: Fri, 14 Jan 2022 18:24:17 +0100 Subject: [PATCH 05/29] Implement calls to reva gtw for lock API --- src/core/cs3iface.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index 84da2f36..a21b9303 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -150,22 +150,49 @@ def rmxattr(_endpoint, filepath, userid, key): def setlock(endpoint, filepath, userid, appname, value): '''Set a lock to filepath with the given value metadata and appname as holder''' - raise NotImplementedError + reference = cs3spr.Reference(path=filepath) + lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_SHARED, holder=appname, metadata=value) + req = cs3sp.SetLockRequest(ref=reference, lock=lock) + res = ctx['cs3stub'].SetLock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code != cs3code.CODE_OK: + ctx['log'].error('msg="Failed to set lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + raise IOError(res.status.message) + ctx['log'].debug('msg="Invoked set lock" result="%s"' % res) def getlock(endpoint, filepath, userid, appname): '''Get the lock metadata for the given filepath''' - raise NotImplementedError + reference = cs3spr.Reference(path=filepath) + req = cs3sp.GetLockRequest(ref=reference) + res = ctx['cs3stub'].GetLock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code != cs3code.CODE_OK: + ctx['log'].error('msg="Failed to get lock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + raise IOError(res.status.message) + ctx['log'].debug('msg="Invoked get lock" result="%s"' % res) + return res.lock def refreshlock(endpoint, filepath, userid, appname, value): '''Refresh the lock metadata for the given filepath''' - raise NotImplementedError + reference = cs3spr.Reference(path=filepath) + lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_SHARED, holder=appname, metadata=value) + req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) + res = ctx['cs3stub'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code != cs3code.CODE_OK: + ctx['log'].error('msg="Failed to refresh lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + raise IOError(res.status.message) + ctx['log'].debug('msg="Invoked refresh lock" result="%s"' % res) def unlock(endpoint, filepath, userid, appname): '''Remove the lock for the given filepath''' - raise NotImplementedError + reference = cs3spr.Reference(path=filepath) + req = cs3sp.UnlockRequest(ref=reference) + res = ctx['cs3stub'].Unlock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code != cs3code.CODE_OK: + ctx['log'].error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + raise IOError(res.status.message) + ctx['log'].debug('msg="Invoked unlock" result="%s"' % res) def readfile(_endpoint, filepath, userid): From ee3f6e9a7853c5f009f5b7da8cf4a4b639422e55 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Mon, 17 Jan 2022 09:26:53 +0100 Subject: [PATCH 06/29] Updated contributors --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8f89647..a2f2cf1c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,14 @@ This service is part of the ScienceMesh Interoperability Platform (IOP) and impl It enables ScienceMesh EFSS storages to integrate Office Online platforms including Microsoft Office Online and Collabora Online. In addition it implements a [bridge](src/bridge/readme.md) module with dedicated extensions to support apps like CodiMD and Etherpad. Author: Giuseppe Lo Presti (@glpatcern)
-Contributions: Michael DSilva (@madsi1m), Lovisa Lugnegaard (@LovisaLugnegard), Samuel Alfageme (@SamuAlfageme), Ishank Arora (@ishank011), Willy Kloucek (@wkloucek) +Contributors: +- Michael DSilva (@madsi1m) +- Lovisa Lugnegaard (@LovisaLugnegard) +- Samuel Alfageme (@SamuAlfageme) +- Ishank Arora (@ishank011) +- Willy Kloucek (@wkloucek) +- Gianmaria Del Monte (@gmgigi96) +- Klaas Freitag (@dragotin) Initial revision: December 2016
First production version for CERNBox: September 2017 (presented at [oCCon17](https://occon17.owncloud.org) - [slides](https://www.slideshare.net/giuseppelopresti/collaborative-editing-and-more-in-cernbox))
From f8e500abcd3155b43e1547a2f0a5d81b10354c1c Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Fri, 28 Jan 2022 13:37:42 +0100 Subject: [PATCH 07/29] Further redacting of URL args so to not log access tokens --- src/core/wopi.py | 2 +- src/core/wopiutils.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/wopi.py b/src/core/wopi.py index 6fb65fa6..559876fa 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -88,7 +88,7 @@ def checkFileInfo(fileid): res = flask.Response(json.dumps(fmd), mimetype='application/json') # amend sensitive metadata for the logs - fmd['HostViewUrl'] = fmd['HostEditUrl'] = fmd['DownloadUrl'] = '_amended_' + fmd['HostViewUrl'] = fmd['HostEditUrl'] = fmd['DownloadUrl'] = '_redacted_' log.info('msg="File metadata response" token="%s" metadata="%s"' % (flask.request.args['access_token'][-20:], fmd)) return res except IOError as e: diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 7f325ace..73849989 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -81,7 +81,8 @@ def logGeneralExceptionAndReturn(ex, req): '''Convenience function to log a stack trace and return HTTP 500''' ex_type, ex_value, ex_traceback = sys.exc_info() log.critical('msg="Unexpected exception caught" exception="%s" type="%s" traceback="%s" client="%s" requestedUrl="%s"' % - (ex, ex_type, traceback.format_exception(ex_type, ex_value, ex_traceback), req.remote_addr, req.url)) + (ex, ex_type, traceback.format_exception(ex_type, ex_value, ex_traceback), req.remote_addr, \ + req.url[0:req.url.find('?')] + '?_args_redacted_' if req.url.find('?') > 0 else req.url)) return 'Internal error, please contact support', http.client.INTERNAL_SERVER_ERROR From e4dc874481c702d24f2e0d8ba1a9b11f31a0aa68 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 1 Feb 2022 17:00:39 +0100 Subject: [PATCH 08/29] Simplified code --- src/core/cs3iface.py | 95 ++++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index a21b9303..ff28d57d 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -25,20 +25,21 @@ # module-wide state ctx = {} # "map" to store some module context: cf. init() -tokens = {} # map userid [string] to {authentication token, token expiration time} +log = None - -def init(config, log): +def init(inconfig, inlog): '''Init module-level variables''' - ctx['log'] = log - ctx['chunksize'] = config.getint('io', 'chunksize') - ctx['ssl_verify'] = config.getboolean('cs3', 'sslverify', fallback=True) - ctx['authtokenvalidity'] = config.getint('cs3', 'authtokenvalidity') - if config.has_option('cs3', 'revagateway'): - revagateway = config.get('cs3', 'revagateway') + global log # pylint: disable=global-statement + log = inlog + ctx['chunksize'] = inconfig.getint('io', 'chunksize') + ctx['ssl_verify'] = inconfig.getboolean('cs3', 'sslverify', fallback=True) + ctx['authtokenvalidity'] = inconfig.getint('cs3', 'authtokenvalidity') + ctx['lockexpiration'] = inconfig.getint('general', 'wopilockexpiration') + if inconfig.has_option('cs3', 'revagateway'): + revagateway = inconfig.get('cs3', 'revagateway') else: # legacy entry, to be dropped at next major release - revagateway = config.get('cs3', 'revahost') + revagateway = inconfig.get('cs3', 'revahost') # prepare the gRPC connection ch = grpc.insecure_channel(revagateway) ctx['cs3stub'] = cs3gw_grpc.GatewayAPIStub(ch) @@ -54,7 +55,7 @@ def authenticate_for_test(userid, userpwd): '''Use basic authentication against Reva for testing purposes''' authReq = cs3gw.AuthenticateRequest(type='basic', client_id=userid, client_secret=userpwd) authRes = ctx['cs3stub'].Authenticate(authReq) - ctx['log'].debug('msg="Authenticated user" res="%s"' % authRes) + log.debug('msg="Authenticated user" res="%s"' % authRes) if authRes.status.code != cs3code.CODE_OK: raise IOError('Failed to authenticate as user ' + userid + ': ' + authRes.status.message) return authRes.token @@ -76,13 +77,13 @@ def stat(endpoint, fileid, userid, versioninv=1): statInfo = ctx['cs3stub'].Stat(request=cs3sp.StatRequest(ref=ref), metadata=[('x-access-token', userid)]) tend = time.time() - ctx['log'].info('msg="Invoked stat" inode="%s" elapsedTimems="%.1f"' % (fileid, (tend-tstart)*1000)) + log.info('msg="Invoked stat" inode="%s" elapsedTimems="%.1f"' % (fileid, (tend-tstart)*1000)) if statInfo.status.code == cs3code.CODE_OK: - ctx['log'].debug('msg="Stat result" data="%s"' % statInfo) + log.debug('msg="Stat result" data="%s"' % statInfo) if statInfo.info.type == cs3spr.RESOURCE_TYPE_CONTAINER: raise IOError('Is a directory') if statInfo.info.type not in (cs3spr.RESOURCE_TYPE_FILE, cs3spr.RESOURCE_TYPE_SYMLINK): - ctx['log'].warning('msg="Stat: unexpected type" type="%d"' % statInfo.info.type) + log.warning('msg="Stat: unexpected type" type="%d"' % statInfo.info.type) raise IOError('Unexpected type %d' % statInfo.info.type) # we base64-encode the inode so it can be used in a WOPISrc inode = urlsafe_b64encode(statInfo.info.id.opaque_id.encode()).decode() @@ -93,7 +94,7 @@ def stat(endpoint, fileid, userid, versioninv=1): 'size': statInfo.info.size, 'mtime': statInfo.info.mtime.seconds } - ctx['log'].info('msg="Failed stat" inode="%s" reason="%s"' % (fileid, statInfo.status.message.replace('"', "'"))) + log.info('msg="Failed stat" inode="%s" reason="%s"' % (fileid, statInfo.status.message.replace('"', "'"))) raise IOError(common.ENOENT_MSG if statInfo.status.code == cs3code.CODE_NOT_FOUND else statInfo.status.message) @@ -111,9 +112,9 @@ def setxattr(_endpoint, filepath, userid, key, value): res = ctx['cs3stub'].SetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked setxattr" result="%s"' % res) + log.debug('msg="Invoked setxattr" result="%s"' % res) def getxattr(_endpoint, filepath, userid, key): @@ -124,16 +125,16 @@ def getxattr(_endpoint, filepath, userid, key): metadata=[('x-access-token', userid)]) tend = time.time() if statInfo.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % (filepath, key, statInfo.status.message.replace('"', "'"))) + log.error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % (filepath, key, statInfo.status.message.replace('"', "'"))) raise IOError(statInfo.status.message) try: xattrvalue = statInfo.info.arbitrary_metadata.metadata[key] if xattrvalue == '': raise KeyError - ctx['log'].debug('msg="Invoked stat for getxattr" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) + log.debug('msg="Invoked stat for getxattr" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) return xattrvalue except KeyError: - ctx['log'].warning('msg="Empty value or key not found in getxattr" filepath="%s" key="%s" metadata="%s"' % (filepath, key, statInfo.info.arbitrary_metadata.metadata)) + log.warning('msg="Empty value or key not found in getxattr" filepath="%s" key="%s" metadata="%s"' % (filepath, key, statInfo.info.arbitrary_metadata.metadata)) return None @@ -143,9 +144,9 @@ def rmxattr(_endpoint, filepath, userid, key): req = cs3sp.UnsetArbitraryMetadataRequest(ref=reference, arbitrary_metadata_keys=[key]) res = ctx['cs3stub'].UnsetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to rmxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) + log.error('msg="Failed to rmxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked rmxattr" result="%s"' % res) + log.debug('msg="Invoked rmxattr" result="%s"' % res) def setlock(endpoint, filepath, userid, appname, value): @@ -155,9 +156,9 @@ def setlock(endpoint, filepath, userid, appname, value): req = cs3sp.SetLockRequest(ref=reference, lock=lock) res = ctx['cs3stub'].SetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to set lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to set lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked set lock" result="%s"' % res) + log.debug('msg="Invoked set lock" result="%s"' % res) def getlock(endpoint, filepath, userid, appname): @@ -166,9 +167,9 @@ def getlock(endpoint, filepath, userid, appname): req = cs3sp.GetLockRequest(ref=reference) res = ctx['cs3stub'].GetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to get lock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to get lock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked get lock" result="%s"' % res) + log.debug('msg="Invoked get lock" result="%s"' % res) return res.lock @@ -179,9 +180,9 @@ def refreshlock(endpoint, filepath, userid, appname, value): req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) res = ctx['cs3stub'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to refresh lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to refresh lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked refresh lock" result="%s"' % res) + log.debug('msg="Invoked refresh lock" result="%s"' % res) def unlock(endpoint, filepath, userid, appname): @@ -190,9 +191,9 @@ def unlock(endpoint, filepath, userid, appname): req = cs3sp.UnlockRequest(ref=reference) res = ctx['cs3stub'].Unlock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked unlock" result="%s"' % res) + log.debug('msg="Invoked unlock" result="%s"' % res) def readfile(_endpoint, filepath, userid): @@ -202,13 +203,13 @@ def readfile(_endpoint, filepath, userid): req = cs3sp.InitiateFileDownloadRequest(ref=cs3spr.Reference(path=filepath)) initfiledownloadres = ctx['cs3stub'].InitiateFileDownload(request=req, metadata=[('x-access-token', userid)]) if initfiledownloadres.status.code == cs3code.CODE_NOT_FOUND: - ctx['log'].info('msg="File not found on read" filepath="%s"' % filepath) + log.info('msg="File not found on read" filepath="%s"' % filepath) yield IOError(common.ENOENT_MSG) elif initfiledownloadres.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to initiateFileDownload on read" filepath="%s" reason="%s"' % + log.error('msg="Failed to initiateFileDownload on read" filepath="%s" reason="%s"' % (filepath, initfiledownloadres.status.message.replace('"', "'"))) yield IOError(initfiledownloadres.status.message) - ctx['log'].debug('msg="readfile: InitiateFileDownloadRes returned" protocols="%s"' % initfiledownloadres.protocols) + log.debug('msg="readfile: InitiateFileDownloadRes returned" protocols="%s"' % initfiledownloadres.protocols) # Download try: @@ -219,15 +220,15 @@ def readfile(_endpoint, filepath, userid): } fileget = requests.get(url=protocol.download_endpoint, headers=headers, verify=ctx['ssl_verify']) except requests.exceptions.RequestException as e: - ctx['log'].error('msg="Exception when downloading file from Reva" reason="%s"' % e) + log.error('msg="Exception when downloading file from Reva" reason="%s"' % e) yield IOError(e) tend = time.time() data = fileget.content if fileget.status_code != http.client.OK: - ctx['log'].error('msg="Error downloading file from Reva" code="%d" reason="%s"' % (fileget.status_code, fileget.reason.replace('"', "'"))) + log.error('msg="Error downloading file from Reva" code="%d" reason="%s"' % (fileget.status_code, fileget.reason.replace('"', "'"))) yield IOError(fileget.reason) else: - ctx['log'].info('msg="File open for read" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) + log.info('msg="File open for read" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) for i in range(0, len(data), ctx['chunksize']): yield data[i:i+ctx['chunksize']] @@ -238,7 +239,7 @@ def writefile(_endpoint, filepath, userid, content, islock=False): The islock flag is currently not supported. TODO the backend should at least support writing the file with O_CREAT|O_EXCL flags to prevent races.''' if islock: - ctx['log'].warning('msg="Lock (no-overwrite) flag not yet supported, going for standard upload"') + log.warning('msg="Lock (no-overwrite) flag not yet supported, going for standard upload"') tstart = time.time() # prepare endpoint if isinstance(content, str): @@ -248,10 +249,10 @@ def writefile(_endpoint, filepath, userid, content, islock=False): req = cs3sp.InitiateFileUploadRequest(ref=cs3spr.Reference(path=filepath), opaque=metadata) initfileuploadres = ctx['cs3stub'].InitiateFileUpload(request=req, metadata=[('x-access-token', userid)]) if initfileuploadres.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to initiateFileUpload on write" filepath="%s" reason="%s"' % \ + log.error('msg="Failed to initiateFileUpload on write" filepath="%s" reason="%s"' % \ (filepath, initfileuploadres.status.message.replace('"', "'"))) raise IOError(initfileuploadres.status.message) - ctx['log'].debug('msg="writefile: InitiateFileUploadRes returned" protocols="%s"' % initfileuploadres.protocols) + log.debug('msg="writefile: InitiateFileUploadRes returned" protocols="%s"' % initfileuploadres.protocols) # Upload try: @@ -264,13 +265,13 @@ def writefile(_endpoint, filepath, userid, content, islock=False): } putres = requests.put(url=protocol.upload_endpoint, data=content, headers=headers, verify=ctx['ssl_verify']) except requests.exceptions.RequestException as e: - ctx['log'].error('msg="Exception when uploading file to Reva" reason="%s"' % e) + log.error('msg="Exception when uploading file to Reva" reason="%s"' % e) raise IOError(e) tend = time.time() if putres.status_code != http.client.OK: - ctx['log'].error('msg="Error uploading file to Reva" code="%d" reason="%s"' % (putres.status_code, putres.reason)) + log.error('msg="Error uploading file to Reva" code="%d" reason="%s"' % (putres.status_code, putres.reason)) raise IOError(putres.reason) - ctx['log'].info('msg="File written successfully" filepath="%s" elapsedTimems="%.1f" islock="%s"' % \ + log.info('msg="File written successfully" filepath="%s" elapsedTimems="%.1f" islock="%s"' % \ (filepath, (tend-tstart)*1000, islock)) @@ -281,9 +282,9 @@ def renamefile(_endpoint, filepath, newfilepath, userid): req = cs3sp.MoveRequest(source=source, destination=destination) res = ctx['cs3stub'].Move(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - ctx['log'].error('msg="Failed to rename file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to rename file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked renamefile" result="%s"' % res) + log.debug('msg="Invoked renamefile" result="%s"' % res) def removefile(_endpoint, filepath, userid, force=False): @@ -294,8 +295,8 @@ def removefile(_endpoint, filepath, userid, force=False): res = ctx['cs3stub'].Delete(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: if str(res) == common.ENOENT_MSG: - ctx['log'].info('msg="Invoked removefile on non-existing file" filepath="%s"' % filepath) + log.info('msg="Invoked removefile on non-existing file" filepath="%s"' % filepath) else: - ctx['log'].error('msg="Failed to remove file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to remove file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - ctx['log'].debug('msg="Invoked removefile" result="%s"' % res) + log.debug('msg="Invoked removefile" result="%s"' % res) From 2ea5dc0081989ee9cf5e7169e6f3a65a01f5ff6f Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 1 Feb 2022 17:01:43 +0100 Subject: [PATCH 09/29] Adopted the lock structure and new API used by Reva --- src/core/commoniface.py | 40 +++++++++++++++++++++++++++++++----- src/core/cs3iface.py | 43 ++++++++++++++++++++++++--------------- src/core/localiface.py | 11 ++++++---- src/core/wopiutils.py | 4 ++-- src/core/xrootiface.py | 14 ++++++------- test/test_storageiface.py | 14 +++++++++---- test/wopiserver-test.conf | 1 + 7 files changed, 89 insertions(+), 38 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index b13b773d..1fafa529 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -24,16 +24,46 @@ # name of the xattr storing the Reva lock LOCKKEY = 'user.iop.lock' +# reference to global config +config = None + + +# Manipulate Reva-compliant locks, i.e. JSON structs with the following format: +#{ +# "lock_id": "id1234", +# "type": 2, +# "user": { +# "idp": "https://your-idprovider.org", +# "opaque_id": "username", +# "type": 1 +# }, +# "app_name": "your_app", +# "expiration": { +# "seconds": 1665446400 +# } +#} + def genrevalock(appname, value): '''Return a base64-encoded lock compatible with the Reva implementation of the CS3 Lock API cf. https://github.com/cs3org/cs3apis/blob/main/cs3/storage/provider/v1beta1/resources.proto''' return urlsafe_b64encode(json.dumps( - {'type': 'LOCK_TYPE_SHARED', - 'h': appname if appname else 'wopi', - 'md': value, - 'mtime': int(time.time()), + {'lock_id': value, + 'type': 2, # LOCK_TYPE_WRITE + 'app_name': appname if appname else 'wopi', + 'user': {}, + 'expiration': { + 'seconds': int(time.time()) + config.getint('general', 'wopilockexpiration') + }, }).encode()).decode() + def retrieverevalock(rawlock): '''Restores the JSON payload from a base64-encoded Reva lock''' - return json.loads(urlsafe_b64decode(rawlock).decode()) + l = json.loads(urlsafe_b64decode(rawlock).decode()) + if 'h' in l: + # temporary code to support the data structure from WOPI 8.0 + l['app_name'] = l['h'] + l['lock_id'] = l['md'] + l['expiration'] = {} + l['expiration']['seconds'] = l['exp'] + return l diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index ff28d57d..f74eda89 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -146,54 +146,65 @@ def rmxattr(_endpoint, filepath, userid, key): if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to rmxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - log.debug('msg="Invoked rmxattr" result="%s"' % res) + log.debug('msg="Invoked rmxattr" result="%s"' % res.status) -def setlock(endpoint, filepath, userid, appname, value): +def setlock(_endpoint, filepath, userid, appname, value): '''Set a lock to filepath with the given value metadata and appname as holder''' reference = cs3spr.Reference(path=filepath) - lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_SHARED, holder=appname, metadata=value) + lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ + expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.SetLockRequest(ref=reference, lock=lock) res = ctx['cs3stub'].SetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to set lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - log.debug('msg="Invoked set lock" result="%s"' % res) + log.debug('msg="Invoked setlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) -def getlock(endpoint, filepath, userid, appname): +def getlock(_endpoint, filepath, userid): '''Get the lock metadata for the given filepath''' reference = cs3spr.Reference(path=filepath) req = cs3sp.GetLockRequest(ref=reference) res = ctx['cs3stub'].GetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to get lock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to getlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - log.debug('msg="Invoked get lock" result="%s"' % res) - return res.lock + log.debug('msg="Invoked getlock" filepath="%s" result="%s"' % (filepath, res.lock)) + return { + 'lock_id': res.lock.lock_id, + 'type': res.lock.type, + 'app_name': res.lock.app_name, + 'user': res.lock.user.opaque_id + '@' + res.lock.user.idp, + 'expiration': { + 'seconds': res.lock.expiration.seconds + } + } -def refreshlock(endpoint, filepath, userid, appname, value): +def refreshlock(_endpoint, filepath, userid, appname, value): '''Refresh the lock metadata for the given filepath''' reference = cs3spr.Reference(path=filepath) - lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_SHARED, holder=appname, metadata=value) + lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ + expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) res = ctx['cs3stub'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to refresh lock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - log.debug('msg="Invoked refresh lock" result="%s"' % res) + log.debug('msg="Invoked refreshlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) -def unlock(endpoint, filepath, userid, appname): +def unlock(_endpoint, filepath, userid, appname, value): '''Remove the lock for the given filepath''' reference = cs3spr.Reference(path=filepath) - req = cs3sp.UnlockRequest(ref=reference) + lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value) + req = cs3sp.UnlockRequest(ref=reference, lock=lock) res = ctx['cs3stub'].Unlock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) - log.debug('msg="Invoked unlock" result="%s"' % res) + log.debug('msg="Invoked unlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) def readfile(_endpoint, filepath, userid): diff --git a/src/core/localiface.py b/src/core/localiface.py index e34a586c..f6e3988e 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -30,7 +30,7 @@ def init(inconfig, inlog): global config # pylint: disable=global-statement global log # pylint: disable=global-statement global homepath # pylint: disable=global-statement - config = inconfig + common.config = config = inconfig log = inlog homepath = config.get('local', 'storagehomepath') try: @@ -106,6 +106,7 @@ def rmxattr(_endpoint, filepath, _userid, key): def setlock(endpoint, filepath, _userid, appname, value): '''Set the lock as an xattr on behalf of the given userid''' + log.debug('msg="Invoked setlock" filepath="%s" value="%s"' % (filepath, value)) if not getxattr(endpoint, filepath, '0:0', common.LOCKKEY): # we do not protect from race conditions here setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value)) @@ -122,17 +123,19 @@ def getlock(endpoint, filepath, _userid): def refreshlock(endpoint, filepath, _userid, appname, value): '''Refresh the lock value as an xattr on behalf of the given userid''' + log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) l = getlock(endpoint, filepath, _userid) if not l: raise IOError('File was not locked') - if l['h'] != appname and l['h'] != 'wopi': - raise IOError('File is locked by %s' % l['h']) + if l['app_name'] != appname and l['app_name'] != 'wopi': + raise IOError('File is locked by %s' % l['app_name']) # this is non-atomic, but the lock was already held setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value)) -def unlock(endpoint, filepath, _userid, _appname): +def unlock(endpoint, filepath, _userid, _appname, value): '''Remove the lock as an xattr on behalf of the given userid''' + log.debug('msg="Invoked unlock" filepath="%s" value="%s' % (filepath, value)) rmxattr(endpoint, filepath, '0:0', common.LOCKKEY) diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 73849989..29d2427d 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -171,7 +171,7 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): try: # check validity: a lock is deemed expired if the most recent between its expiration time and the last # save time by WOPI has passed - retrievedLock = jwt.decode(lockcontent['md'], srv.wopisecret, algorithms=['HS256']) + retrievedLock = jwt.decode(lockcontent['lock_id'], srv.wopisecret, algorithms=['HS256']) savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY) if max(0 if 'exp' not in retrievedLock else retrievedLock['exp'], 0 if not savetime else int(savetime) + srv.config.getint('general', 'wopilockexpiration')) < time.time(): @@ -201,7 +201,7 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): log.info('msg="%s" user="%s" filename="%s" fileid="%s" lock="%s" retrievedlock="%s" expTime="%s" token="%s"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], fileid, lock, retrievedLock['wopilock'], time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(retrievedLock['exp'])), encacctok)) - return retrievedLock['wopilock'], lockcontent['h'] + return retrievedLock['wopilock'], lockcontent['app_name'] def _makeLock(lock): diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index 0a834124..1f49c2b2 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -118,7 +118,7 @@ def init(inconfig, inlog): global endpointoverride # pylint: disable=global-statement global defaultstorage # pylint: disable=global-statement global homepath # pylint: disable=global-statement - config = inconfig + common.config = config = inconfig log = inlog endpointoverride = config.get('xroot', 'endpointoverride', fallback='') defaultstorage = config.get('xroot', 'storageserver') @@ -272,7 +272,7 @@ def setlock(endpoint, filepath, userid, appname, value): '''Set a lock as an xattr with the given value metadata and appname as holder. The special option "c" (create-if-not-exists) is used to be atomic''' try: - log.debug('msg="Invoked setlock" filepath="%s"' % filepath) + log.debug('msg="Invoked setlock" filepath="%s" value="%s"' % (filepath, value)) setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value) + '&mgm.option=c') except IOError as e: if EXCL_XATTR_MSG in str(e): @@ -289,19 +289,19 @@ def getlock(endpoint, filepath, userid): def refreshlock(endpoint, filepath, userid, appname, value): '''Refresh the lock value as an xattr''' - log.debug('msg="Invoked refreshlock" filepath="%s"' % filepath) + log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) l = getlock(endpoint, filepath, userid) if not l: raise IOError('File was not locked') - if l['h'] != appname and l['h'] != 'wopi': - raise IOError('File is locked by %s' % l['h']) + if l['app_name'] != appname and l['app_name'] != 'wopi': + raise IOError('File is locked by %s' % l['app_name']) # this is non-atomic, but the lock was already held setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value)) -def unlock(endpoint, filepath, userid, _appname): +def unlock(endpoint, filepath, userid, _appname, value): '''Remove a lock as an xattr''' - log.debug('msg="Invoked unlock" filepath="%s"' % filepath) + log.debug('msg="Invoked unlock" filepath="%s" value="%s' % (filepath, value)) rmxattr(endpoint, filepath, userid, common.LOCKKEY) diff --git a/test/test_storageiface.py b/test/test_storageiface.py index 2e06e98d..ae077f65 100644 --- a/test/test_storageiface.py +++ b/test/test_storageiface.py @@ -209,11 +209,15 @@ def test_lock(self): self.assertIsInstance(statInfo, dict) self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock') l = self.storage.getlock(self.endpoint, self.homepath + '/testlock', self.userid) - self.assertEqual(l, 'testlock') + self.assertIsInstance(l, dict) + self.assertEqual(l['lock_id'], 'testlock') + self.assertEqual(l['app_name'], 'myapp') + self.assertIsInstance(l['expiration'], dict) + self.assertIsInstance(l['expiration']['seconds'], int) with self.assertRaises(IOError) as context: self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock2') self.assertIn(EXCL_ERROR, str(context.exception)) - self.storage.unlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp') + self.storage.unlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp', 'testlock') self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid) def test_refresh_lock(self): @@ -232,8 +236,10 @@ def test_refresh_lock(self): self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock2') l = self.storage.getlock(self.endpoint, self.homepath + '/testrlock', self.userid) self.assertIsInstance(l, dict) - self.assertEqual(l['md'], 'testlock2') - self.assertEqual(l['h'], 'myapp') + self.assertEqual(l['lock_id'], 'testlock2') + self.assertEqual(l['app_name'], 'myapp') + self.assertIsInstance(l['expiration'], dict) + self.assertIsInstance(l['expiration']['seconds'], int) with self.assertRaises(IOError) as context: self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp2', 'testlock2') self.assertIn('File is locked by myapp', str(context.exception)) diff --git a/test/wopiserver-test.conf b/test/wopiserver-test.conf index a931a7a0..d9baf340 100644 --- a/test/wopiserver-test.conf +++ b/test/wopiserver-test.conf @@ -1,6 +1,7 @@ [general] storagetype = local port = 8880 +wopilockexpiration = 10 [security] usehttps = no From caf280500f5c0d7709c2fe8cae1e009305eacf00 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 1 Feb 2022 18:02:59 +0100 Subject: [PATCH 10/29] Use the native expiration time for the locks --- src/core/cs3iface.py | 4 ++-- src/core/wopi.py | 2 +- src/core/wopiutils.py | 37 ++++++++++++++++--------------------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index f74eda89..5276d165 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -218,7 +218,7 @@ def readfile(_endpoint, filepath, userid): yield IOError(common.ENOENT_MSG) elif initfiledownloadres.status.code != cs3code.CODE_OK: log.error('msg="Failed to initiateFileDownload on read" filepath="%s" reason="%s"' % - (filepath, initfiledownloadres.status.message.replace('"', "'"))) + (filepath, initfiledownloadres.status.message.replace('"', "'"))) yield IOError(initfiledownloadres.status.message) log.debug('msg="readfile: InitiateFileDownloadRes returned" protocols="%s"' % initfiledownloadres.protocols) @@ -298,7 +298,7 @@ def renamefile(_endpoint, filepath, newfilepath, userid): log.debug('msg="Invoked renamefile" result="%s"' % res) -def removefile(_endpoint, filepath, userid, force=False): +def removefile(_endpoint, filepath, userid, _force=False): '''Remove a file using the given userid as access token. The force argument is ignored for now for CS3 storage.''' reference = cs3spr.Reference(path=filepath) diff --git a/src/core/wopi.py b/src/core/wopi.py index 559876fa..bf73b638 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -211,7 +211,7 @@ def unlock(fileid, reqheaders, acctok): return utils.makeConflictResponse('UNLOCK', retrievedLock, lock, '', acctok['filename']) # OK, the lock matches. Remove any extended attribute related to locks and conflicts handling try: - st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname']) + st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], utils.encodeLock(lock)) except IOError: # ignore, it's not worth to report anything here pass diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 29d2427d..10c4c85c 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -159,9 +159,8 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app return statinfo['inode'], acctok -def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): - '''Retrieves and logs a lock for a given file: returns the lock and its holder, or (None, None) if missing - TODO use the native lock metadata (breaking change) such as the `mtime` and return the whole lock''' +def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=None): + '''Retrieves and logs a lock for a given file: returns the lock and its holder, or (None, None) if missing''' encacctok = flask.request.args['access_token'][-20:] if 'access_token' in flask.request.args else 'N/A' lockcontent = st.getlock(acctok['endpoint'], overridefilename if overridefilename else acctok['filename'], acctok['userid']) if not lockcontent: @@ -171,10 +170,11 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): try: # check validity: a lock is deemed expired if the most recent between its expiration time and the last # save time by WOPI has passed - retrievedLock = jwt.decode(lockcontent['lock_id'], srv.wopisecret, algorithms=['HS256']) + rawlock = lockcontent['lock_id'] + lockcontent['lock_id'] = jwt.decode(lockcontent['lock_id'], srv.wopisecret, algorithms=['HS256']) savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY) - if max(0 if 'exp' not in retrievedLock else retrievedLock['exp'], - 0 if not savetime else int(savetime) + srv.config.getint('general', 'wopilockexpiration')) < time.time(): + if max(lockcontent['expiration']['seconds'], int(savetime) + \ + srv.config.getint('general', 'wopilockexpiration')) < time.time(): # we got a malformed or expired lock, reject. Note that we may get an ExpiredSignatureError # by jwt.decode() as we had stored it with a timed signature. raise jwt.exceptions.ExpiredSignatureError @@ -183,7 +183,7 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): 'exception="%s"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok, type(e))) # the retrieved lock is not valid any longer, discard and remove it from the backend try: - st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname']) + st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], rawlock) except IOError: # ignore, it's not worth to report anything here pass @@ -199,19 +199,14 @@ def retrieveWopiLock(fileid, operation, lock, acctok, overridefilename=None): ('empty lock' if isinstance(e, StopIteration) else str(e))) return None, None log.info('msg="%s" user="%s" filename="%s" fileid="%s" lock="%s" retrievedlock="%s" expTime="%s" token="%s"' % - (operation.title(), acctok['userid'][-20:], acctok['filename'], fileid, lock, retrievedLock['wopilock'], - time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(retrievedLock['exp'])), encacctok)) - return retrievedLock['wopilock'], lockcontent['app_name'] + (operation.title(), acctok['userid'][-20:], acctok['filename'], fileid, lockforlog, lockcontent['lock_id'], + time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(lockcontent['expiration']['seconds'])), encacctok)) + return lockcontent['lock_id'], lockcontent['app_name'] -def _makeLock(lock): - '''Generates the lock payload given the raw data - TODO drop the expiration time in favour of the lock native metadata''' - lockcontent = {} - lockcontent['wopilock'] = lock - # append or overwrite the expiration time - lockcontent['exp'] = int(time.time()) + srv.config.getint('general', 'wopilockexpiration') - return jwt.encode(lockcontent, srv.wopisecret, algorithm='HS256') +def encodeLock(lock): + '''Generates the lock payload given the raw data''' + return jwt.encode(lock, srv.wopisecret, algorithm='HS256') def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): @@ -284,7 +279,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): try: # now atomically store the lock as encoded JWT - st.setlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], _makeLock(lock)) + st.setlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], encodeLock(lock)) log.info('msg="%s" filename="%s" token="%s" lock="%s" result="success"' % (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], lock)) @@ -314,7 +309,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): return makeConflictResponse(operation, retrievedLock, lock, oldlock, acctok['filename'], \ 'The file is locked by %s' % (lockHolder if lockHolder != 'wopi' else 'another online editor')) # else it's our lock or it had expired, refresh it and return - st.refreshlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], _makeLock(lock)) + st.refreshlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], encodeLock(lock)) log.info('msg="%s" filename="%s" token="%s" lock="%s" result="refreshed"' % (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], lock)) return 'OK', http.client.OK @@ -378,7 +373,7 @@ def storeWopiFile(request, retrievedlock, acctok, xakey, targetname=''): st.setxattr(acctok['endpoint'], targetname, acctok['userid'], xakey, int(time.time())) # and reinstate the lock if existing if retrievedlock: - st.setlock(acctok['endpoint'], targetname, acctok['userid'], acctok['appname'], _makeLock(retrievedlock)) + st.setlock(acctok['endpoint'], targetname, acctok['userid'], acctok['appname'], encodeLock(retrievedlock)) def getuserhome(username): From 821cd2887194a880833a0390de2f70d59469dab2 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 2 Feb 2022 16:06:08 +0100 Subject: [PATCH 11/29] Refactoring --- src/core/cs3iface.py | 50 +++++++++++++++++++++++------------------- src/core/localiface.py | 2 -- src/core/wopiutils.py | 4 ++-- src/core/xrootiface.py | 11 +++++----- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index 5276d165..aa6396ef 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -42,7 +42,7 @@ def init(inconfig, inlog): revagateway = inconfig.get('cs3', 'revahost') # prepare the gRPC connection ch = grpc.insecure_channel(revagateway) - ctx['cs3stub'] = cs3gw_grpc.GatewayAPIStub(ch) + ctx['cs3gw'] = cs3gw_grpc.GatewayAPIStub(ch) def getuseridfromcreds(token, _wopiuser): @@ -54,7 +54,7 @@ def getuseridfromcreds(token, _wopiuser): def authenticate_for_test(userid, userpwd): '''Use basic authentication against Reva for testing purposes''' authReq = cs3gw.AuthenticateRequest(type='basic', client_id=userid, client_secret=userpwd) - authRes = ctx['cs3stub'].Authenticate(authReq) + authRes = ctx['cs3gw'].Authenticate(authReq) log.debug('msg="Authenticated user" res="%s"' % authRes) if authRes.status.code != cs3code.CODE_OK: raise IOError('Failed to authenticate as user ' + userid + ': ' + authRes.status.message) @@ -74,8 +74,8 @@ def stat(endpoint, fileid, userid, versioninv=1): else: # assume we have an opaque fileid ref = cs3spr.Reference(resource_id=cs3spr.ResourceId(storage_id=endpoint, opaque_id=fileid)) - statInfo = ctx['cs3stub'].Stat(request=cs3sp.StatRequest(ref=ref), - metadata=[('x-access-token', userid)]) + statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=ref), + metadata=[('X-Access-Token', userid)]) tend = time.time() log.info('msg="Invoked stat" inode="%s" elapsedTimems="%.1f"' % (fileid, (tend-tstart)*1000)) if statInfo.status.code == cs3code.CODE_OK: @@ -112,7 +112,8 @@ def setxattr(_endpoint, filepath, userid, key, value): res = ctx['cs3stub'].SetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % + (filepath, key, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked setxattr" result="%s"' % res) @@ -121,11 +122,12 @@ def getxattr(_endpoint, filepath, userid, key): '''Get the extended attribute using the given userid as access token''' tstart = time.time() reference = cs3spr.Reference(path=filepath) - statInfo = ctx['cs3stub'].Stat(request=cs3sp.StatRequest(ref=reference), - metadata=[('x-access-token', userid)]) + statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=reference), + metadata=[('X-Access-Token', userid)]) tend = time.time() if statInfo.status.code != cs3code.CODE_OK: - log.error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % (filepath, key, statInfo.status.message.replace('"', "'"))) + log.error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % + (filepath, key, statInfo.status.message.replace('"', "'"))) raise IOError(statInfo.status.message) try: xattrvalue = statInfo.info.arbitrary_metadata.metadata[key] @@ -134,7 +136,8 @@ def getxattr(_endpoint, filepath, userid, key): log.debug('msg="Invoked stat for getxattr" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) return xattrvalue except KeyError: - log.warning('msg="Empty value or key not found in getxattr" filepath="%s" key="%s" metadata="%s"' % (filepath, key, statInfo.info.arbitrary_metadata.metadata)) + log.warning('msg="Empty value or key not found in getxattr" filepath="%s" key="%s" metadata="%s"' % + (filepath, key, statInfo.info.arbitrary_metadata.metadata)) return None @@ -155,9 +158,10 @@ def setlock(_endpoint, filepath, userid, appname, value): lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.SetLockRequest(ref=reference, lock=lock) - res = ctx['cs3stub'].SetLock(request=req, metadata=[('x-access-token', userid)]) + res = ctx['cs3gw'].SetLock(request=req, metadata=[('X-Access-Token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" reason="%s"' % + (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked setlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -166,7 +170,7 @@ def getlock(_endpoint, filepath, userid): '''Get the lock metadata for the given filepath''' reference = cs3spr.Reference(path=filepath) req = cs3sp.GetLockRequest(ref=reference) - res = ctx['cs3stub'].GetLock(request=req, metadata=[('x-access-token', userid)]) + res = ctx['cs3gw'].GetLock(request=req, metadata=[('X-Access-Token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to getlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) @@ -188,9 +192,10 @@ def refreshlock(_endpoint, filepath, userid, appname, value): lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) - res = ctx['cs3stub'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) + res = ctx['cs3gw'].RefreshLock(request=req, metadata=[('X-Access-Token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" reason="%s"' % + (filepath, appname, value, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked refreshlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -200,7 +205,7 @@ def unlock(_endpoint, filepath, userid, appname, value): reference = cs3spr.Reference(path=filepath) lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value) req = cs3sp.UnlockRequest(ref=reference, lock=lock) - res = ctx['cs3stub'].Unlock(request=req, metadata=[('x-access-token', userid)]) + res = ctx['cs3gw'].Unlock(request=req, metadata=[('X-Access-Token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) @@ -212,7 +217,7 @@ def readfile(_endpoint, filepath, userid): tstart = time.time() # prepare endpoint req = cs3sp.InitiateFileDownloadRequest(ref=cs3spr.Reference(path=filepath)) - initfiledownloadres = ctx['cs3stub'].InitiateFileDownload(request=req, metadata=[('x-access-token', userid)]) + initfiledownloadres = ctx['cs3gw'].InitiateFileDownload(request=req, metadata=[('X-Access-Token', userid)]) if initfiledownloadres.status.code == cs3code.CODE_NOT_FOUND: log.info('msg="File not found on read" filepath="%s"' % filepath) yield IOError(common.ENOENT_MSG) @@ -226,7 +231,7 @@ def readfile(_endpoint, filepath, userid): try: protocol = [p for p in initfiledownloadres.protocols if p.protocol == "simple"][0] headers = { - 'x-access-token': userid, + 'X-Access-Token': userid, 'X-Reva-Transfer': protocol.token # needed if the downloads pass through the data gateway in reva } fileget = requests.get(url=protocol.download_endpoint, headers=headers, verify=ctx['ssl_verify']) @@ -236,7 +241,8 @@ def readfile(_endpoint, filepath, userid): tend = time.time() data = fileget.content if fileget.status_code != http.client.OK: - log.error('msg="Error downloading file from Reva" code="%d" reason="%s"' % (fileget.status_code, fileget.reason.replace('"', "'"))) + log.error('msg="Error downloading file from Reva" code="%d" reason="%s"' % + (fileget.status_code, fileget.reason.replace('"', "'"))) yield IOError(fileget.reason) else: log.info('msg="File open for read" filepath="%s" elapsedTimems="%.1f"' % (filepath, (tend-tstart)*1000)) @@ -247,10 +253,10 @@ def readfile(_endpoint, filepath, userid): def writefile(_endpoint, filepath, userid, content, islock=False): '''Write a file using the given userid as access token. The entire content is written and any pre-existing file is deleted (or moved to the previous version if supported). - The islock flag is currently not supported. TODO the backend should at least support + The islock flag is currently not supported. The backend should at least support writing the file with O_CREAT|O_EXCL flags to prevent races.''' if islock: - log.warning('msg="Lock (no-overwrite) flag not yet supported, going for standard upload"') + log.warning('msg="Lock (no-overwrite) flag not supported, going for standard upload"') tstart = time.time() # prepare endpoint if isinstance(content, str): @@ -270,7 +276,7 @@ def writefile(_endpoint, filepath, userid, content, islock=False): # Get the endpoint for simple protocol protocol = [p for p in initfileuploadres.protocols if p.protocol == "simple"][0] headers = { - 'x-access-token': userid, + 'X-Access-Token': userid, 'Upload-Length': size, 'X-Reva-Transfer': protocol.token # needed if the uploads pass through the data gateway in reva } @@ -303,7 +309,7 @@ def removefile(_endpoint, filepath, userid, _force=False): The force argument is ignored for now for CS3 storage.''' reference = cs3spr.Reference(path=filepath) req = cs3sp.DeleteRequest(ref=reference) - res = ctx['cs3stub'].Delete(request=req, metadata=[('x-access-token', userid)]) + res = ctx['cs3gw'].Delete(request=req, metadata=[('X-Access-Token', userid)]) if res.status.code != cs3code.CODE_OK: if str(res) == common.ENOENT_MSG: log.info('msg="Invoked removefile on non-existing file" filepath="%s"' % filepath) diff --git a/src/core/localiface.py b/src/core/localiface.py index f6e3988e..f34fc97e 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -12,8 +12,6 @@ from stat import S_ISDIR import core.commoniface as common -LOCKKEY = 'user.iop.lock' # this is to be compatible with the (future) Lock API in Reva - # module-wide state config = None log = None diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 10c4c85c..304bbdf5 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -274,8 +274,8 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): pass # lock not found, assume we're clear else: # any other error is logged but not raised as this is optimistically not blocking WOPI operations - log.warning('msg="%s: unable to store LibreOffice-compatible lock" filename="%s" token="%s" lock="%s" reason="%s"' % - (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], lock, e)) + log.warning('msg="%s: unable to store LibreOffice-compatible lock" filename="%s" token="%s" reason="%s"' % + (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], e)) try: # now atomically store the lock as encoded JWT diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index 1f49c2b2..ef3c7c84 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -80,7 +80,7 @@ def _xrootcmd(endpoint, cmd, subcmd, userid, args): url = _geturlfor(endpoint) + '//proc/user/' + _eosargs(userid) + '&mgm.cmd=' + cmd + \ ('&mgm.subcmd=' + subcmd if subcmd else '') + '&' + args tstart = time.time() - rc, statInfo_unused = f.open(url, OpenFlags.READ) + rc, _ = f.open(url, OpenFlags.READ) tend = time.time() res = b''.join(f.readlines()).decode().split('&') if len(res) == 3: # we may only just get stdout: in that case, assume it's all OK @@ -92,12 +92,13 @@ def _xrootcmd(endpoint, cmd, subcmd, userid, args): if common.ENOENT_MSG.lower() in msg or 'unable to get attribute' in msg: log.info('msg="Invoked xroot on non-existing entity" cmd="%s" subcmd="%s" args="%s" error="%s" rc="%s"' % \ (cmd, subcmd, args, msg, rc.strip('\00'))) - elif EXCL_XATTR_MSG in msg: + raise IOError(common.ENOENT_MSG) + if EXCL_XATTR_MSG in msg: log.info('msg="Invoked setxattr on an already locked entity" cmd="%s" subcmd="%s" args="%s" error="%s" rc="%s"' % \ (cmd, subcmd, args, msg, rc.strip('\00'))) - else: - log.error('msg="Error with xroot" cmd="%s" subcmd="%s" args="%s" error="%s" rc="%s"' % \ - (cmd, subcmd, args, msg, rc.strip('\00'))) + raise IOError(EXCL_XATTR_MSG) + log.error('msg="Error with xroot" cmd="%s" subcmd="%s" args="%s" error="%s" rc="%s"' % \ + (cmd, subcmd, args, msg, rc.strip('\00'))) raise IOError(msg) # all right, return everything that came in stdout log.debug('msg="Invoked xroot" cmd="%s%s" url="%s" res="%s" elapsedTimems="%.1f"' % From 6cf96c1b38b9daccadcfb531f1a378515ee5ae56 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 2 Feb 2022 16:10:58 +0100 Subject: [PATCH 12/29] Extended storage functions to accept a lock argument --- requirements.txt | 2 +- src/core/commoniface.py | 2 +- src/core/cs3iface.py | 62 ++++++++++++++++---------------- src/core/ioplocks.py | 6 ++-- src/core/localiface.py | 16 ++++----- src/core/wopi.py | 8 ++--- src/core/wopiutils.py | 23 +++++++----- src/core/xrootiface.py | 14 ++++---- test/test_storageiface.py | 75 ++++++++++++++++++++++----------------- 9 files changed, 113 insertions(+), 95 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8d20811c..9b97067f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,5 @@ requests more_itertools tuspy prometheus-flask-exporter -cs3apis>=0.1.dev66 +cs3apis>=0.1.dev84 waitress diff --git a/src/core/commoniface.py b/src/core/commoniface.py index 1fafa529..3de835fd 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -22,7 +22,7 @@ ACCESS_ERROR = 'Operation not permitted' # name of the xattr storing the Reva lock -LOCKKEY = 'user.iop.lock' +LOCKKEY = 'iop.lock' # reference to global config config = None diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index aa6396ef..3b5eb3c0 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -74,8 +74,7 @@ def stat(endpoint, fileid, userid, versioninv=1): else: # assume we have an opaque fileid ref = cs3spr.Reference(resource_id=cs3spr.ResourceId(storage_id=endpoint, opaque_id=fileid)) - statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=ref), - metadata=[('X-Access-Token', userid)]) + statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=ref), metadata=[('x-access-token', userid)]) tend = time.time() log.info('msg="Invoked stat" inode="%s" elapsedTimems="%.1f"' % (fileid, (tend-tstart)*1000)) if statInfo.status.code == cs3code.CODE_OK: @@ -103,14 +102,13 @@ def statx(endpoint, fileid, userid, versioninv=0): return stat(endpoint, fileid, userid, versioninv) -def setxattr(_endpoint, filepath, userid, key, value): +def setxattr(_endpoint, filepath, userid, key, value, lockid): '''Set the extended attribute to using the given userid as access token''' reference = cs3spr.Reference(path=filepath) - arbitrary_metadata = cs3spr.ArbitraryMetadata() - arbitrary_metadata.metadata.update({key: str(value)}) # pylint: disable=no-member - req = cs3sp.SetArbitraryMetadataRequest(ref=reference, arbitrary_metadata=arbitrary_metadata) - res = ctx['cs3stub'].SetArbitraryMetadata(request=req, - metadata=[('x-access-token', userid)]) + md = cs3spr.ArbitraryMetadata() + md.metadata.update({key: str(value)}) # pylint: disable=no-member + req = cs3sp.SetArbitraryMetadataRequest(ref=reference, arbitrary_metadata=md) #, lock_id=lockid) + res = ctx['cs3gw'].SetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) @@ -122,8 +120,7 @@ def getxattr(_endpoint, filepath, userid, key): '''Get the extended attribute using the given userid as access token''' tstart = time.time() reference = cs3spr.Reference(path=filepath) - statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=reference), - metadata=[('X-Access-Token', userid)]) + statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=reference), metadata=[('x-access-token', userid)]) tend = time.time() if statInfo.status.code != cs3code.CODE_OK: log.error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % @@ -141,11 +138,11 @@ def getxattr(_endpoint, filepath, userid, key): return None -def rmxattr(_endpoint, filepath, userid, key): +def rmxattr(_endpoint, filepath, userid, key, lockid): '''Remove the extended attribute using the given userid as access token''' reference = cs3spr.Reference(path=filepath) - req = cs3sp.UnsetArbitraryMetadataRequest(ref=reference, arbitrary_metadata_keys=[key]) - res = ctx['cs3stub'].UnsetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) + req = cs3sp.UnsetArbitraryMetadataRequest(ref=reference, arbitrary_metadata_keys=[key]) #, lock_id=lockid) + res = ctx['cs3gw'].UnsetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to rmxattr" filepath="%s" key="%s" reason="%s"' % (filepath, key, res.status.message.replace('"', "'"))) raise IOError(res.status.message) @@ -158,7 +155,7 @@ def setlock(_endpoint, filepath, userid, appname, value): lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.SetLockRequest(ref=reference, lock=lock) - res = ctx['cs3gw'].SetLock(request=req, metadata=[('X-Access-Token', userid)]) + res = ctx['cs3gw'].SetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) @@ -170,11 +167,15 @@ def getlock(_endpoint, filepath, userid): '''Get the lock metadata for the given filepath''' reference = cs3spr.Reference(path=filepath) req = cs3sp.GetLockRequest(ref=reference) - res = ctx['cs3gw'].GetLock(request=req, metadata=[('X-Access-Token', userid)]) + res = ctx['cs3gw'].GetLock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code == cs3code.CODE_NOT_FOUND: + log.debug('msg="Invoked getlock on unlocked file" filepath="%s"' % filepath) + return None if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to getlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked getlock" filepath="%s" result="%s"' % (filepath, res.lock)) + # return a dict that mimics the internal JSON structure used by Reva, cf. commoniface.py return { 'lock_id': res.lock.lock_id, 'type': res.lock.type, @@ -192,7 +193,7 @@ def refreshlock(_endpoint, filepath, userid, appname, value): lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value, \ expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) - res = ctx['cs3gw'].RefreshLock(request=req, metadata=[('X-Access-Token', userid)]) + res = ctx['cs3gw'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" reason="%s"' % (filepath, appname, value, res.status.message.replace('"', "'"))) @@ -205,7 +206,7 @@ def unlock(_endpoint, filepath, userid, appname, value): reference = cs3spr.Reference(path=filepath) lock = cs3spr.Lock(type=cs3spr.LOCK_TYPE_WRITE, app_name=appname, lock_id=value) req = cs3sp.UnlockRequest(ref=reference, lock=lock) - res = ctx['cs3gw'].Unlock(request=req, metadata=[('X-Access-Token', userid)]) + res = ctx['cs3gw'].Unlock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) @@ -217,7 +218,7 @@ def readfile(_endpoint, filepath, userid): tstart = time.time() # prepare endpoint req = cs3sp.InitiateFileDownloadRequest(ref=cs3spr.Reference(path=filepath)) - initfiledownloadres = ctx['cs3gw'].InitiateFileDownload(request=req, metadata=[('X-Access-Token', userid)]) + initfiledownloadres = ctx['cs3gw'].InitiateFileDownload(request=req, metadata=[('x-access-token', userid)]) if initfiledownloadres.status.code == cs3code.CODE_NOT_FOUND: log.info('msg="File not found on read" filepath="%s"' % filepath) yield IOError(common.ENOENT_MSG) @@ -231,8 +232,8 @@ def readfile(_endpoint, filepath, userid): try: protocol = [p for p in initfiledownloadres.protocols if p.protocol == "simple"][0] headers = { - 'X-Access-Token': userid, - 'X-Reva-Transfer': protocol.token # needed if the downloads pass through the data gateway in reva + 'x-access-token': userid, + 'x-reva-transfer': protocol.token # needed if the downloads pass through the data gateway in reva } fileget = requests.get(url=protocol.download_endpoint, headers=headers, verify=ctx['ssl_verify']) except requests.exceptions.RequestException as e: @@ -250,7 +251,7 @@ def readfile(_endpoint, filepath, userid): yield data[i:i+ctx['chunksize']] -def writefile(_endpoint, filepath, userid, content, islock=False): +def writefile(_endpoint, filepath, userid, content, lockid, islock=False): '''Write a file using the given userid as access token. The entire content is written and any pre-existing file is deleted (or moved to the previous version if supported). The islock flag is currently not supported. The backend should at least support @@ -263,11 +264,11 @@ def writefile(_endpoint, filepath, userid, content, islock=False): content = bytes(content, 'UTF-8') size = str(len(content)) metadata = types.Opaque(map={"Upload-Length": types.OpaqueEntry(decoder="plain", value=str.encode(size))}) - req = cs3sp.InitiateFileUploadRequest(ref=cs3spr.Reference(path=filepath), opaque=metadata) - initfileuploadres = ctx['cs3stub'].InitiateFileUpload(request=req, metadata=[('x-access-token', userid)]) + req = cs3sp.InitiateFileUploadRequest(ref=cs3spr.Reference(path=filepath), opaque=metadata) # lock_id=lockid, + initfileuploadres = ctx['cs3gw'].InitiateFileUpload(request=req, metadata=[('x-access-token', userid)]) if initfileuploadres.status.code != cs3code.CODE_OK: log.error('msg="Failed to initiateFileUpload on write" filepath="%s" reason="%s"' % \ - (filepath, initfileuploadres.status.message.replace('"', "'"))) + (filepath, initfileuploadres.status.message.replace('"', "'"))) raise IOError(initfileuploadres.status.message) log.debug('msg="writefile: InitiateFileUploadRes returned" protocols="%s"' % initfileuploadres.protocols) @@ -276,9 +277,9 @@ def writefile(_endpoint, filepath, userid, content, islock=False): # Get the endpoint for simple protocol protocol = [p for p in initfileuploadres.protocols if p.protocol == "simple"][0] headers = { - 'X-Access-Token': userid, + 'x-access-token': userid, 'Upload-Length': size, - 'X-Reva-Transfer': protocol.token # needed if the uploads pass through the data gateway in reva + 'x-reva-transfer': protocol.token # needed if the uploads pass through the data gateway in reva } putres = requests.put(url=protocol.upload_endpoint, data=content, headers=headers, verify=ctx['ssl_verify']) except requests.exceptions.RequestException as e: @@ -292,12 +293,12 @@ def writefile(_endpoint, filepath, userid, content, islock=False): (filepath, (tend-tstart)*1000, islock)) -def renamefile(_endpoint, filepath, newfilepath, userid): +def renamefile(_endpoint, filepath, newfilepath, userid, lockid): '''Rename a file from origfilepath to newfilepath using the given userid as access token.''' source = cs3spr.Reference(path=filepath) destination = cs3spr.Reference(path=newfilepath) - req = cs3sp.MoveRequest(source=source, destination=destination) - res = ctx['cs3stub'].Move(request=req, metadata=[('x-access-token', userid)]) + req = cs3sp.MoveRequest(source=source, destination=destination) # lock_id=lockid) + res = ctx['cs3gw'].Move(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: log.error('msg="Failed to rename file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) @@ -309,7 +310,7 @@ def removefile(_endpoint, filepath, userid, _force=False): The force argument is ignored for now for CS3 storage.''' reference = cs3spr.Reference(path=filepath) req = cs3sp.DeleteRequest(ref=reference) - res = ctx['cs3gw'].Delete(request=req, metadata=[('X-Access-Token', userid)]) + res = ctx['cs3gw'].Delete(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: if str(res) == common.ENOENT_MSG: log.info('msg="Invoked removefile on non-existing file" filepath="%s"' % filepath) @@ -317,3 +318,4 @@ def removefile(_endpoint, filepath, userid, _force=False): log.error('msg="Failed to remove file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked removefile" result="%s"' % res) + diff --git a/src/core/ioplocks.py b/src/core/ioplocks.py index 05cd3110..f3657e55 100644 --- a/src/core/ioplocks.py +++ b/src/core/ioplocks.py @@ -129,7 +129,7 @@ def createLock(filestat, filename, userid, endpoint): (srv.wopiurl, time.strftime('%d.%m.%Y %H:%M', time.localtime(time.time())), lockid) # try to write in exclusive mode (and if a valid WOPI lock exists, assume the corresponding LibreOffice lock # is still there so the write will fail) - st.writefile(endpoint, utils.getLibreOfficeLockName(filename), userid, lolockcontent, islock=True) + st.writefile(endpoint, utils.getLibreOfficeLockName(filename), userid, lolockcontent, None, islock=True) log.info('msg="cboxLock: created LibreOffice-compatible lock file" filename="%s" fileid="%s" lockid="%ld"' % (filename, filestat['inode'], lockid)) return str(lockid), http.client.OK @@ -179,7 +179,7 @@ def createLock(filestat, filename, userid, endpoint): lockid = int(time.time()) lolockcontent = ',OnlyOffice Online Editor,%s,%s,ExtWebApp;\n%d;' % \ (srv.wopiurl, time.strftime('%d.%m.%Y %H:%M', time.localtime(time.time())), lockid) - st.writefile(endpoint, utils.getLibreOfficeLockName(filename), userid, lolockcontent, islock=False) + st.writefile(endpoint, utils.getLibreOfficeLockName(filename), userid, lolockcontent, None, islock=False) log.info('msg="cboxLock: refreshed LibreOffice-compatible lock file" filename="%s" fileid="%s" mtime="%ld" lockid="%ld"' % (filename, filestat['inode'], filestat['mtime'], lockid)) return str(lockid), http.client.OK @@ -206,7 +206,7 @@ def iopunlock(filename, userid, endpoint): lock = lock.decode('UTF-8') if 'OnlyOffice Online Editor' in lock: # remove the LibreOffice-compatible lock file - st.removefile(endpoint, utils.getLibreOfficeLockName(filename), userid, 1) + st.removefile(endpoint, utils.getLibreOfficeLockName(filename), userid, True) # and log this along with the previous lockid for reference lockid = int(lock.split(';\n')[1].strip(';')) log.info('msg="cboxUnlock: successfully removed LibreOffice-compatible lock file" filename="%s" lockid="%ld"' % diff --git a/src/core/localiface.py b/src/core/localiface.py index f34fc97e..7dffd5d3 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -74,7 +74,7 @@ def statx(endpoint, filepath, userid, versioninv=1): return stat(endpoint, filepath, userid) -def setxattr(_endpoint, filepath, _userid, key, value): +def setxattr(_endpoint, filepath, _userid, key, value, _lockid): '''Set the extended attribute to on behalf of the given userid''' try: os.setxattr(_getfilepath(filepath), 'user.' + key, str(value).encode()) @@ -93,7 +93,7 @@ def getxattr(_endpoint, filepath, _userid, key): return None -def rmxattr(_endpoint, filepath, _userid, key): +def rmxattr(_endpoint, filepath, _userid, key, _lockid): '''Remove the extended attribute on behalf of the given userid''' try: os.removexattr(_getfilepath(filepath), 'user.' + key) @@ -107,7 +107,7 @@ def setlock(endpoint, filepath, _userid, appname, value): log.debug('msg="Invoked setlock" filepath="%s" value="%s"' % (filepath, value)) if not getxattr(endpoint, filepath, '0:0', common.LOCKKEY): # we do not protect from race conditions here - setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value)) + setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value), None) else: raise IOError(common.EXCL_ERROR) @@ -128,13 +128,13 @@ def refreshlock(endpoint, filepath, _userid, appname, value): if l['app_name'] != appname and l['app_name'] != 'wopi': raise IOError('File is locked by %s' % l['app_name']) # this is non-atomic, but the lock was already held - setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value)) + setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value), None) def unlock(endpoint, filepath, _userid, _appname, value): '''Remove the lock as an xattr on behalf of the given userid''' log.debug('msg="Invoked unlock" filepath="%s" value="%s' % (filepath, value)) - rmxattr(endpoint, filepath, '0:0', common.LOCKKEY) + rmxattr(endpoint, filepath, '0:0', common.LOCKKEY, None) def readfile(_endpoint, filepath, _userid): @@ -161,7 +161,7 @@ def readfile(_endpoint, filepath, _userid): yield IOError(e) -def writefile(_endpoint, filepath, _userid, content, islock=False): +def writefile(_endpoint, filepath, _userid, content, _lockid, islock=False): '''Write a file via xroot on behalf of the given userid. The entire content is written and any pre-existing file is deleted (or moved to the previous version if supported). With islock=True, the file is opened with O_CREAT|O_EXCL.''' @@ -202,7 +202,7 @@ def writefile(_endpoint, filepath, _userid, content, islock=False): (filepath, (tend-tstart)*1000, islock)) -def renamefile(_endpoint, origfilepath, newfilepath, _userid): +def renamefile(_endpoint, origfilepath, newfilepath, _userid, _lockid): '''Rename a file from origfilepath to newfilepath on behalf of the given userid.''' try: os.rename(_getfilepath(origfilepath), _getfilepath(newfilepath)) @@ -212,7 +212,7 @@ def renamefile(_endpoint, origfilepath, newfilepath, _userid): def removefile(_endpoint, filepath, _userid, force=False): '''Remove a file on behalf of the given userid. - The force argument is irrelevant and ignored for local storage.''' + The force argument is irrelevant and ignored for local storage.''' try: os.remove(_getfilepath(filepath)) except (FileNotFoundError, PermissionError, IsADirectoryError, OSError) as e: diff --git a/src/core/wopi.py b/src/core/wopi.py index bf73b638..dd3a44de 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -218,7 +218,7 @@ def unlock(fileid, reqheaders, acctok): try: # also remove the LibreOffice-compatible lock file when relevant if os.path.splitext(acctok['filename'])[1] not in srv.nonofficetypes: - st.removefile(acctok['endpoint'], utils.getLibreOfficeLockName(acctok['filename']), acctok['userid'], force=True) + st.removefile(acctok['endpoint'], utils.getLibreOfficeLockName(acctok['filename']), acctok['userid'], True) except IOError: # same as above pass @@ -332,11 +332,11 @@ def renameFile(fileid, reqheaders, acctok): targetName = os.path.dirname(acctok['filename']) + '/' + targetName + os.path.splitext(acctok['filename'])[1] log.info('msg="RenameFile" user="%s" filename="%s" token="%s" targetname="%s"' % (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:], targetName)) - st.renamefile(acctok['endpoint'], acctok['filename'], targetName, acctok['userid']) + st.renamefile(acctok['endpoint'], acctok['filename'], targetName, acctok['userid'], utils.encodeLock(retrievedLock)) # also rename the locks if os.path.splitext(acctok['filename'])[1] not in srv.nonofficetypes: st.renamefile(acctok['endpoint'], utils.getLibreOfficeLockName(acctok['filename']), \ - utils.getLibreOfficeLockName(targetName), acctok['userid']) + utils.getLibreOfficeLockName(targetName), acctok['userid'], None) # prepare and send the response as JSON renamemd = {} renamemd['Name'] = reqheaders['X-WOPI-RequestedName'] @@ -426,7 +426,7 @@ def putFile(fileid): utils.storeWopiFile(flask.request, retrievedLock, acctok, utils.LASTSAVETIMEKEY, newname) # keep track of this action in the original file's xattr, to avoid looping (see below) - st.setxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], utils.LASTSAVETIMEKEY, 0) + st.setxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], utils.LASTSAVETIMEKEY, 0, utils.encodeLock(retrievedLock)) log.info('msg="Conflicting copy created" user="%s" savetime="%s" lastmtime="%s" newfilename="%s" token="%s"' % (acctok['userid'][-20:], savetime, mtime, newname, flask.request.args['access_token'][-20:])) # and report failure to the application: note we use a CONFLICT response as it is better handled by the app diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 304bbdf5..749c55cb 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -162,10 +162,13 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=None): '''Retrieves and logs a lock for a given file: returns the lock and its holder, or (None, None) if missing''' encacctok = flask.request.args['access_token'][-20:] if 'access_token' in flask.request.args else 'N/A' - lockcontent = st.getlock(acctok['endpoint'], overridefilename if overridefilename else acctok['filename'], acctok['userid']) - if not lockcontent: + try: + lockcontent = st.getlock(acctok['endpoint'], overridefilename if overridefilename else acctok['filename'], acctok['userid']) + if not lockcontent: + raise IOError + except IOError as e: log.info('msg="%s" user="%s" filename="%s" token="%s" error="No lock found"' % - (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok)) + (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok)) return None, None try: # check validity: a lock is deemed expired if the most recent between its expiration time and the last @@ -193,7 +196,7 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non if isinstance(lolock, IOError): raise lolock if 'WOPIServer' in lolock.decode('UTF-8'): - st.removefile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'], 1) + st.removefile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'], True) except (IOError, StopIteration) as e: log.warning('msg="Unable to delete the LibreOffice-compatible lock file" error="%s"' % ('empty lock' if isinstance(e, StopIteration) else str(e))) @@ -206,7 +209,9 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non def encodeLock(lock): '''Generates the lock payload given the raw data''' - return jwt.encode(lock, srv.wopisecret, algorithm='HS256') + if lock: + return jwt.encode(lock, srv.wopisecret, algorithm='HS256') + return None def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): @@ -237,7 +242,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): lockcontent = ',Collaborative Online Editor,%s,%s,WOPIServer;' % \ (srv.wopiurl, time.strftime('%d.%m.%Y %H:%M', time.localtime(time.time()))) st.writefile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'], \ - lockcontent, islock=True) + lockcontent, None, islock=True) except IOError as e: if common.EXCL_ERROR in str(e): # retrieve the LibreOffice-compatible lock just found @@ -285,7 +290,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): # on first lock, set an xattr with the current time for later conflicts checking try: - st.setxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY, int(time.time())) + st.setxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY, int(time.time()), encodeLock(lock)) except IOError as e: # not fatal, but will generate a conflict file later on, so log a warning log.warning('msg="Unable to set lastwritetime xattr" user="%s" filename="%s" token="%s" reason="%s"' % @@ -368,9 +373,9 @@ def storeWopiFile(request, retrievedlock, acctok, xakey, targetname=''): and stores the save time as an xattr. Throws IOError in case of any failure''' if not targetname: targetname = acctok['filename'] - st.writefile(acctok['endpoint'], targetname, acctok['userid'], request.get_data()) + st.writefile(acctok['endpoint'], targetname, acctok['userid'], request.get_data(), encodeLock(retrievedlock)) # save the current time for later conflict checking: this is never older than the mtime of the file - st.setxattr(acctok['endpoint'], targetname, acctok['userid'], xakey, int(time.time())) + st.setxattr(acctok['endpoint'], targetname, acctok['userid'], xakey, int(time.time()), encodeLock(retrievedlock)) # and reinstate the lock if existing if retrievedlock: st.setlock(acctok['endpoint'], targetname, acctok['userid'], acctok['appname'], encodeLock(retrievedlock)) diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index ef3c7c84..e6d5fff2 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -244,7 +244,7 @@ def statx(endpoint, fileid, userid, versioninv=0): } -def setxattr(endpoint, filepath, _userid, key, value): +def setxattr(endpoint, filepath, _userid, key, value, _lockid): '''Set the extended attribute to via a special open. The userid is overridden to make sure it also works on shared files.''' _xrootcmd(endpoint, 'attr', 'set', '0:0', 'mgm.attr.key=user.' + key + '&mgm.attr.value=' + str(value) + \ @@ -263,7 +263,7 @@ def getxattr(endpoint, filepath, _userid, key): return None -def rmxattr(endpoint, filepath, _userid, key): +def rmxattr(endpoint, filepath, _userid, key, _lockid): '''Remove the extended attribute via a special open. The userid is overridden to make sure it also works on shared files.''' _xrootcmd(endpoint, 'attr', 'rm', '0:0', 'mgm.attr.key=user.' + key + '&mgm.path=' + _getfilepath(filepath, encodeamp=True)) @@ -274,7 +274,7 @@ def setlock(endpoint, filepath, userid, appname, value): The special option "c" (create-if-not-exists) is used to be atomic''' try: log.debug('msg="Invoked setlock" filepath="%s" value="%s"' % (filepath, value)) - setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value) + '&mgm.option=c') + setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value) + '&mgm.option=c', None) except IOError as e: if EXCL_XATTR_MSG in str(e): raise IOError(common.EXCL_ERROR) @@ -297,13 +297,13 @@ def refreshlock(endpoint, filepath, userid, appname, value): if l['app_name'] != appname and l['app_name'] != 'wopi': raise IOError('File is locked by %s' % l['app_name']) # this is non-atomic, but the lock was already held - setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value)) + setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value), None) def unlock(endpoint, filepath, userid, _appname, value): '''Remove a lock as an xattr''' log.debug('msg="Invoked unlock" filepath="%s" value="%s' % (filepath, value)) - rmxattr(endpoint, filepath, userid, common.LOCKKEY) + rmxattr(endpoint, filepath, userid, common.LOCKKEY, None) def readfile(endpoint, filepath, userid): @@ -333,7 +333,7 @@ def readfile(endpoint, filepath, userid): yield chunk -def writefile(endpoint, filepath, userid, content, islock=False): +def writefile(endpoint, filepath, userid, content, _lockid, islock=False): '''Write a file via xroot on behalf of the given userid. The entire content is written and any pre-existing file is deleted (or moved to the previous version if supported). With islock=True, the write explicitly disables versioning, and the file is opened with @@ -370,7 +370,7 @@ def writefile(endpoint, filepath, userid, content, islock=False): (filepath, (tend-tstart)*1000, islock)) -def renamefile(endpoint, origfilepath, newfilepath, userid): +def renamefile(endpoint, origfilepath, newfilepath, userid, _lockid): '''Rename a file via a special open from origfilepath to newfilepath on behalf of the given userid.''' _xrootcmd(endpoint, 'file', 'rename', userid, 'mgm.path=' + _getfilepath(origfilepath, encodeamp=True) + \ '&mgm.file.source=' + _getfilepath(origfilepath, encodeamp=True) + \ diff --git a/test/test_storageiface.py b/test/test_storageiface.py index ae077f65..224db894 100644 --- a/test/test_storageiface.py +++ b/test/test_storageiface.py @@ -72,8 +72,7 @@ def __init__(self, *args, **kwargs): def test_stat(self): '''Call stat() and assert the path matches''' - buf = b'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) statInfo = self.storage.stat(self.endpoint, self.homepath + '/test.txt', self.userid) self.assertIsInstance(statInfo, dict) self.assertTrue('mtime' in statInfo, 'Missing mtime from stat output') @@ -82,8 +81,7 @@ def test_stat(self): def test_statx_fileid(self): '''Call statx() and test if fileid-based stat is supported''' - buf = b'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid) if self.endpoint in str(statInfo['inode']): # detected CS3 storage, test if fileid-based stat is supported @@ -94,13 +92,11 @@ def test_statx_fileid(self): def test_statx_invariant_fileid(self): '''Call statx() before and after updating a file, and assert the inode did not change''' - buf = b'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid, versioninv=1) self.assertIsInstance(statInfo, dict) inode = statInfo['inode'] - buf = b'blabla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid, versioninv=1) self.assertIsInstance(statInfo, dict) self.assertEqual(statInfo['inode'], inode, 'Fileid is not invariant to multiple write operations') @@ -120,8 +116,7 @@ def test_statx_nofile(self): def test_readfile_bin(self): '''Writes a binary file and reads it back, validating that the content matches''' - content = b'bla' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) content = '' for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') @@ -132,7 +127,7 @@ def test_readfile_bin(self): def test_readfile_text(self): '''Writes a text file and reads it back, validating that the content matches''' content = 'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None) content = '' for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') @@ -143,7 +138,7 @@ def test_readfile_text(self): def test_readfile_empty(self): '''Writes an empty file and reads it back, validating that the read does not fail''' content = '' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None) for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') content += chunk.decode('utf-8') @@ -158,8 +153,7 @@ def test_read_nofile(self): def test_write_remove_specialchars(self): '''Test write and removal of a file with special chars''' - buf = b'ebe5tresbsrdthbrdhvdtr' - self.storage.writefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid, databuf, None) statInfo = self.storage.stat(self.endpoint, self.homepath + '/testwrite&rm', self.userid) self.assertIsInstance(statInfo, dict) self.storage.removefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid) @@ -168,22 +162,20 @@ def test_write_remove_specialchars(self): def test_write_islock(self): '''Test double write with the islock flag''' - buf = b'ebe5tresbsrdthbrdhvdtr' try: self.storage.removefile(self.endpoint, self.homepath + '/testoverwrite', self.userid) except IOError: pass - self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, buf, islock=True) + self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, None, islock=True) statInfo = self.storage.stat(self.endpoint, self.homepath + '/testoverwrite', self.userid) self.assertIsInstance(statInfo, dict) with self.assertRaises(IOError) as context: - self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, buf, islock=True) + self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, None, islock=True) self.assertIn(EXCL_ERROR, str(context.exception)) self.storage.removefile(self.endpoint, self.homepath + '/testoverwrite', self.userid) def test_write_race(self): '''Test multithreaded double write with the islock flag''' - buf = b'ebe5tresbsrdthbrdhvdtr' try: self.storage.removefile(self.endpoint, self.homepath + '/testwriterace', self.userid) except IOError: @@ -199,12 +191,11 @@ def test_write_race(self): def test_lock(self): '''Test setting locks''' - buf = b'ebe5tresbsrdthbrdhvdtr' try: self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid) except IOError: pass - self.storage.writefile(self.endpoint, self.homepath + '/testlock', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/testlock', self.userid, databuf, None) statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlock', self.userid) self.assertIsInstance(statInfo, dict) self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'testlock') @@ -226,7 +217,7 @@ def test_refresh_lock(self): self.storage.removefile(self.endpoint, self.homepath + '/testrlock', self.userid) except IOError: pass - self.storage.writefile(self.endpoint, self.homepath + '/testrlock', self.userid, databuf) + self.storage.writefile(self.endpoint, self.homepath + '/testrlock', self.userid, databuf, None) statInfo = self.storage.stat(self.endpoint, self.homepath + '/testrlock', self.userid) self.assertIsInstance(statInfo, dict) with self.assertRaises(IOError) as context: @@ -248,12 +239,11 @@ def test_refresh_lock(self): def test_lock_race(self): '''Test multithreaded setting locks''' - buf = b'ebe5tresbsrdthbrdhvdtr' try: self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid) except IOError: pass - self.storage.writefile(self.endpoint, self.homepath + '/testlockrace', self.userid, buf) + self.storage.writefile(self.endpoint, self.homepath + '/testlockrace', self.userid, databuf, None) statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlockrace', self.userid) self.assertIsInstance(statInfo, dict) t = Thread(target=self.storage.setlock, @@ -264,31 +254,52 @@ def test_lock_race(self): self.assertIn(EXCL_ERROR, str(context.exception)) self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid) + @unittest.expectedFailure + def test_lock_operations(self): + '''Test file operations on locked file (expected to fail until locks are enforced by the storage)''' + try: + self.storage.removefile(self.endpoint, self.homepath + '/testlockop', self.userid) + except IOError: + pass + self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, None) + statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlockop', self.userid) + self.assertIsInstance(statInfo, dict) + self.storage.setlock(self.endpoint, self.homepath + '/testlockop', self.userid, 'myapp', 'testlock') + self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, 'testlock') + self.storage.setxattr(self.endpoint, self.homepath + '/testlockop', self.userid, 'testkey', 123, 'testlock') + self.storage.renamefile(self.endpoint, self.homepath + '/testlockop', self.homepath + '/testlockop_renamed', self.userid, 'testlock') + with self.assertRaises(IOError): + self.storage.writefile(self.endpoint, self.homepath + '/testlockop_renamed', self.userid, databuf, None) + with self.assertRaises(IOError): + self.storage.setxattr(self.endpoint, self.homepath + '/testlockop_renamed', self.userid, 'testkey', 123, None) + with self.assertRaises(IOError): + self.storage.renamefile(self.endpoint, self.homepath + '/testlockop_renamed', self.homepath + '/testlockop', self.userid, None) + self.storage.removefile(self.endpoint, self.homepath + '/testlockop_renamed', self.userid) + def test_remove_nofile(self): '''Test removal of a non-existing file''' - with self.assertRaises(IOError): + with self.assertRaises(IOError) as context: self.storage.removefile(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid) + self.assertIn(ENOENT_MSG, str(context.exception)) def test_xattr(self): '''Test all xattr methods with special chars''' - buf = b'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, buf) - self.storage.setxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', 123) + self.storage.writefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, databuf, None) + self.storage.setxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', 123, None) v = self.storage.getxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey') self.assertEqual(v, '123') - self.storage.rmxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey') + self.storage.rmxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', None) v = self.storage.getxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey') self.assertEqual(v, None) self.storage.removefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid) def test_rename_statx(self): '''Test renaming and statx of a file with special chars''' - buf = b'bla\n' - self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, buf) - self.storage.renamefile(self.endpoint, self.homepath + '/test.txt', self.homepath + '/test&renamed.txt', self.userid) + self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) + self.storage.renamefile(self.endpoint, self.homepath + '/test.txt', self.homepath + '/test&renamed.txt', self.userid, None) statInfo = self.storage.statx(self.endpoint, self.homepath + '/test&renamed.txt', self.userid) self.assertEqual(statInfo['filepath'], self.homepath + '/test&renamed.txt') - self.storage.renamefile(self.endpoint, self.homepath + '/test&renamed.txt', self.homepath + '/test.txt', self.userid) + self.storage.renamefile(self.endpoint, self.homepath + '/test&renamed.txt', self.homepath + '/test.txt', self.userid, None) statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid) self.assertEqual(statInfo['filepath'], self.homepath + '/test.txt') self.storage.removefile(self.endpoint, self.homepath + '/test.txt', self.userid) From ce2eca3474886df26c86694cc0b643328522a5dc Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Fri, 4 Feb 2022 14:27:11 +0100 Subject: [PATCH 13/29] Properly implemented support for conflict paths --- src/core/wopi.py | 4 ++-- src/core/wopiutils.py | 10 +++++----- src/wopiserver.py | 6 +++--- wopiserver.conf | 10 ++++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/core/wopi.py b/src/core/wopi.py index dd3a44de..c4e1ae01 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -420,9 +420,9 @@ def putFile(fileid): try: utils.storeWopiFile(flask.request, retrievedLock, acctok, utils.LASTSAVETIMEKEY, newname) except IOError as e: - if common.ACCESS_ERROR in str(e): + if common.ACCESS_ERROR in str(e) and srv.conflictpath: # let's try the user's home instead of the current folder - newname = utils.getuserhome(acctok['username']) + os.path.sep + os.path.basename(newname) + newname = utils.getconflictpath(acctok['username']) + os.path.sep + os.path.basename(newname) utils.storeWopiFile(flask.request, retrievedLock, acctok, utils.LASTSAVETIMEKEY, newname) # keep track of this action in the original file's xattr, to avoid looping (see below) diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 749c55cb..b1669526 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -381,8 +381,8 @@ def storeWopiFile(request, retrievedlock, acctok, xakey, targetname=''): st.setlock(acctok['endpoint'], targetname, acctok['userid'], acctok['appname'], encodeLock(retrievedlock)) -def getuserhome(username): - '''Returns the path to the "home" directory for a given user. - TODO This is CERN/EOS-specific, to be removed once locking is fully implemented - and the logic to store webconflict files can be dropped.''' - return '/eos/user/%s/%s' % (username[0], username) +def getconflictpath(username): + '''Returns the path to a suitable conflict path directory for a given user''' + if not srv.conflictpath: + return None + return srv.conflictpath.replace('user_initial', username[0]).replace('username', username) diff --git a/src/wopiserver.py b/src/wopiserver.py index ae6e7ac4..1007d07b 100755 --- a/src/wopiserver.py +++ b/src/wopiserver.py @@ -123,10 +123,10 @@ def init(cls): cls.log.error('msg="Failed to open the provided certificate or key to start in https mode"') raise cls.wopiurl = cls.config.get('general', 'wopiurl') - if cls.config.has_option('general', 'lockpath'): - cls.lockpath = cls.config.get('general', 'lockpath') + if cls.config.has_option('general', 'conflictpath'): + cls.conflictpath = cls.config.get('general', 'conflictpath') else: - cls.lockpath = '' + cls.conflictpath = None _ = cls.config.get('general', 'downloadurl') # make sure this is defined # WOPI proxy configuration (optional) cls.wopiproxy = cls.config.get('general', 'wopiproxy', fallback='') diff --git a/wopiserver.conf b/wopiserver.conf index f9125b85..399299c5 100644 --- a/wopiserver.conf +++ b/wopiserver.conf @@ -57,10 +57,12 @@ wopilockexpiration = 3600 # on-premise setups. #wopilockstrictcheck = False -# Location of the lock files. Currently, two modes are supported: -# if a path is provided, all locks will be stored there with a hashed name, -# otherwise the lock is stored on the same path as the original file. -#lockpath = /your_storage/wopilocks +# Location of the webconflict files. By default, such files are stored +# in the same path as the original file. If that fails, and a path is provided +# here, an attempt is made to store such files in there. +# The keywords and are replaced with the actual username's +# initial letter and the actual username, respectively. +#conflictpath = /your_storage/home/user_initial/username # Enable support of rename operations from WOPI apps. This is currently # disabled by default as it has been observed that both MS Office and Collabora From 0e330903359638807607c9eaf33a6a0fbcc3b1ec Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Mon, 7 Feb 2022 10:39:06 +0100 Subject: [PATCH 14/29] Bridge: fixed uncaught exception --- src/bridge/__init__.py | 5 ++++- src/bridge/codimd.py | 14 +++++++++----- src/bridge/etherpad.py | 11 +++++++---- src/bridge/wopiclient.py | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/bridge/__init__.py b/src/bridge/__init__.py index a3a886b1..58af90d0 100644 --- a/src/bridge/__init__.py +++ b/src/bridge/__init__.py @@ -369,7 +369,10 @@ def cleanup(self, openfile, wopisrc, wopilock): del WB.openfiles[wopisrc] elif openfile['toclose'] != wopilock['toclose']: # some user still on it, refresh lock if the toclose part has changed - wopic.refreshlock(wopisrc, openfile['acctok'], wopilock, toclose=openfile['toclose']) + try: + wopic.refreshlock(wopisrc, openfile['acctok'], wopilock, toclose=openfile['toclose']) + except wopic.InvalidLock: + WB.log.warning('msg="SaveThread: failed to refresh lock, will try again later" url="%s"' % wopisrc) @atexit.register diff --git a/src/bridge/codimd.py b/src/bridge/codimd.py index 4930ad6b..ce02a49e 100644 --- a/src/bridge/codimd.py +++ b/src/bridge/codimd.py @@ -269,11 +269,15 @@ def savetostorage(wopisrc, acctok, isclose, wopilock): if isclose and wopilock['digest'] == 'dirty': h = hashlib.sha1() h.update(mddoc) - wopilock = wopic.refreshlock(wopisrc, acctok, wopilock, digest=(h.hexdigest() if h else 'dirty')) - log.info('msg="Save completed" filename="%s" isclose="%s" token="%s"' % - (wopilock['filename'], isclose, acctok[-20:])) - # combine the responses - return attresponse if attresponse else (wopic.jsonify('File saved successfully'), http.client.OK) + try: + wopilock = wopic.refreshlock(wopisrc, acctok, wopilock, digest=(h.hexdigest() if h else 'dirty')) + log.info('msg="Save completed" filename="%s" isclose="%s" token="%s"' % + (wopilock['filename'], isclose, acctok[-20:])) + # combine the responses + return attresponse if attresponse else (wopic.jsonify('File saved successfully'), http.client.OK) + except wopic.InvalidLock: + return wopic.jsonify('File saved, but failed to refresh lock'), http.client.INTERNAL_SERVER_ERROR + # on close, use saveas for either the new bundle, if this is the first time we have attachments, # or the single md file, if there are no more attachments. diff --git a/src/bridge/etherpad.py b/src/bridge/etherpad.py index 59cd38e5..f9c8e4fa 100644 --- a/src/bridge/etherpad.py +++ b/src/bridge/etherpad.py @@ -158,7 +158,10 @@ def savetostorage(wopisrc, acctok, isclose, wopilock): reply = wopic.handleputfile('PutFile', wopisrc, res) if reply: return reply - wopilock = wopic.refreshlock(wopisrc, acctok, wopilock, digest='dirty') - log.info('msg="Save completed" filename="%s" isclose="%s" token="%s"' % - (wopilock['filename'], isclose, acctok[-20:])) - return wopic.jsonify('File saved successfully'), http.client.OK + try: + wopilock = wopic.refreshlock(wopisrc, acctok, wopilock, digest='dirty') + log.info('msg="Save completed" filename="%s" isclose="%s" token="%s"' % + (wopilock['filename'], isclose, acctok[-20:])) + return wopic.jsonify('File saved successfully'), http.client.OK + except wopic.InvalidLock: + return wopic.jsonify('File saved, but failed to refresh lock'), http.client.INTERNAL_SERVER_ERROR diff --git a/src/bridge/wopiclient.py b/src/bridge/wopiclient.py index 507396b1..c18f0c54 100644 --- a/src/bridge/wopiclient.py +++ b/src/bridge/wopiclient.py @@ -113,7 +113,7 @@ def refreshlock(wopisrc, acctok, wopilock, digest=None, toclose=None): # else fail log.error('msg="Calling WOPI RefreshLock failed" url="%s" response="%d" reason="%s"' % (wopisrc, res.status_code, res.headers.get('X-WOPI-LockFailureReason'))) - return None + raise InvalidLock('Failed to refresh the lock') def relock(wopisrc, acctok, docid, isclose): From e103d7936f55edc924ddbf15f269c0c3baa61ab7 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Thu, 3 Feb 2022 18:11:25 +0100 Subject: [PATCH 15/29] Changed lock_id payload encoding Use a WebDAV-friendly prefix for the lock_id and use simple b64 encoding/decoding as opposed to a JWT --- src/core/wopiutils.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index b1669526..0156457f 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -13,6 +13,8 @@ from random import choice from string import ascii_lowercase from datetime import datetime +from base64 import b64encode, b64decode +from binascii import Error as B64Error import http.client import flask import jwt @@ -173,20 +175,19 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non try: # check validity: a lock is deemed expired if the most recent between its expiration time and the last # save time by WOPI has passed - rawlock = lockcontent['lock_id'] - lockcontent['lock_id'] = jwt.decode(lockcontent['lock_id'], srv.wopisecret, algorithms=['HS256']) + storedlock = lockcontent['lock_id'] + lockcontent['lock_id'] = _decodeLock(storedlock) savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY) - if max(lockcontent['expiration']['seconds'], int(savetime) + \ + if max(lockcontent['expiration']['seconds'], (int(savetime) if savetime else 0) + \ srv.config.getint('general', 'wopilockexpiration')) < time.time(): - # we got a malformed or expired lock, reject. Note that we may get an ExpiredSignatureError - # by jwt.decode() as we had stored it with a timed signature. - raise jwt.exceptions.ExpiredSignatureError - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError) as e: + # we got a malformed or expired lock, reject + raise B64Error + except B64Error as e: log.warning('msg="%s" user="%s" filename="%s" token="%s" error="WOPI lock expired or invalid, ignoring" ' \ 'exception="%s"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok, type(e))) # the retrieved lock is not valid any longer, discard and remove it from the backend try: - st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], rawlock) + st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], storedlock) except IOError: # ignore, it's not worth to report anything here pass @@ -208,9 +209,19 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non def encodeLock(lock): - '''Generates the lock payload given the raw data''' + '''Generates the lock payload for the storage given the raw metadata''' if lock: - return jwt.encode(lock, srv.wopisecret, algorithm='HS256') + # the prefix here makes the lock WebDAV-compatible, though according to + # https://datatracker.ietf.org/doc/html/rfc4918#appendix-C the payload must be a UUID + # (see https://github.com/cs3org/reva/pull/2460#discussion_r802360564 for more details) + return 'opaquelocktoken:' + b64encode(lock.encode()).decode() + return None + + +def _decodeLock(storedlock): + '''Restores the lock payload reverting the `encodeLock` format. May raise B64Error''' + if storedlock: + return b64decode(storedlock.replace('opaquelocktoken:', '').encode()).decode() return None From da5ac35fd3a97ef0d21498ffb0c7d5eda9669219 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 9 Feb 2022 11:51:05 +0100 Subject: [PATCH 16/29] Fixed check for expired locks --- src/core/commoniface.py | 2 +- src/core/wopiutils.py | 28 +++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index 3de835fd..bb43b25c 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -63,7 +63,7 @@ def retrieverevalock(rawlock): if 'h' in l: # temporary code to support the data structure from WOPI 8.0 l['app_name'] = l['h'] - l['lock_id'] = l['md'] + l['lock_id'] = 'opaquelocktoken:' + l['md'] l['expiration'] = {} l['expiration']['seconds'] = l['exp'] return l diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 0156457f..928f68a6 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -172,26 +172,27 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non log.info('msg="%s" user="%s" filename="%s" token="%s" error="No lock found"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok)) return None, None + try: - # check validity: a lock is deemed expired if the most recent between its expiration time and the last - # save time by WOPI has passed + # decode the lock payload storedlock = lockcontent['lock_id'] lockcontent['lock_id'] = _decodeLock(storedlock) - savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY) - if max(lockcontent['expiration']['seconds'], (int(savetime) if savetime else 0) + \ - srv.config.getint('general', 'wopilockexpiration')) < time.time(): - # we got a malformed or expired lock, reject - raise B64Error except B64Error as e: - log.warning('msg="%s" user="%s" filename="%s" token="%s" error="WOPI lock expired or invalid, ignoring" ' \ - 'exception="%s"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok, type(e))) + log.warning('msg="%s" user="%s" filename="%s" token="%s" error="Malformed WOPI lock, ignoring" exception="%s"' % + (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok, type(e))) + + # check validity: a lock is deemed expired if the most recent between its expiration time and + # the last save time by WOPI has passed + savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], LASTSAVETIMEKEY) + if max(lockcontent['expiration']['seconds'], + (int(savetime) if savetime else 0) + srv.config.getint('general', 'wopilockexpiration')) < time.time(): # the retrieved lock is not valid any longer, discard and remove it from the backend try: st.unlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], storedlock) except IOError: # ignore, it's not worth to report anything here pass - # also remove the LibreOffice-compatible lock file, if it has the expected signature - cf. storeWopiLock() + # also remove the LibreOffice-compatible lock file, if it exists and has the expected signature - cf. storeWopiLock() try: lolock = next(st.readfile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'])) if isinstance(lolock, IOError): @@ -202,6 +203,7 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non log.warning('msg="Unable to delete the LibreOffice-compatible lock file" error="%s"' % ('empty lock' if isinstance(e, StopIteration) else str(e))) return None, None + log.info('msg="%s" user="%s" filename="%s" fileid="%s" lock="%s" retrievedlock="%s" expTime="%s" token="%s"' % (operation.title(), acctok['userid'][-20:], acctok['filename'], fileid, lockforlog, lockcontent['lock_id'], time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(lockcontent['expiration']['seconds'])), encacctok)) @@ -220,9 +222,9 @@ def encodeLock(lock): def _decodeLock(storedlock): '''Restores the lock payload reverting the `encodeLock` format. May raise B64Error''' - if storedlock: - return b64decode(storedlock.replace('opaquelocktoken:', '').encode()).decode() - return None + if storedlock and storedlock.find('opaquelocktoken:') == 0: + return b64decode(storedlock[16:].encode()).decode() + return storedlock # it's not our lock, but it's likely valid def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): From 71f521970b1c5dfbe331cd975a28e81397ce4dc5 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 9 Feb 2022 18:52:00 +0100 Subject: [PATCH 17/29] Fixed error handling and improved logs --- src/core/cs3iface.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index 3b5eb3c0..58d8bbe2 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -110,8 +110,8 @@ def setxattr(_endpoint, filepath, userid, key, value, lockid): req = cs3sp.SetArbitraryMetadataRequest(ref=reference, arbitrary_metadata=md) #, lock_id=lockid) res = ctx['cs3gw'].SetArbitraryMetadata(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to setxattr" filepath="%s" key="%s" reason="%s"' % - (filepath, key, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setxattr" filepath="%s" key="%s" code="%s" reason="%s"' % + (filepath, key, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked setxattr" result="%s"' % res) @@ -122,6 +122,9 @@ def getxattr(_endpoint, filepath, userid, key): reference = cs3spr.Reference(path=filepath) statInfo = ctx['cs3gw'].Stat(request=cs3sp.StatRequest(ref=reference), metadata=[('x-access-token', userid)]) tend = time.time() + if statInfo.status.code == cs3code.CODE_NOT_FOUND: + log.debug('msg="Invoked stat for getxattr on missing file" filepath="%s"' % filepath) + return None if statInfo.status.code != cs3code.CODE_OK: log.error('msg="Failed to stat" filepath="%s" key="%s" reason="%s"' % (filepath, key, statInfo.status.message.replace('"', "'"))) @@ -156,9 +159,13 @@ def setlock(_endpoint, filepath, userid, appname, value): expiration={'seconds': int(time.time() + ctx['lockexpiration'])}) req = cs3sp.SetLockRequest(ref=reference, lock=lock) res = ctx['cs3gw'].SetLock(request=req, metadata=[('x-access-token', userid)]) + if res.status.code == cs3code.CODE_FAILED_PRECONDITION: + log.info('msg="Invoked setlock on an already locked entity" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, res.status.message.replace('"', "'"))) + raise IOError(common.EXCL_ERROR) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" reason="%s"' % - (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to setlock" filepath="%s" appname="%s" value="%s" code="%s" reason="%s"' % + (filepath, appname, value, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked setlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -169,10 +176,11 @@ def getlock(_endpoint, filepath, userid): req = cs3sp.GetLockRequest(ref=reference) res = ctx['cs3gw'].GetLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code == cs3code.CODE_NOT_FOUND: - log.debug('msg="Invoked getlock on unlocked file" filepath="%s"' % filepath) + log.debug('msg="Invoked getlock on unlocked or missing file" filepath="%s"' % filepath) return None if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to getlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to getlock" filepath="%s" code="%s" reason="%s"' % + (filepath, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked getlock" filepath="%s" result="%s"' % (filepath, res.lock)) # return a dict that mimics the internal JSON structure used by Reva, cf. commoniface.py @@ -195,8 +203,8 @@ def refreshlock(_endpoint, filepath, userid, appname, value): req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) res = ctx['cs3gw'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" reason="%s"' % - (filepath, appname, value, res.status.message.replace('"', "'"))) + log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" code="%s" reason="%s"' % + (filepath, appname, value, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked refreshlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -208,7 +216,8 @@ def unlock(_endpoint, filepath, userid, appname, value): req = cs3sp.UnlockRequest(ref=reference, lock=lock) res = ctx['cs3gw'].Unlock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to unlock" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to unlock" filepath="%s" code="%s" reason="%s"' % + (filepath, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked unlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -223,8 +232,8 @@ def readfile(_endpoint, filepath, userid): log.info('msg="File not found on read" filepath="%s"' % filepath) yield IOError(common.ENOENT_MSG) elif initfiledownloadres.status.code != cs3code.CODE_OK: - log.error('msg="Failed to initiateFileDownload on read" filepath="%s" reason="%s"' % - (filepath, initfiledownloadres.status.message.replace('"', "'"))) + log.error('msg="Failed to initiateFileDownload on read" filepath="%s" code="%s" reason="%s"' % + (filepath, initfiledownloadres.status.code, initfiledownloadres.status.message.replace('"', "'"))) yield IOError(initfiledownloadres.status.message) log.debug('msg="readfile: InitiateFileDownloadRes returned" protocols="%s"' % initfiledownloadres.protocols) @@ -267,8 +276,8 @@ def writefile(_endpoint, filepath, userid, content, lockid, islock=False): req = cs3sp.InitiateFileUploadRequest(ref=cs3spr.Reference(path=filepath), opaque=metadata) # lock_id=lockid, initfileuploadres = ctx['cs3gw'].InitiateFileUpload(request=req, metadata=[('x-access-token', userid)]) if initfileuploadres.status.code != cs3code.CODE_OK: - log.error('msg="Failed to initiateFileUpload on write" filepath="%s" reason="%s"' % \ - (filepath, initfileuploadres.status.message.replace('"', "'"))) + log.error('msg="Failed to initiateFileUpload on write" filepath="%s" code="%s" reason="%s"' % \ + (filepath, initfileuploadres.status.code, initfileuploadres.status.message.replace('"', "'"))) raise IOError(initfileuploadres.status.message) log.debug('msg="writefile: InitiateFileUploadRes returned" protocols="%s"' % initfileuploadres.protocols) @@ -300,7 +309,8 @@ def renamefile(_endpoint, filepath, newfilepath, userid, lockid): req = cs3sp.MoveRequest(source=source, destination=destination) # lock_id=lockid) res = ctx['cs3gw'].Move(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to rename file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to rename file" filepath="%s" code="%s" reason="%s"' % + (filepath, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked renamefile" result="%s"' % res) @@ -315,7 +325,7 @@ def removefile(_endpoint, filepath, userid, _force=False): if str(res) == common.ENOENT_MSG: log.info('msg="Invoked removefile on non-existing file" filepath="%s"' % filepath) else: - log.error('msg="Failed to remove file" filepath="%s" reason="%s"' % (filepath, res.status.message.replace('"', "'"))) + log.error('msg="Failed to remove file" filepath="%s" code="%s" reason="%s"' % + (filepath, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked removefile" result="%s"' % res) - From 57d79b4345b4bba6822237c9d73813825e49aabb Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Thu, 10 Feb 2022 18:38:21 +0100 Subject: [PATCH 18/29] Added lockid on readfile and improved logs --- src/core/cs3iface.py | 13 ++++++++----- src/core/localiface.py | 7 ++++++- src/core/wopi.py | 4 +++- src/core/wopiutils.py | 8 ++++---- src/core/xrootiface.py | 19 ++++++++++++++++--- test/test_storageiface.py | 8 ++++---- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index 58d8bbe2..18aacb38 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -188,7 +188,10 @@ def getlock(_endpoint, filepath, userid): 'lock_id': res.lock.lock_id, 'type': res.lock.type, 'app_name': res.lock.app_name, - 'user': res.lock.user.opaque_id + '@' + res.lock.user.idp, + 'user': {'opaque_id' : res.lock.user.opaque_id, + 'idp': res.lock.user.idp, + 'type': 1 + } if res.lock.user.opaque_id else {}, 'expiration': { 'seconds': res.lock.expiration.seconds } @@ -203,8 +206,8 @@ def refreshlock(_endpoint, filepath, userid, appname, value): req = cs3sp.RefreshLockRequest(ref=reference, lock=lock) res = ctx['cs3gw'].RefreshLock(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - log.error('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" code="%s" reason="%s"' % - (filepath, appname, value, res.status.code, res.status.message.replace('"', "'"))) + log.warning('msg="Failed to refreshlock" filepath="%s" appname="%s" value="%s" code="%s" reason="%s"' % + (filepath, appname, value, res.status.code, res.status.message.replace('"', "'"))) raise IOError(res.status.message) log.debug('msg="Invoked refreshlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) @@ -222,11 +225,11 @@ def unlock(_endpoint, filepath, userid, appname, value): log.debug('msg="Invoked unlock" filepath="%s" value="%s" result="%s"' % (filepath, value, res.status)) -def readfile(_endpoint, filepath, userid): +def readfile(_endpoint, filepath, userid, lockid): '''Read a file using the given userid as access token. Note that the function is a generator, managed by Flask.''' tstart = time.time() # prepare endpoint - req = cs3sp.InitiateFileDownloadRequest(ref=cs3spr.Reference(path=filepath)) + req = cs3sp.InitiateFileDownloadRequest(ref=cs3spr.Reference(path=filepath)) # lock_id=lockid initfiledownloadres = ctx['cs3gw'].InitiateFileDownload(request=req, metadata=[('x-access-token', userid)]) if initfiledownloadres.status.code == cs3code.CODE_NOT_FOUND: log.info('msg="File not found on read" filepath="%s"' % filepath) diff --git a/src/core/localiface.py b/src/core/localiface.py index 7dffd5d3..529ecee4 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -124,9 +124,14 @@ def refreshlock(endpoint, filepath, _userid, appname, value): log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) l = getlock(endpoint, filepath, _userid) if not l: + log.warning('msg="Failed to refreshlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is not locked')) raise IOError('File was not locked') if l['app_name'] != appname and l['app_name'] != 'wopi': + log.warning('msg="Failed to refreshlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is locked by %s' % l['app_name'])) raise IOError('File is locked by %s' % l['app_name']) + log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) # this is non-atomic, but the lock was already held setxattr(endpoint, filepath, '0:0', common.LOCKKEY, common.genrevalock(appname, value), None) @@ -137,7 +142,7 @@ def unlock(endpoint, filepath, _userid, _appname, value): rmxattr(endpoint, filepath, '0:0', common.LOCKKEY, None) -def readfile(_endpoint, filepath, _userid): +def readfile(_endpoint, filepath, _userid, _lockid): '''Read a file on behalf of the given userid. Note that the function is a generator, managed by Flask.''' log.debug('msg="Invoking readFile" filepath="%s"' % filepath) try: diff --git a/src/core/wopi.py b/src/core/wopi.py index c4e1ae01..a4a1c682 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -114,7 +114,9 @@ def getFile(fileid): log.info('msg="GetFile" user="%s" filename="%s" fileid="%s" token="%s"' % (acctok['userid'][-20:], acctok['filename'], fileid, flask.request.args['access_token'][-20:])) # get the file reader generator - f = peekable(st.readfile(acctok['endpoint'], acctok['filename'], acctok['userid'])) + # TODO for the time being we do not look if the file is locked. Once exclusive locks are implemented in Reva, + # the lock must be fetched prior to the following call in order to access the file. + f = peekable(st.readfile(acctok['endpoint'], acctok['filename'], acctok['userid'], None)) firstchunk = f.peek() if isinstance(firstchunk, IOError): return ('Failed to fetch file from storage: %s' % firstchunk), http.client.INTERNAL_SERVER_ERROR diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 928f68a6..297cdd60 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -170,7 +170,7 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non raise IOError except IOError as e: log.info('msg="%s" user="%s" filename="%s" token="%s" error="No lock found"' % - (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok)) + (operation.title(), acctok['userid'][-20:], acctok['filename'], encacctok)) return None, None try: @@ -194,7 +194,7 @@ def retrieveWopiLock(fileid, operation, lockforlog, acctok, overridefilename=Non pass # also remove the LibreOffice-compatible lock file, if it exists and has the expected signature - cf. storeWopiLock() try: - lolock = next(st.readfile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'])) + lolock = next(st.readfile(acctok['endpoint'], getLibreOfficeLockName(acctok['filename']), acctok['userid'], None)) if isinstance(lolock, IOError): raise lolock if 'WOPIServer' in lolock.decode('UTF-8'): @@ -261,7 +261,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): # retrieve the LibreOffice-compatible lock just found try: retrievedlolock = next(st.readfile(acctok['endpoint'], \ - getLibreOfficeLockName(acctok['filename']), acctok['userid'])) + getLibreOfficeLockName(acctok['filename']), acctok['userid'], None)) if isinstance(retrievedlolock, IOError): raise retrievedlolock retrievedlolock = retrievedlolock.decode('UTF-8') @@ -296,7 +296,7 @@ def storeWopiLock(fileid, operation, lock, oldlock, acctok, isoffice): (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], e)) try: - # now atomically store the lock as encoded JWT + # now atomically store the lock st.setlock(acctok['endpoint'], acctok['filename'], acctok['userid'], acctok['appname'], encodeLock(lock)) log.info('msg="%s" filename="%s" token="%s" lock="%s" result="success"' % (operation.title(), acctok['filename'], flask.request.args['access_token'][-20:], lock)) diff --git a/src/core/xrootiface.py b/src/core/xrootiface.py index e6d5fff2..c1ee5a2a 100644 --- a/src/core/xrootiface.py +++ b/src/core/xrootiface.py @@ -290,23 +290,36 @@ def getlock(endpoint, filepath, userid): def refreshlock(endpoint, filepath, userid, appname, value): '''Refresh the lock value as an xattr''' - log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) l = getlock(endpoint, filepath, userid) if not l: + log.warning('msg="Failed to refreshlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is not locked')) raise IOError('File was not locked') if l['app_name'] != appname and l['app_name'] != 'wopi': + log.warning('msg="Failed to refreshlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is locked by %s' % l['app_name'])) raise IOError('File is locked by %s' % l['app_name']) + log.debug('msg="Invoked refreshlock" filepath="%s" value="%s"' % (filepath, value)) # this is non-atomic, but the lock was already held setxattr(endpoint, filepath, userid, common.LOCKKEY, common.genrevalock(appname, value), None) -def unlock(endpoint, filepath, userid, _appname, value): +def unlock(endpoint, filepath, userid, appname, value): '''Remove a lock as an xattr''' + l = getlock(endpoint, filepath, userid) + if not l: + log.warning('msg="Failed to unlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is not locked')) + raise IOError('File was not locked') + if l['app_name'] != appname and l['app_name'] != 'wopi': + log.warning('msg="Failed to unlock" filepath="%s" appname="%s" reason="%s"' % + (filepath, appname, 'File is locked by %s' % l['app_name'])) + raise IOError('File is locked by %s' % l['app_name']) log.debug('msg="Invoked unlock" filepath="%s" value="%s' % (filepath, value)) rmxattr(endpoint, filepath, userid, common.LOCKKEY, None) -def readfile(endpoint, filepath, userid): +def readfile(endpoint, filepath, userid, _lockid): '''Read a file via xroot on behalf of the given userid. Note that the function is a generator, managed by Flask.''' log.debug('msg="Invoking readFile" filepath="%s"' % filepath) with XrdClient.File() as f: diff --git a/test/test_storageiface.py b/test/test_storageiface.py index 224db894..f5d22f90 100644 --- a/test/test_storageiface.py +++ b/test/test_storageiface.py @@ -118,7 +118,7 @@ def test_readfile_bin(self): '''Writes a binary file and reads it back, validating that the content matches''' self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None) content = '' - for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): + for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') content += chunk.decode('utf-8') self.assertEqual(content, 'bla', 'File test.txt should contain the string "bla"') @@ -129,7 +129,7 @@ def test_readfile_text(self): content = 'bla\n' self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None) content = '' - for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): + for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') content += chunk.decode('utf-8') self.assertEqual(content, 'bla\n', 'File test.txt should contain the text "bla\\n"') @@ -139,7 +139,7 @@ def test_readfile_empty(self): '''Writes an empty file and reads it back, validating that the read does not fail''' content = '' self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None) - for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid): + for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None): self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile') content += chunk.decode('utf-8') self.assertEqual(content, '', 'File test.txt should be empty') @@ -147,7 +147,7 @@ def test_readfile_empty(self): def test_read_nofile(self): '''Test reading of a non-existing file''' - readex = next(self.storage.readfile(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid)) + readex = next(self.storage.readfile(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid, None)) self.assertIsInstance(readex, IOError, 'readfile returned %s' % readex) self.assertEqual(str(readex), ENOENT_MSG, 'readfile returned %s' % readex) From 258fd868755d741c2cee978fb804560d44231550 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Fri, 11 Feb 2022 18:34:14 +0100 Subject: [PATCH 19/29] Added support for a recovery path In case of failures or permission issues saving a file, an attempt is made to save a copy to a configured local storage in order to ease later recovery by service operations. Same process is performed by the bridge extension: fixes #39. --- src/bridge/__init__.py | 23 ++++-- src/bridge/codimd.py | 5 +- src/bridge/etherpad.py | 5 +- src/bridge/wopiclient.py | 2 +- src/core/wopi.py | 165 +++++++++++++++++++++------------------ src/core/wopiutils.py | 20 ++++- src/wopiserver.py | 10 ++- wopiserver.conf | 12 ++- wopiserver.yaml | 2 + 9 files changed, 148 insertions(+), 96 deletions(-) diff --git a/src/bridge/__init__.py b/src/bridge/__init__.py index 58af90d0..af39e4f5 100644 --- a/src/bridge/__init__.py +++ b/src/bridge/__init__.py @@ -20,6 +20,7 @@ from base64 import urlsafe_b64encode import flask import bridge.wopiclient as wopic +import core.wopiutils as utils # The supported plugins integrated with this WOPI Bridge @@ -171,6 +172,7 @@ def appopen(wopisrc, acctok): 'lastsave': int(time.time()) - WB.saveinterval, 'toclose': {acctok[-20:]: False}, 'docid': wopilock['docid'], + 'app': os.path.splitext(filemd['BaseFileName'])[1][1:], } # also clear any potential stale response for this document try: @@ -282,15 +284,15 @@ def run(self): self.cleanup(openfile, wopisrc, wopilock) except Exception as e: # pylint: disable=broad-except ex_type, ex_value, ex_traceback = sys.exc_info() - WB.log.error('msg="SaveThread: unexpected exception caught" ex="%s" type="%s" traceback="%s"' % - (e, ex_type, traceback.format_exception(ex_type, ex_value, ex_traceback))) - WB.log.info('msg="SaveThread terminated, shutting down"') + WB.log.critical('msg="SaveThread: unexpected exception caught" ex="%s" type="%s" traceback="%s"' % + (e, ex_type, traceback.format_exception(ex_type, ex_value, ex_traceback))) def savedirty(self, openfile, wopisrc): '''save documents that are dirty for more than `saveinterval` or that are being closed''' wopilock = None if openfile['tosave'] and (_intersection(openfile['toclose']) or (openfile['lastsave'] < time.time() - WB.saveinterval)): + app = BRIDGE_EXT_PLUGINS.get(openfile['app']) if 'app' in openfile else None try: wopilock = wopic.getlock(wopisrc, openfile['acctok']) except wopic.InvalidLock: @@ -301,14 +303,23 @@ def savedirty(self, openfile, wopisrc): wopisrc, openfile['acctok'], openfile['docid'], _intersection(openfile['toclose'])) except wopic.InvalidLock as ile: # even this attempt failed, give up - # TODO here we should save the file on a local storage to help later recovery WB.saveresponses[wopisrc] = wopic.jsonify(str(ile)), http.client.INTERNAL_SERVER_ERROR + # attempt to save to local storage to help for later recovery: this is a feature of the core wopiserver + content = rc = None + if app: + content, rc = WB.plugins[app].savetostorage(wopisrc, openfile['acctok'], False, {'docid': openfile['docid']}, onlyfetch=True) + if rc == http.client.OK: + utils.storeForRecovery(content, wopisrc[wopisrc.rfind('/')+1:], openfile['acctok'][-20:], ile) + if rc != http.client.OK: + WB.log.error('msg="SaveThread: failed to fetch file for recovery to local storage" token="%s" docid="%s" app="%s" response="%s"' % + (openfile['acctok'][-20:], openfile['docid'], app, content)) # set some 'fake' metadata, will be automatically cleaned up later openfile['lastsave'] = int(time.time()) openfile['tosave'] = False openfile['toclose'] = {'invalid-lock': True} return None - app = BRIDGE_EXT_PLUGINS.get(wopilock['app']) + if not app: + app = BRIDGE_EXT_PLUGINS.get(wopilock['app']) if not app: WB.log.error('msg="SaveThread: malformed app attribute in WOPI lock" lock="%s"' % wopilock) WB.saveresponses[wopisrc] = wopic.jsonify('Unrecognized app for this file'), http.client.BAD_REQUEST @@ -379,7 +390,7 @@ def cleanup(self, openfile, wopisrc, wopilock): def stopsavethread(): '''Exit handler to cleanly stop the storage sync thread''' if WB.savethread: - WB.log.info('msg="Waiting for SaveThread to complete"') + WB.log.info('msg="Waiting for SaveThread to complete"') # TODO when this handler is called, the logger is not accessible any longer with WB.savecv: WB.active = False WB.savecv.notify() diff --git a/src/bridge/codimd.py b/src/bridge/codimd.py index ce02a49e..cd5e7a63 100644 --- a/src/bridge/codimd.py +++ b/src/bridge/codimd.py @@ -233,13 +233,16 @@ def _getattachments(mddoc, docfilename, forcezip=False): return zip_buffer.getvalue(), response -def savetostorage(wopisrc, acctok, isclose, wopilock): +def savetostorage(wopisrc, acctok, isclose, wopilock, onlyfetch=False): '''Copy document from CodiMD back to storage''' # get document from CodiMD try: log.info('msg="Fetching file from CodiMD" isclose="%s" appurl="%s" token="%s"' % (isclose, appurl + wopilock['docid'], acctok[-20:])) mddoc = _fetchfromcodimd(wopilock, acctok) + if onlyfetch: + # this is used only in case of recovery to local storage + return mddoc, http.client.OK except AppFailure: return wopic.jsonify('Could not save file, failed to fetch document from CodiMD'), http.client.INTERNAL_SERVER_ERROR diff --git a/src/bridge/etherpad.py b/src/bridge/etherpad.py index f9c8e4fa..25e91c84 100644 --- a/src/bridge/etherpad.py +++ b/src/bridge/etherpad.py @@ -134,13 +134,16 @@ def _fetchfrometherpad(wopilock, acctok): raise AppFailure -def savetostorage(wopisrc, acctok, isclose, wopilock): +def savetostorage(wopisrc, acctok, isclose, wopilock, onlyfetch=False): '''Copy document from Etherpad back to storage''' # get document from Etherpad try: log.info('msg="Fetching file from Etherpad" isclose="%s" appurl="%s" token="%s"' % (isclose, appurl + '/p' + wopilock['docid'], acctok[-20:])) epfile = _fetchfrometherpad(wopilock, acctok) + if onlyfetch: + # this is used only in case of recovery to local storage + return epfile, http.client.OK except AppFailure: return wopic.jsonify('Could not save file, failed to fetch document from Etherpad'), http.client.INTERNAL_SERVER_ERROR diff --git a/src/bridge/wopiclient.py b/src/bridge/wopiclient.py index c18f0c54..934b66ef 100644 --- a/src/bridge/wopiclient.py +++ b/src/bridge/wopiclient.py @@ -154,8 +154,8 @@ def handleputfile(wopicall, wopisrc, res): return jsonify('Error saving the file. %s' % res.headers.get('X-WOPI-LockFailureReason')), \ http.client.INTERNAL_SERVER_ERROR if res.status_code != http.client.OK: + # hopefully the server has kept a local copy for later recovery log.error('msg="Calling WOPI %s failed" url="%s" response="%s"' % (wopicall, wopisrc, res.status_code)) - # TODO need to save the file on a local storage for later recovery return jsonify('Error saving the file, please contact support'), http.client.INTERNAL_SERVER_ERROR return None diff --git a/src/core/wopi.py b/src/core/wopi.py index a4a1c682..4e62f33f 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -17,7 +17,7 @@ import core.wopiutils as utils import core.commoniface as common -IO_ERROR = 'I/O Error' +IO_ERROR = 'I/O Error, please contact support' # convenience references to global entities st = None @@ -264,10 +264,10 @@ def putRelative(fileid, reqheaders, acctok): # OK, the targetName is good to go break # we got another error with this file, fail - log.info('msg="PutRelative" user="%s" filename="%s" token="%s" suggTarget="%s" error="%s"' % - (acctok['userid'][-20:], targetName, flask.request.args['access_token'][-20:], \ - suggTarget, str(e))) - return 'Illegal filename %s: %s' % (targetName, e), http.client.BAD_REQUEST + log.warning('msg="PutRelative" user="%s" filename="%s" token="%s" suggTarget="%s" error="%s"' % + (acctok['userid'][-20:], targetName, flask.request.args['access_token'][-20:], \ + suggTarget, str(e))) + return 'Illegal filename %s' % targetName, http.client.BAD_REQUEST else: # the relative target is a filename to be respected, and that may overwrite an existing file relTarget = os.path.dirname(acctok['filename']) + os.path.sep + relTarget # make full path @@ -286,8 +286,7 @@ def putRelative(fileid, reqheaders, acctok): try: utils.storeWopiFile(flask.request, None, acctok, utils.LASTSAVETIMEKEY, targetName) except IOError as e: - log.info('msg="Error writing file" filename="%s" token="%s" error="%s"' % - (targetName, flask.request.args['access_token'][-20:], e)) + utils.storeForRecovery(flask.request.get_data(), targetName, flask.request.args['access_token'][-20:], e) return IO_ERROR, http.client.INTERNAL_SERVER_ERROR # generate an access token for the new file log.info('msg="PutRelative: generating new access token" user="%s" filename="%s" ' \ @@ -366,13 +365,19 @@ def _createNewFile(fileid, acctok): return 'File exists', http.client.CONFLICT except IOError: # indeed the file did not exist, so we write it for the first time - utils.storeWopiFile(flask.request, None, acctok, utils.LASTSAVETIMEKEY) - log.info('msg="File stored successfully" action="editnew" user="%s" filename="%s" token="%s"' % - (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:])) - # and we keep track of it as an open file with timestamp = Epoch, despite not having any lock yet. - # XXX this is to work around an issue with concurrent editing of newly created files (cf. iopOpen) - srv.openfiles[acctok['filename']] = ('0', set([acctok['username']])) - return 'OK', http.client.OK + try: + utils.storeWopiFile(flask.request, None, acctok, utils.LASTSAVETIMEKEY) + log.info('msg="File stored successfully" action="editnew" user="%s" filename="%s" token="%s"' % + (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:])) + # and we keep track of it as an open file with timestamp = Epoch, despite not having any lock yet. + # XXX this is to work around an issue with concurrent editing of newly created files (cf. iopOpen) + srv.openfiles[acctok['filename']] = ('0', set([acctok['username']])) + return 'OK', http.client.OK + except IOError as e: + utils.storeForRecovery(flask.request.get_data(), acctok['filename'], \ + flask.request.args['access_token'][-20:], e) + return IO_ERROR, http.client.INTERNAL_SERVER_ERROR + def putFile(fileid): @@ -382,73 +387,79 @@ def putFile(fileid): acctok = jwt.decode(flask.request.args['access_token'], srv.wopisecret, algorithms=['HS256']) if acctok['exp'] < time.time(): raise jwt.exceptions.ExpiredSignatureError - if 'X-WOPI-Lock' not in flask.request.headers: - # no lock given: assume we are in creation mode (cf. editnew WOPI action) - return _createNewFile(fileid, acctok) - # otherwise, check that the caller holds the current lock on the file - lock = flask.request.headers['X-WOPI-Lock'] - retrievedLock, lockHolder = utils.retrieveWopiLock(fileid, 'PUTFILE', lock, acctok) - if retrievedLock is None: - return utils.makeConflictResponse('PUTFILE', retrievedLock, lock, '', acctok['filename'], \ - 'Cannot overwrite unlocked file') - if not utils.compareWopiLocks(retrievedLock, lock): - return utils.makeConflictResponse('PUTFILE', retrievedLock, lock, '', acctok['filename'], \ - 'Cannot overwrite file locked by %s' % \ - (lockHolder if lockHolder != 'wopi' else 'another application')) - # OK, we can save the file now - log.info('msg="PutFile" user="%s" filename="%s" fileid="%s" action="edit" token="%s"' % - (acctok['userid'][-20:], acctok['filename'], fileid, flask.request.args['access_token'][-20:])) - try: - # check now the destination file against conflicts - savetime = st.getxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], utils.LASTSAVETIMEKEY) - mtime = None - mtime = st.stat(acctok['endpoint'], acctok['filename'], acctok['userid'])['mtime'] - if savetime is None or not savetime.isdigit() or int(mtime) > int(savetime): - # no xattr was there or we got our xattr but mtime is more recent: someone may have updated the file - # from a different source (e.g. FUSE or SMB mount), therefore force conflict. - # Note we can't get a time resolution better than one second! - log.info('msg="Forcing conflict based on lastWopiSaveTime" user="%s" filename="%s" ' \ - 'savetime="%s" lastmtime="%s" token="%s"' % - (acctok['userid'][-20:], acctok['filename'], savetime, mtime, flask.request.args['access_token'][-20:])) - raise IOError - log.debug('msg="Got lastWopiSaveTime" user="%s" filename="%s" savetime="%s" lastmtime="%s" token="%s"' % - (acctok['userid'][-20:], acctok['filename'], savetime, mtime, flask.request.args['access_token'][-20:])) - - except IOError: - # either the file was deleted or it was updated/overwritten by others: force conflict - newname, ext = os.path.splitext(acctok['filename']) - # !!! typical EFSS formats are like '_conflict--