From 82eeb95a3c6dc7b1a816837ea1a83328ac1bf304 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 11 Oct 2022 20:15:52 +0200 Subject: [PATCH 1/3] Implemented configurable host URLs --- src/core/commoniface.py | 2 +- src/core/wopi.py | 32 +++++++++++++++++++++++++------- src/core/wopiutils.py | 6 ++++++ wopiserver.conf | 14 ++++++++++++-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index 4ec996dc..b6857218 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -81,7 +81,7 @@ def retrieverevalock(rawlock): def encodeinode(endpoint, inode): '''Encodes a given endpoint and inode to be used as a safe WOPISrc: endpoint is assumed to already be URL safe''' - return endpoint + '-' + urlsafe_b64encode(inode.encode()).decode() + return endpoint + '!' + urlsafe_b64encode(inode.encode()).decode() def validatelock(filepath, appname, oldlock, oldvalue, op, log): diff --git a/src/core/wopi.py b/src/core/wopi.py index 4f2a801e..41c10506 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -38,8 +38,19 @@ def checkFileInfo(fileid, acctok): fmd['BaseFileName'] = fmd['BreadcrumbDocName'] = os.path.basename(acctok['filename']) wopiSrc = 'WOPISrc=%s&access_token=%s' % (utils.generateWopiSrc(fileid, acctok['appname'] == srv.proxiedappname), flask.request.args['access_token']) - 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) + hosteurl = srv.config.get('general', 'hostediturl', fallback=None) + if hosteurl: + fmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, acctok, fileid) + else: + fmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', wopiSrc) + hostvurl = srv.config.get('general', 'hostviewurl', fallback=None) + if hostvurl: + fmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, acctok, fileid) + else: + fmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', wopiSrc) + fsurl = srv.config.get('general', 'filesharingurl', fallback=None) + if fsurl: + fmd['FileSharingUrl'] = utils.generateUrlFromTemplate(fsurl, acctok, fileid) furl = acctok['folderurl'] fmd['BreadcrumbFolderUrl'] = furl if furl != '/' else srv.wopiurl # the WOPI URL is a placeholder if acctok['username'] == '': @@ -62,9 +73,6 @@ def checkFileInfo(fileid, acctok): (srv.config.get('general', 'downloadurl'), flask.request.args['access_token']) fmd['BreadcrumbBrandName'] = srv.config.get('general', 'brandingname', fallback=None) fmd['BreadcrumbBrandUrl'] = srv.config.get('general', 'brandingurl', fallback=None) - fsurl = srv.config.get('general', 'filesharingurl', fallback=None) - if fsurl: - fmd['FileSharingUrl'] = fsurl.replace('', url_quote(acctok['filename'])).replace('', fileid) fmd['OwnerId'] = statInfo['ownerid'] fmd['UserId'] = acctok['wopiuser'] # typically same as OwnerId; different when accessing shared documents fmd['Size'] = statInfo['size'] @@ -416,8 +424,18 @@ def putRelative(fileid, reqheaders, acctok): putrelmd['Name'] = os.path.basename(targetName) newwopisrc = '%s&access_token=%s' % (utils.generateWopiSrc(inode, acctok['appname'] == srv.proxiedappname), newacctok) putrelmd['Url'] = url_unquote(newwopisrc).replace('&access_token', '?access_token') - putrelmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', newwopisrc) - putrelmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appediturl'] else '?', newwopisrc) + hosteurl = srv.config.get('general', 'hostediturl', fallback=None) + if hosteurl: + putrelmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, + {'appname': acctok['appname'], 'filename': targetName}, inode) + else: + putrelmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', newwopisrc) + hostvurl = srv.config.get('general', 'hostviewurl', fallback=None) + if hostvurl: + putrelmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, + {'appname': acctok['appname'], 'filename': targetName}, inode) + else: + putrelmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', newwopisrc) resp = flask.Response(json.dumps(putrelmd), mimetype='application/json') putrelmd['Url'] = putrelmd['HostEditUrl'] = putrelmd['HostViewUrl'] = '_redacted_' log.info('msg="PutRelative response" token="%s" metadata="%s"' % (newacctok[-20:], putrelmd)) diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 06d3f8f6..1991af32 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -146,6 +146,12 @@ def generateWopiSrc(fileid, proxy=False): return url_quote_plus('%s/wopi/files/%s' % (srv.wopiproxy, fileid)).replace('-', '%2D') +def generateUrlFromTemplate(url, acctok, fileid): + '''One-liner to parse an URL template and return it with actualised placeholders''' + return url.replace('', url_quote_plus(acctok['filename'])). \ + replace('', fileid).replace('', acctok['appname']) + + def getLibreOfficeLockName(filename): '''Returns the filename of a LibreOffice-compatible lock file. This enables interoperability between Online and Desktop applications''' diff --git a/wopiserver.conf b/wopiserver.conf index 26b51f82..b1acd908 100644 --- a/wopiserver.conf +++ b/wopiserver.conf @@ -44,9 +44,19 @@ loghandler = file # Optional URL to display a file sharing dialog. This enables # a 'Share' button within the application. The URL may contain -# either the `` or `` placeholders, which are +# the ``, ``, and `` placeholders, which are # dynamically replaced with actual values for the opened file. -#filesharingurl = https://your-efss-server.org/fileshare?filepath=&resource= +#filesharingurl = https://your-efss-server.org/fileshare?filepath=&app=&resource= + +# URLs for the pages that embed the application in edit mode and +# preview mode. By default, the appediturl and appviewurl are used, +# but it is recommended to configure here a URL that displays apps +# within an iframe on your EFSS. +# Placeholders ``, ``, and `` are dynamically +# replaced similarly to the above. The suggested example reflects +# the ownCloud web implementation. +#hostediturl = https://your-efss-server.org/external/spaces?app=&fileId= +#hostviewurl = https://your-efss-server.org/external/spaces?app=&fileId=&viewmode=VIEW_MODE_PREVIEW # Optional URL prefix for WebDAV access to the files. This enables # a 'Edit in Desktop client' action on Windows-based clients From f0e573204ea23fc07981012e4cbd5ba0af66d2c8 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 11 Oct 2022 20:45:43 +0200 Subject: [PATCH 2/3] Fixed fileid on host URLs in case the wopiproxy is configured This implied adding the original fileid to the WOPI access token, and altering the internal format of inodes to be compatible with the web frontend. --- src/core/commoniface.py | 9 ++++++++- src/core/wopi.py | 28 +++++++++++++++++----------- src/core/wopiutils.py | 16 ++++++++-------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index b6857218..9a4a7ce0 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -80,10 +80,17 @@ def retrieverevalock(rawlock): def encodeinode(endpoint, inode): - '''Encodes a given endpoint and inode to be used as a safe WOPISrc: endpoint is assumed to already be URL safe''' + '''Encodes a given endpoint and inode to be used as a safe WOPISrc: endpoint is assumed to already be URL safe. + Note that the separator is chosen to be `!` for compatibility with the ownCloud Web frontend.''' return endpoint + '!' + urlsafe_b64encode(inode.encode()).decode() +def decodeinode(inode): + '''Decodes an inode obtained from encodeinode()''' + e, f = inode.split('!') + return e, urlsafe_b64decode(f.encode()).decode() + + def validatelock(filepath, appname, oldlock, oldvalue, op, log): '''Common logic for validating locks in the xrootd and local storage interfaces. Duplicates some logic implemented in Reva for the cs3 storage interface''' diff --git a/src/core/wopi.py b/src/core/wopi.py index 41c10506..12299a23 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -13,7 +13,6 @@ import http.client from datetime import datetime from urllib.parse import unquote_plus as url_unquote -from urllib.parse import quote_plus as url_quote from more_itertools import peekable import flask import core.wopiutils as utils @@ -40,17 +39,17 @@ def checkFileInfo(fileid, acctok): flask.request.args['access_token']) hosteurl = srv.config.get('general', 'hostediturl', fallback=None) if hosteurl: - fmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, acctok, fileid) + fmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, acctok) else: fmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', wopiSrc) hostvurl = srv.config.get('general', 'hostviewurl', fallback=None) if hostvurl: - fmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, acctok, fileid) + fmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, acctok) else: fmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', wopiSrc) fsurl = srv.config.get('general', 'filesharingurl', fallback=None) if fsurl: - fmd['FileSharingUrl'] = utils.generateUrlFromTemplate(fsurl, acctok, fileid) + fmd['FileSharingUrl'] = utils.generateUrlFromTemplate(fsurl, acctok) furl = acctok['folderurl'] fmd['BreadcrumbFolderUrl'] = furl if furl != '/' else srv.wopiurl # the WOPI URL is a placeholder if acctok['username'] == '': @@ -407,6 +406,8 @@ def putRelative(fileid, reqheaders, acctok): # either way, we now have a targetName to save the file: attempt to do so try: utils.storeWopiFile(acctok, None, utils.LASTSAVETIMEKEY, targetName) + newstat = st.statx(acctok['endpoint'], targetName, acctok['userid']) + _, newfileid = common.decodeinode(newstat['inode']) except IOError as e: utils.storeForRecovery(flask.request.get_data(), acctok['username'], targetName, flask.request.args['access_token'][-20:], e) @@ -420,20 +421,25 @@ def putRelative(fileid, reqheaders, acctok): acctok['folderurl'], acctok['endpoint'], (acctok['appname'], acctok['appediturl'], acctok['appviewurl'])) # prepare and send the response as JSON - putrelmd = {} - putrelmd['Name'] = os.path.basename(targetName) + mdforhosturls = { + 'appname': acctok['appname'], + 'filename': targetName, + 'endpoint': acctok['endpoint'], + 'fileid': newfileid, + } newwopisrc = '%s&access_token=%s' % (utils.generateWopiSrc(inode, acctok['appname'] == srv.proxiedappname), newacctok) - putrelmd['Url'] = url_unquote(newwopisrc).replace('&access_token', '?access_token') + putrelmd = { + 'Name': os.path.basename(targetName), + 'Url': url_unquote(newwopisrc).replace('&access_token', '?access_token'), + } hosteurl = srv.config.get('general', 'hostediturl', fallback=None) if hosteurl: - putrelmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, - {'appname': acctok['appname'], 'filename': targetName}, inode) + putrelmd['HostEditUrl'] = utils.generateUrlFromTemplate(hosteurl, mdforhosturls) else: putrelmd['HostEditUrl'] = '%s%s%s' % (acctok['appediturl'], '&' if '?' in acctok['appediturl'] else '?', newwopisrc) hostvurl = srv.config.get('general', 'hostviewurl', fallback=None) if hostvurl: - putrelmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, - {'appname': acctok['appname'], 'filename': targetName}, inode) + putrelmd['HostViewUrl'] = utils.generateUrlFromTemplate(hostvurl, mdforhosturls) else: putrelmd['HostViewUrl'] = '%s%s%s' % (acctok['appviewurl'], '&' if '?' in acctok['appviewurl'] else '?', newwopisrc) resp = flask.Response(json.dumps(putrelmd), mimetype='application/json') diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 1991af32..46bed2be 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -146,10 +146,11 @@ def generateWopiSrc(fileid, proxy=False): return url_quote_plus('%s/wopi/files/%s' % (srv.wopiproxy, fileid)).replace('-', '%2D') -def generateUrlFromTemplate(url, acctok, fileid): - '''One-liner to parse an URL template and return it with actualised placeholders''' +def generateUrlFromTemplate(url, acctok): + '''One-liner to parse an URL template and return it with actualised placeholders. See also common.encodeinode()''' return url.replace('', url_quote_plus(acctok['filename'])). \ - replace('', fileid).replace('', acctok['appname']) + replace('', acctok['endpoint'] + '!' + acctok['fileid']). \ + replace('', acctok['appname']) def getLibreOfficeLockName(filename): @@ -182,8 +183,8 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app log.debug('msg="Generating token" userid="%s" fileid="%s" endpoint="%s" app="%s"' % (userid[-20:], fileid, endpoint, appname)) try: - # stat the file to check for existence and get a version-invariant inode and modification time: - # the inode serves as fileid (and must not change across save operations), the mtime is used for version information. + # stat the file to check for existence and get a version-invariant inode: + # the inode serves as fileid (and must not change across save operations) statinfo = st.statx(endpoint, fileid, userid) except IOError as e: log.info('msg="Requested file not found or not a file" fileid="%s" error="%s"' % (fileid, e)) @@ -208,8 +209,8 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app # does not set appname when the app is not proxied, so we optimistically assume it's Collabora and let it go) log.info('msg="Forcing read-only access to ODF file" filename="%s"' % statinfo['filepath']) viewmode = ViewMode.READ_ONLY - acctok = jwt.encode({'userid': userid, 'wopiuser': wopiuser, 'filename': statinfo['filepath'], 'username': username, - 'viewmode': viewmode.value, 'folderurl': folderurl, 'endpoint': endpoint, + acctok = jwt.encode({'userid': userid, 'wopiuser': wopiuser, 'filename': statinfo['filepath'], 'fileid': fileid, + 'username': username, 'viewmode': viewmode.value, 'folderurl': folderurl, 'endpoint': endpoint, 'appname': appname, 'appediturl': appediturl, 'appviewurl': appviewurl, 'exp': exptime, 'iss': 'cs3org:wopiserver:%s' % WOPIVER}, # standard claims srv.wopisecret, algorithm='HS256') @@ -218,7 +219,6 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app (userid[-20:], wopiuser if wopiuser != userid else username, viewmode, endpoint, statinfo['filepath'], statinfo['inode'], statinfo['mtime'], folderurl, appname, exptime, acctok[-20:])) - # return the inode == fileid, the filepath and the access token return statinfo['inode'], acctok, viewmode From f178fc823a86cb14acda9ddb4777f0809faf0456 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 12 Oct 2022 11:32:04 +0200 Subject: [PATCH 3/3] Decoupled the fileId separator from the web frontend implementation and made it part of the configuration --- src/core/commoniface.py | 3 ++- src/core/wopiutils.py | 5 +++-- wopiserver.conf | 17 +++++++++-------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/core/commoniface.py b/src/core/commoniface.py index 9a4a7ce0..6eeffaea 100644 --- a/src/core/commoniface.py +++ b/src/core/commoniface.py @@ -81,7 +81,8 @@ def retrieverevalock(rawlock): def encodeinode(endpoint, inode): '''Encodes a given endpoint and inode to be used as a safe WOPISrc: endpoint is assumed to already be URL safe. - Note that the separator is chosen to be `!` for compatibility with the ownCloud Web frontend.''' + Note that the separator is chosen to be `!` (similar to how the web frontend is implemented) to allow the inverse + operation, assuming that `endpoint` does not contain any `!` characters.''' return endpoint + '!' + urlsafe_b64encode(inode.encode()).decode() diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 46bed2be..897832f7 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -147,9 +147,10 @@ def generateWopiSrc(fileid, proxy=False): def generateUrlFromTemplate(url, acctok): - '''One-liner to parse an URL template and return it with actualised placeholders. See also common.encodeinode()''' + '''One-liner to parse an URL template and return it with actualised placeholders. See also common.encodeinode().''' return url.replace('', url_quote_plus(acctok['filename'])). \ - replace('', acctok['endpoint'] + '!' + acctok['fileid']). \ + replace('', acctok['endpoint']). \ + replace('', acctok['fileid']). \ replace('', acctok['appname']) diff --git a/wopiserver.conf b/wopiserver.conf index b1acd908..ce93254b 100644 --- a/wopiserver.conf +++ b/wopiserver.conf @@ -44,19 +44,20 @@ loghandler = file # Optional URL to display a file sharing dialog. This enables # a 'Share' button within the application. The URL may contain -# the ``, ``, and `` placeholders, which are -# dynamically replaced with actual values for the opened file. -#filesharingurl = https://your-efss-server.org/fileshare?filepath=&app=&resource= +# any of the ``, ``, ``, and `` +# placeholders, which are dynamically replaced with actual values +# for the opened file. +#filesharingurl = https://your-efss-server.org/fileshare?filepath=&app=&fileId=! # URLs for the pages that embed the application in edit mode and # preview mode. By default, the appediturl and appviewurl are used, # but it is recommended to configure here a URL that displays apps # within an iframe on your EFSS. -# Placeholders ``, ``, and `` are dynamically -# replaced similarly to the above. The suggested example reflects -# the ownCloud web implementation. -#hostediturl = https://your-efss-server.org/external/spaces?app=&fileId= -#hostviewurl = https://your-efss-server.org/external/spaces?app=&fileId=&viewmode=VIEW_MODE_PREVIEW +# Placeholders ``, ``, ``, and `` are +# dynamically replaced similarly to the above. The suggested example +# reflects the ownCloud web implementation. +#hostediturl = https://your-efss-server.org/external/spaces?app=&fileId=! +#hostviewurl = https://your-efss-server.org/external/spaces?app=&fileId=!&viewmode=VIEW_MODE_PREVIEW # Optional URL prefix for WebDAV access to the files. This enables # a 'Edit in Desktop client' action on Windows-based clients