diff --git a/src/core/commoniface.py b/src/core/commoniface.py new file mode 100644 index 00000000..d203b330 --- /dev/null +++ b/src/core/commoniface.py @@ -0,0 +1,26 @@ +''' +commoniface.py + +common entities used by all storage interfaces for the IOP WOPI server + +Author: Giuseppe.LoPresti@cern.ch, CERN/IT-ST +''' + +import time +import json + +# standard file missing message +ENOENT_MSG = 'No such file or directory' + +# standard error thrown when attempting to overwrite a file/xattr in O_EXCL mode +EXCL_ERROR = 'File exists and islock flag requested' + +# standard error thrown when attempting an operation without the required access rights +ACCESS_ERROR = 'Operation not permitted' + +# name of the xattr storing the Reva lock +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}) diff --git a/src/core/cs3iface.py b/src/core/cs3iface.py index 072b2811..84da2f36 100644 --- a/src/core/cs3iface.py +++ b/src/core/cs3iface.py @@ -21,7 +21,7 @@ import cs3.rpc.v1beta1.code_pb2 as cs3code import cs3.types.v1beta1.types_pb2 as types -ENOENT_MSG = 'No such file or directory' +import core.commoniface as common # module-wide state ctx = {} # "map" to store some module context: cf. init() @@ -94,7 +94,7 @@ def stat(endpoint, fileid, userid, versioninv=1): 'mtime': statInfo.info.mtime.seconds } ctx['log'].info('msg="Failed stat" inode="%s" reason="%s"' % (fileid, statInfo.status.message.replace('"', "'"))) - raise IOError(ENOENT_MSG if statInfo.status.code == cs3code.CODE_NOT_FOUND else statInfo.status.message) + raise IOError(common.ENOENT_MSG if statInfo.status.code == cs3code.CODE_NOT_FOUND else statInfo.status.message) def statx(endpoint, fileid, userid, versioninv=0): @@ -148,6 +148,26 @@ def rmxattr(_endpoint, filepath, userid, key): ctx['log'].debug('msg="Invoked rmxattr" result="%s"' % res) +def setlock(endpoint, filepath, userid, appname, value): + '''Set a lock to filepath with the given value metadata and appname as holder''' + raise NotImplementedError + + +def getlock(endpoint, filepath, userid, appname): + '''Get the lock metadata for the given filepath''' + raise NotImplementedError + + +def refreshlock(endpoint, filepath, userid, appname, value): + '''Refresh the lock metadata for the given filepath''' + raise NotImplementedError + + +def unlock(endpoint, filepath, userid, appname): + '''Remove the lock for the given filepath''' + raise NotImplementedError + + def readfile(_endpoint, filepath, userid): '''Read a file using the given userid as access token. Note that the function is a generator, managed by Flask.''' tstart = time.time() @@ -156,7 +176,7 @@ def readfile(_endpoint, filepath, userid): 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) - yield IOError(ENOENT_MSG) + 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"' % (filepath, initfiledownloadres.status.message.replace('"', "'"))) @@ -239,14 +259,14 @@ def renamefile(_endpoint, filepath, newfilepath, userid): ctx['log'].debug('msg="Invoked renamefile" result="%s"' % res) -def removefile(_endpoint, filepath, userid, _force=0): +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) req = cs3sp.DeleteRequest(ref=reference) res = ctx['cs3stub'].Delete(request=req, metadata=[('x-access-token', userid)]) if res.status.code != cs3code.CODE_OK: - if str(res) == ENOENT_MSG: + if str(res) == common.ENOENT_MSG: ctx['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('"', "'"))) diff --git a/src/core/ioplocks.py b/src/core/ioplocks.py index cd7e33cb..05cd3110 100644 --- a/src/core/ioplocks.py +++ b/src/core/ioplocks.py @@ -7,6 +7,7 @@ import time import http import core.wopiutils as utils +import core.commoniface as common # convenience references to global entities st = None @@ -133,7 +134,7 @@ def createLock(filestat, filename, userid, endpoint): (filename, filestat['inode'], lockid)) return str(lockid), http.client.OK except IOError as e: - if 'File exists and islock flag requested' not in str(e): + if common.EXCL_ERROR not in str(e): # writing failed log.error('msg="cboxLock: unable to store LibreOffice-compatible lock file" filename="%s" reason="%s"' % (filename, e)) diff --git a/src/core/localiface.py b/src/core/localiface.py index 747f9c96..1399ba90 100644 --- a/src/core/localiface.py +++ b/src/core/localiface.py @@ -10,6 +10,8 @@ import os import warnings from stat import S_ISDIR +import json +import core.commoniface as common # module-wide state config = None @@ -101,6 +103,38 @@ def rmxattr(_endpoint, filepath, _userid, key): raise IOError(e) +def setlock(endpoint, filepath, _userid, appname, value): + '''Set the lock as an xattr on behalf of the given userid''' + 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)) + else: + raise IOError(common.EXCL_ERROR) + + +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 None + +def refreshlock(endpoint, filepath, _userid, appname, value): + '''Refresh the lock value as an xattr on behalf of the given userid''' + 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']) + # 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): + '''Remove the lock as an xattr on behalf of the given userid''' + rmxattr(endpoint, filepath, '0:0', common.LOCKKEY) + + def readfile(_endpoint, filepath, _userid): '''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) @@ -148,7 +182,7 @@ def writefile(_endpoint, filepath, _userid, content, islock=False): # as f goes out of scope here, we'd get a false ResourceWarning, which is ignored by the above filter except FileExistsError: log.info('msg="File exists on write but islock flag requested" filepath="%s"' % filepath) - raise IOError('File exists and islock flag requested') + raise IOError(common.EXCL_ERROR) except OSError as e: log.warning('msg="Error writing file in O_EXCL mode" filepath="%s" error="%s"' % (filepath, e)) raise IOError(e) @@ -174,7 +208,7 @@ def renamefile(_endpoint, origfilepath, newfilepath, _userid): raise IOError(e) -def removefile(_endpoint, filepath, _userid, _force=0): +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.''' try: diff --git a/src/core/wopi.py b/src/core/wopi.py index 258a4b8f..6fb65fa6 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -11,12 +11,13 @@ import json import http.client from urllib.parse import quote_plus as url_quote_plus -from urllib.parse import unquote as url_unquote from more_itertools import peekable import jwt import flask import core.wopiutils as utils +import core.commoniface as common +IO_ERROR = 'I/O Error' # convenience references to global entities st = None @@ -39,54 +40,57 @@ def checkFileInfo(fileid): # compute some entities for the response wopiSrc = 'WOPISrc=%s&access_token=%s' % (utils.generateWopiSrc(fileid), flask.request.args['access_token']) # populate metadata for this file - filemd = {} - filemd['BaseFileName'] = filemd['BreadcrumbDocName'] = os.path.basename(acctok['filename']) + fmd = {} + fmd['BaseFileName'] = fmd['BreadcrumbDocName'] = os.path.basename(acctok['filename']) furl = acctok['folderurl'] # encode the path part as it is going to be an URL GET argument - filemd['BreadcrumbFolderUrl'] = furl[:furl.find('=')+1] + url_quote_plus(furl[furl.find('=')+1:]) if furl != '/' else '' + fmd['BreadcrumbFolderUrl'] = furl[:furl.find('=')+1] + url_quote_plus(furl[furl.find('=')+1:]) if furl != '/' else '' if acctok['username'] == '': - filemd['UserFriendlyName'] = 'Guest ' + utils.randomString(3) + fmd['UserFriendlyName'] = 'Guest ' + utils.randomString(3) if '?path' in furl and furl[-1] != '/' and furl[-1] != '=': # this is a subfolder of a public share, show it - filemd['BreadcrumbFolderName'] = 'Back to ' + furl[furl.find('?path'):].split('/')[-1] + fmd['BreadcrumbFolderName'] = 'Back to ' + furl[furl.find('?path'):].split('/')[-1] else: # this is the top level public share, which is anonymous - filemd['BreadcrumbFolderName'] = 'Back to the public share' + fmd['BreadcrumbFolderName'] = 'Back to the public share' else: - filemd['UserFriendlyName'] = acctok['username'] - filemd['BreadcrumbFolderName'] = 'Back to ' + os.path.dirname(acctok['filename']) + fmd['UserFriendlyName'] = acctok['username'] + fmd['BreadcrumbFolderName'] = 'Back to ' + os.path.dirname(acctok['filename']) if furl == '/': # if no target folder URL was given, override the above and completely hide it - filemd['BreadcrumbFolderName'] = '' + fmd['BreadcrumbFolderName'] = '' if acctok['viewmode'] in (utils.ViewMode.READ_ONLY, utils.ViewMode.READ_WRITE): - filemd['DownloadUrl'] = '%s?access_token=%s' % \ + fmd['DownloadUrl'] = '%s?access_token=%s' % \ (srv.config.get('general', 'downloadurl'), flask.request.args['access_token']) - filemd['OwnerId'] = statInfo['ownerid'] - filemd['UserId'] = acctok['wopiuser'] # typically same as OwnerId; different when accessing shared documents - filemd['Size'] = statInfo['size'] + fmd['OwnerId'] = statInfo['ownerid'] + fmd['UserId'] = acctok['wopiuser'] # typically same as OwnerId; different when accessing shared documents + fmd['Size'] = statInfo['size'] # TODO the version is generated like this in ownCloud: 'V' . $file->getEtag() . \md5($file->getChecksum()); - filemd['Version'] = statInfo['mtime'] # mtime is used as version here - filemd['SupportsExtendedLockLength'] = filemd['SupportsGetLock'] = True - filemd['SupportsUpdate'] = filemd['UserCanWrite'] = filemd['SupportsLocks'] = \ - filemd['SupportsDeleteFile'] = acctok['viewmode'] == utils.ViewMode.READ_WRITE - filemd['UserCanNotWriteRelative'] = acctok['viewmode'] != utils.ViewMode.READ_WRITE - filemd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', wopiSrc) - filemd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', wopiSrc) - filemd['SupportsRename'] = filemd['UserCanRename'] = enablerename and utils.ViewMode.READ_WRITE + fmd['Version'] = statInfo['mtime'] # mtime is used as version here + fmd['SupportsExtendedLockLength'] = fmd['SupportsGetLock'] = True + fmd['SupportsUpdate'] = fmd['UserCanWrite'] = fmd['SupportsLocks'] = \ + fmd['SupportsDeleteFile'] = acctok['viewmode'] == utils.ViewMode.READ_WRITE + fmd['UserCanNotWriteRelative'] = acctok['viewmode'] != utils.ViewMode.READ_WRITE + fmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', wopiSrc) + fmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', wopiSrc) + fmd['SupportsRename'] = fmd['UserCanRename'] = enablerename and utils.ViewMode.READ_WRITE # populate app-specific metadata if acctok['appname'].find('Microsoft') > 0: # the following is to enable the 'Edit in Word/Excel/PowerPoint' (desktop) action (probably broken) try: - filemd['ClientUrl'] = srv.config.get('general', 'webdavurl') + '/' + acctok['filename'] + fmd['ClientUrl'] = srv.config.get('general', 'webdavurl') + '/' + acctok['filename'] except configparser.NoOptionError: # if no WebDAV URL is provided, ignore this setting pass # extensions for Collabora Online - filemd['EnableOwnerTermination'] = True - filemd['DisableExport'] = filemd['DisableCopy'] = filemd['DisablePrint'] = acctok['viewmode'] == utils.ViewMode.VIEW_ONLY - #filemd['LastModifiedTime'] = datetime.fromtimestamp(int(statInfo['mtime'])).isoformat() # this currently breaks + fmd['EnableOwnerTermination'] = True + fmd['DisableExport'] = fmd['DisableCopy'] = fmd['DisablePrint'] = acctok['viewmode'] == utils.ViewMode.VIEW_ONLY + #fmd['LastModifiedTime'] = datetime.fromtimestamp(int(statInfo['mtime'])).isoformat() # this currently breaks - log.info('msg="File metadata response" token="%s" metadata="%s"' % (flask.request.args['access_token'][-20:], filemd)) - return flask.Response(json.dumps(filemd), mimetype='application/json') + res = flask.Response(json.dumps(fmd), mimetype='application/json') + # amend sensitive metadata for the logs + fmd['HostViewUrl'] = fmd['HostEditUrl'] = fmd['DownloadUrl'] = '_amended_' + log.info('msg="File metadata response" token="%s" metadata="%s"' % (flask.request.args['access_token'][-20:], fmd)) + return res except IOError as e: log.info('msg="Requested file not found" filename="%s" token="%s" error="%s"' % (acctok['filename'], flask.request.args['access_token'][-20:], e)) @@ -130,40 +134,6 @@ def getFile(fileid): # # The following operations are all called on POST /wopi/files/ # -def unlock(fileid, reqheaders, acctok, force=False): - '''Implements the Unlock WOPI call''' - lock = reqheaders['X-WOPI-Lock'] - retrievedLock = utils.retrieveWopiLock(fileid, 'UNLOCK', lock, acctok) - if not force and not utils.compareWopiLocks(retrievedLock, lock): - return utils.makeConflictResponse('UNLOCK', retrievedLock, lock, '', acctok['filename']) - # OK, the lock matches. Remove any extended attribute related to locks and conflicts handling - try: - st.removefile(acctok['endpoint'], utils.getLockName(acctok['filename']), acctok['userid'], 1) - except IOError: - # ignore, it's not worth to report anything here - pass - try: - st.rmxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], utils.LASTSAVETIMEKEY) - except IOError: - # same as above - pass - 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'], 1) - except IOError: - # same as above - pass - # and update our internal list of opened files - if not force: - try: - del srv.openfiles[acctok['filename']] - except KeyError: - # already removed? - pass - return 'OK', http.client.OK - - def setLock(fileid, reqheaders, acctok): '''Implements the Lock, RefreshLock, and UnlockAndRelock WOPI calls''' # cf. http://wopi.readthedocs.io/projects/wopirest/en/latest/files/Lock.html @@ -171,10 +141,10 @@ def setLock(fileid, reqheaders, acctok): lock = reqheaders['X-WOPI-Lock'] oldLock = reqheaders.get('X-WOPI-OldLock') validateTarget = reqheaders.get('X-WOPI-Validate-Target') - retrievedLock = utils.retrieveWopiLock(fileid, op, lock, acctok) + retrievedLock, _ = utils.retrieveWopiLock(fileid, op, lock, acctok) # perform the required checks for the validity of the new lock - if not retrievedLock and op == 'REFRESH_LOCK': + if op == 'REFRESH_LOCK' and not retrievedLock: if validateTarget: # this is an extension of the API: a REFRESH_LOCK without previous lock but with a Validate-Target header # is allowed provided that the target file was last saved by WOPI and not overwritten by external actions, @@ -185,47 +155,23 @@ def setLock(fileid, reqheaders, acctok): if not savetime or not savetime.isdigit(): return utils.makeConflictResponse(op, '', lock, oldLock, acctok['filename'], 'The file was not locked' + ' and got modified' if validateTarget else '') - if retrievedLock and not utils.compareWopiLocks(retrievedLock, (oldLock if oldLock else lock)): - return utils.makeConflictResponse(op, retrievedLock, lock, oldLock, acctok['filename'], \ - 'The file was locked by another online editor') - # LOCK or REFRESH_LOCK: set the lock to the given one, including the expiration time + # LOCK or REFRESH_LOCK: atomically set the lock to the given one, including the expiration time, + # and return conflict response if the file was already locked try: - utils.storeWopiLock(op, lock, acctok, os.path.splitext(acctok['filename'])[1] not in srv.nonofficetypes) + return utils.storeWopiLock(fileid, op, lock, oldLock, acctok, \ + os.path.splitext(acctok['filename'])[1] not in srv.nonofficetypes) except IOError as e: - if utils.EXCL_ERROR in str(e): - # this file was already locked externally: storeWopiLock looks at LibreOffice-compatible locks - return utils.makeConflictResponse(op, 'External App', lock, oldLock, acctok['filename'], \ - 'The file was locked by another application') - if 'No such file or directory' in str(e): - # the file got renamed/deleted: this is equivalent to a conflict - return utils.makeConflictResponse(op, 'External App', lock, oldLock, acctok['filename'], \ - 'The file got moved or deleted') - # any other failure - return str(e), http.client.INTERNAL_SERVER_ERROR - if not retrievedLock: - # on first lock, set an xattr with the current time for later conflicts checking - try: - st.setxattr(acctok['endpoint'], acctok['filename'], acctok['userid'], utils.LASTSAVETIMEKEY, int(time.time())) - 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"' % - (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:], e)) - # also, keep track of files that have been opened for write: this is for statistical purposes only - # (cf. the GetLock WOPI call and the /wopi/cbox/open/list action) - if acctok['filename'] not in srv.openfiles: - srv.openfiles[acctok['filename']] = (time.asctime(), set([acctok['username']])) - else: - # the file was already opened but without lock: this happens on new files (cf. editnew action), just log - log.info('msg="First lock for new file" user="%s" filename="%s" token="%s"' % - (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:])) - return 'OK', http.client.OK + # expected failures are handled in storeWopiLock + log.error('msg="%s: unable to store WOPI lock" filename="%s" token="%s" lock="%s" reason="%s"' % + (op.title(), acctok['filename'], flask.request.args['access_token'][-20:], lock, e)) + return IO_ERROR, http.client.INTERNAL_SERVER_ERROR def getLock(fileid, _reqheaders_unused, acctok): '''Implements the GetLock WOPI call''' resp = flask.Response() - lock = utils.retrieveWopiLock(fileid, 'GETLOCK', '', acctok) + lock, _ = utils.retrieveWopiLock(fileid, 'GETLOCK', '', acctok) resp.status_code = http.client.OK if lock else http.client.NOT_FOUND if lock: resp.headers['X-WOPI-Lock'] = lock @@ -257,6 +203,34 @@ def getLock(fileid, _reqheaders_unused, acctok): return resp +def unlock(fileid, reqheaders, acctok): + '''Implements the Unlock WOPI call''' + lock = reqheaders['X-WOPI-Lock'] + retrievedLock, _ = utils.retrieveWopiLock(fileid, 'UNLOCK', lock, acctok) + if not utils.compareWopiLocks(retrievedLock, lock): + 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']) + except IOError: + # ignore, it's not worth to report anything here + pass + 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) + except IOError: + # same as above + pass + # and update our internal list of opened files + try: + del srv.openfiles[acctok['filename']] + except KeyError: + # already removed? + pass + return 'OK', http.client.OK + + def putRelative(fileid, reqheaders, acctok): '''Implements the PutRelative WOPI call. Corresponds to the 'Save as...' menu entry.''' # cf. http://wopi.readthedocs.io/projects/wopirest/en/latest/files/PutRelativeFile.html @@ -298,7 +272,7 @@ def putRelative(fileid, reqheaders, acctok): try: # check for file existence + lock fileExists = st.stat(acctok['endpoint'], relTarget, acctok['userid']) - retrievedTargetLock = utils.retrieveWopiLock(fileid, 'PUT_RELATIVE', None, acctok, overridefilename=relTarget) + retrievedTargetLock, _ = utils.retrieveWopiLock(fileid, 'PUT_RELATIVE', None, acctok, overridefilename=relTarget) except IOError: fileExists = False if fileExists and (not overwriteTarget or retrievedTargetLock): @@ -308,11 +282,11 @@ def putRelative(fileid, reqheaders, acctok): targetName = relTarget # either way, we now have a targetName to save the file: attempt to do so try: - utils.storeWopiFile(flask.request, acctok, utils.LASTSAVETIMEKEY, targetName) + 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)) - return 'I/O Error', http.client.INTERNAL_SERVER_ERROR + 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" ' \ 'mode="ViewMode.READ_WRITE" friendlyname="%s"' % @@ -334,7 +308,7 @@ def putRelative(fileid, reqheaders, acctok): def deleteFile(fileid, _reqheaders_unused, acctok): '''Implements the DeleteFile WOPI call''' - retrievedLock = utils.retrieveWopiLock(fileid, 'DELETE', '', acctok) + retrievedLock, _ = utils.retrieveWopiLock(fileid, 'DELETE', '', acctok) if retrievedLock is not None: # file is locked and cannot be deleted return utils.makeConflictResponse('DELETE', retrievedLock, '', '', acctok['filename']) @@ -343,14 +317,14 @@ def deleteFile(fileid, _reqheaders_unused, acctok): return 'OK', http.client.OK except IOError as e: log.info('msg="DeleteFile" token="%s" error="%s"' % (flask.request.args['access_token'][-20:], e)) - return 'Internal error', http.client.INTERNAL_SERVER_ERROR + return IO_ERROR, http.client.INTERNAL_SERVER_ERROR def renameFile(fileid, reqheaders, acctok): '''Implements the RenameFile WOPI call.''' targetName = reqheaders['X-WOPI-RequestedName'] lock = reqheaders['X-WOPI-Lock'] if 'X-WOPI-Lock' in reqheaders else None - retrievedLock = utils.retrieveWopiLock(fileid, 'RENAMEFILE', lock, acctok) + retrievedLock, _ = utils.retrieveWopiLock(fileid, 'RENAMEFILE', lock, acctok) if retrievedLock is not None and not utils.compareWopiLocks(retrievedLock, lock): return utils.makeConflictResponse('RENAMEFILE', retrievedLock, lock, '', acctok['filename']) try: @@ -360,8 +334,6 @@ def renameFile(fileid, reqheaders, acctok): (acctok['userid'][-20:], acctok['filename'], flask.request.args['access_token'][-20:], targetName)) st.renamefile(acctok['endpoint'], acctok['filename'], targetName, acctok['userid']) # also rename the locks - st.renamefile(acctok['endpoint'], utils.getLockName(acctok['filename']), \ - utils.getLockName(targetName), acctok['userid']) if os.path.splitext(acctok['filename'])[1] not in srv.nonofficetypes: st.renamefile(acctok['endpoint'], utils.getLibreOfficeLockName(acctok['filename']), \ utils.getLibreOfficeLockName(targetName), acctok['userid']) @@ -392,7 +364,7 @@ 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, acctok, utils.LASTSAVETIMEKEY) + 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. @@ -413,13 +385,14 @@ def putFile(fileid): return _createNewFile(fileid, acctok) # otherwise, check that the caller holds the current lock on the file lock = flask.request.headers['X-WOPI-Lock'] - retrievedLock = utils.retrieveWopiLock(fileid, 'PUTFILE', lock, acctok) + 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 another application') + '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:])) @@ -444,7 +417,14 @@ def putFile(fileid): newname, ext = os.path.splitext(acctok['filename']) # !!! typical EFSS formats are like '_conflict--