From 6c8df0412ecefe38221568129bd8ff0b4af522cb Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Thu, 24 Jun 2021 18:32:43 +0200 Subject: [PATCH] Alternate model for discovery See https://github.com/cs3org/reva/issues/1779 for more details The concept is that WOPI will not be responsible for the discovery, and the functionality is implemented in the WOPI driver of the Reva AppProvider. Eventually, the discovery.py module will be dropped. --- src/bridge/__init__.py | 55 ++++++-------- src/bridge/codimd.py | 5 +- src/bridge/etherpad.py | 5 +- src/core/discovery.py | 36 +++------ src/core/readme.md | 3 +- src/core/wopi.py | 26 ++----- src/core/wopiutils.py | 17 +++-- src/wopiserver.py | 169 +++++++++++++++++++++++++++-------------- 8 files changed, 171 insertions(+), 145 deletions(-) diff --git a/src/bridge/__init__.py b/src/bridge/__init__.py index 7d2226e4..283a742f 100644 --- a/src/bridge/__init__.py +++ b/src/bridge/__init__.py @@ -26,11 +26,9 @@ CERTPATH = '/var/run/secrets/cert.pem' # path to a secret used to hash noteids and protect the /list endpoint +# TODO "merge" with main wopisecret SECRETPATH = '/var/run/secrets/wbsecret' -# path to the APIKEY secrets -APIKEYPATH = '/var/run/secrets/' - # The supported plugins integrated with this WOPI Bridge BRIDGE_EXT_PLUGINS = {'md': 'codimd', 'zmd': 'codimd', 'mds': 'codimd', 'epd': 'etherpad'} @@ -74,16 +72,19 @@ def init(cls, config, log): wopic.sslverify = cls.sslverify @classmethod - def loadplugin(cls, appname, appurl, appinturl): + def loadplugin(cls, appname, appurl, appinturl, apikey): '''Load plugin for the given appname, if supported by the bridge service''' p = appname.lower() - if p not in set(BRIDGE_EXT_PLUGINS.values()): + if p in cls.plugins: + # already initialized + return + if not issupported(appname): raise ValueError(appname) try: cls.plugins[p] = __import__('bridge.' + p, globals(), locals(), [p]) cls.plugins[p].log = cls.log cls.plugins[p].sslverify = cls.sslverify - cls.plugins[p].init(appurl, appinturl, APIKEYPATH) + cls.plugins[p].init(appurl, appinturl, apikey) cls.log.info('msg="Imported plugin for application" app="%s" plugin="%s"' % (p, cls.plugins[p])) except Exception as e: cls.log.info('msg="Disabled plugin following failed initialization" app="%s" message="%s"' % (p, e)) @@ -96,7 +97,12 @@ def loadplugin(cls, appname, appurl, appinturl): cls.savethread.start() -def _guireturn(msg): +def issupported(appname): + '''One-liner to return if a given application is supported by the bridge extensions''' + return appname.lower() in set(BRIDGE_EXT_PLUGINS.values()) + + +def guireturn(msg): '''One-liner to better render messages that may be visible in the UI''' return '
%s
' % msg @@ -107,31 +113,21 @@ def _gendocid(wopisrc): return urlsafe_b64encode(dig).decode()[:-1] - # The Bridge endpoints start here ############################################################################################################# -def appopen(): - '''Open a MD doc by contacting the provided WOPISrc with the given access_token''' - try: - wopisrc = urlparse.unquote(flask.request.args['WOPISrc']) - acctok = flask.request.args['access_token'] - WB.log.info('msg="Open called" client="%s" user-agent="%s" token="%s"' % - (flask.request.remote_addr, flask.request.user_agent, acctok[-20:])) - except KeyError as e: - WB.log.error('msg="Open: unable to open the file, missing WOPI context" error="%s"' % e) - return _guireturn('Missing arguments'), http.client.BAD_REQUEST - +def appopen(wopisrc, acctok): + '''Open a doc by contacting the provided WOPISrc with the given access_token''' # WOPI GetFileInfo res = wopic.request(wopisrc, acctok, 'GET') if res.status_code != http.client.OK: WB.log.warning('msg="Open: unable to fetch file WOPI metadata" response="%d"' % res.status_code) - return _guireturn('Invalid WOPI context'), http.client.NOT_FOUND + return guireturn('Invalid WOPI context'), http.client.NOT_FOUND filemd = res.json() app = BRIDGE_EXT_PLUGINS.get(os.path.splitext(filemd['BaseFileName'])[1][1:]) if not app or not WB.plugins[app]: WB.log.warning('msg="Open: file type not supported or missing plugin" filename="%s" token="%s"' % (filemd['FileName'], acctok[-20:])) - return _guireturn('File type not supported'), http.client.BAD_REQUEST + return guireturn('File type not supported'), http.client.BAD_REQUEST WB.log.debug('msg="Processing open for supported app" app="%s" plugin="%s"' % (app, WB.plugins[app])) app = WB.plugins[app] @@ -183,20 +179,19 @@ def appopen(): wopilock = app.loadfromstorage(filemd, wopisrc, acctok, None) except app.AppFailure: # this can be raised by loadfromstorage - return _guireturn('Unable to load the app, please try again later or contact support'), http.client.INTERNAL_SERVER_ERROR + return guireturn('Unable to load the app, please try again later or contact support'), http.client.INTERNAL_SERVER_ERROR # here we append the user browser to the displayName # TODO need to review this for production usage, it should actually come from WOPI if configured accordingly - redirecturl = app.getredirecturl( - filemd['UserCanWrite'], wopisrc, acctok, wopilock, - urlparse.quote_plus(filemd['UserFriendlyName'] + '@' + \ - (flask.request.user_agent.platform[:3] if flask.request.user_agent.platform else 'oth'))) - WB.log.info('msg="Redirecting client to the app" redirecturl="%s"' % redirecturl) - return flask.redirect(redirecturl) + redirurl = app.getredirecturl(filemd['UserCanWrite'], wopisrc, acctok, wopilock, + urlparse.quote_plus(filemd['UserFriendlyName'] + '@' + \ + (flask.request.user_agent.platform[:3] if flask.request.user_agent.platform else 'oth'))) + WB.log.info('msg="Redirecting client to the app" redirecturl="%s"' % redirurl) + return flask.redirect(redirurl) def appsave(docid): - '''Save a MD doc given its WOPI context, and return a JSON-formatted message. The actual save is asynchronous.''' + '''Save a doc given its WOPI context, and return a JSON-formatted message. The actual save is asynchronous.''' # fetch metadata from request try: meta = urlparse.unquote(flask.request.headers['X-EFSS-Metadata']) @@ -250,7 +245,7 @@ def applist(): (flask.request.args.get('apikey') != WB.hashsecret): # added for convenience WB.log.warning('msg="List: unauthorized access attempt, missing authorization token" ' 'client="%s"' % flask.request.remote_addr) - return _guireturn('Client not authorized'), http.client.UNAUTHORIZED + return guireturn('Client not authorized'), http.client.UNAUTHORIZED WB.log.info('msg="List: returning list of open files" client="%s"' % flask.request.remote_addr) return flask.Response(json.dumps(WB.openfiles), mimetype='application/json') diff --git a/src/bridge/codimd.py b/src/bridge/codimd.py index 9ecf15fc..f0f68d38 100644 --- a/src/bridge/codimd.py +++ b/src/bridge/codimd.py @@ -33,15 +33,14 @@ class AppFailure(Exception): sslverify = None -def init(_appurl, _appinturl, apipath): +def init(_appurl, _appinturl, _apikey): '''Initialize global vars from the environment''' global appurl global appexturl global apikey appexturl = _appurl appurl = _appinturl - with open(apipath + 'codimd_apikey') as f: - apikey = f.readline().strip('\n') + apikey = _apikey def getredirecturl(isreadwrite, wopisrc, acctok, wopilock, displayname): diff --git a/src/bridge/etherpad.py b/src/bridge/etherpad.py index 2c0390de..354a031f 100644 --- a/src/bridge/etherpad.py +++ b/src/bridge/etherpad.py @@ -28,7 +28,7 @@ class AppFailure(Exception): groupid = None -def init(_appurl, _appinturl, apipath): +def init(_appurl, _appinturl, _apikey): '''Initialize global vars from the environment''' global appurl global appexturl @@ -36,8 +36,7 @@ def init(_appurl, _appinturl, apipath): global groupid appexturl = _appurl appurl = _appinturl - with open(apipath + 'etherpad_apikey') as f: - apikey = f.readline().strip('\n') + apikey = _apikey # create a general group to attach all pads groupid = _apicall('createGroupIfNotExistsFor', {'groupMapper': 1}) groupid = groupid['data']['groupID'] diff --git a/src/core/discovery.py b/src/core/discovery.py index 57f9ef6e..fa04fd9c 100644 --- a/src/core/discovery.py +++ b/src/core/discovery.py @@ -3,6 +3,7 @@ Helper code for the WOPI discovery phase, as well as for integrating the apps supported by the bridge functionality. +This code is going to be deprecated once the new Reva AppProvider is fully functional. ''' from xml.etree import ElementTree as ET @@ -15,9 +16,8 @@ # convenience references to global entities srv = None log = None -apps = {} -def registerapp(appname, appurl, appinturl): +def registerapp(appname, appurl, appinturl, apikey=None): '''Registers the given app in the internal endpoints list''' '''For the time being, this is highly customized to keep backwards-compatibility. To be reviewed''' if not appinturl: @@ -34,21 +34,17 @@ def registerapp(appname, appurl, appinturl): urlsrc = discXml.find('net-zone/app')[0].attrib['urlsrc'] if urlsrc.find('loleaflet') > 0: # this is Collabora - apps[appname] = {} codetypes = srv.config.get('general', 'codeofficetypes', fallback='.odt .ods .odp').split() for t in codetypes: srv.endpoints[t] = {} srv.endpoints[t]['view'] = urlsrc + 'permission=readonly' srv.endpoints[t]['edit'] = urlsrc + 'permission=edit' srv.endpoints[t]['new'] = urlsrc + 'permission=edit' # pylint: disable=bad-whitespace - apps[appname][t] = srv.endpoints[t] log.info('msg="Collabora Online endpoints successfully configured" count="%d" CODEURL="%s"' % (len(codetypes), srv.endpoints['.odt']['edit'])) - return flask.Response(json.dumps(list(apps[appname].keys())), mimetype='application/json') + return # else this must be Microsoft Office Online - # TODO remove hardcoded logic - apps[appname] = {} srv.endpoints['.docx'] = {} srv.endpoints['.docx']['view'] = appurl + '/wv/wordviewerframe.aspx?edit=0' srv.endpoints['.docx']['edit'] = appurl + '/we/wordeditorframe.aspx?edit=1' @@ -61,12 +57,9 @@ def registerapp(appname, appurl, appinturl): srv.endpoints['.pptx']['view'] = appurl + '/p/PowerPointFrame.aspx?PowerPointView=ReadingView' srv.endpoints['.pptx']['edit'] = appurl + '/p/PowerPointFrame.aspx?PowerPointView=EditView' srv.endpoints['.pptx']['new'] = appurl + '/p/PowerPointFrame.aspx?PowerPointView=EditView&New=1' # pylint: disable=bad-whitespace - apps[appname]['.docx'] = srv.endpoints['.docx'] - apps[appname]['.xlsx'] = srv.endpoints['.xlsx'] - apps[appname]['.pptx'] = srv.endpoints['.pptx'] log.info('msg="Microsoft Office Online endpoints successfully configured" OfficeURL="%s"' % srv.endpoints['.docx']['edit']) - return flask.Response(json.dumps(list(apps[appname].keys())), mimetype='application/json') + return elif discReq.status_code == http.client.NOT_FOUND: # try and scrape the app homepage to see if a bridge-supported app is found @@ -74,8 +67,7 @@ def registerapp(appname, appurl, appinturl): discReq = requests.get(appurl, verify=False).content.decode() if discReq.find('CodiMD') > 0: # TODO remove hardcoded logic - bridge.WB.loadplugin(appname, appurl, appinturl) - apps[appname] = {} + bridge.WB.loadplugin(appname, appurl, appinturl, apikey) bridgeurl = srv.config.get('general', 'wopiurl') + '/wopi/bridge/open?' srv.endpoints['.md'] = {} srv.endpoints['.md']['view'] = srv.endpoints['.md']['edit'] = bridgeurl @@ -83,31 +75,25 @@ def registerapp(appname, appurl, appinturl): srv.endpoints['.zmd']['view'] = srv.endpoints['.zmd']['edit'] = bridgeurl srv.endpoints['.txt'] = {} srv.endpoints['.txt']['view'] = srv.endpoints['.txt']['edit'] = bridgeurl - apps[appname]['.md'] = srv.endpoints['.md'] - apps[appname]['.zmd'] = srv.endpoints['.zmd'] - apps[appname]['.txt'] = srv.endpoints['.txt'] log.info('msg="iopRegisterApp: CodiMD endpoints successfully configured" BridgeURL="%s"' % bridgeurl) - return flask.Response(json.dumps(list(apps[appname].keys())), mimetype='application/json') + return if discReq.find('Etherpad') > 0: - bridge.WB.loadplugin(appname, appurl, appinturl) + bridge.WB.loadplugin(appname, appurl, appinturl, apikey) bridgeurl = srv.config.get('general', 'wopiurl') + '/wopi/bridge/open?' # TODO remove hardcoded logic - apps[appname] = {} srv.endpoints['.epd'] = {} srv.endpoints['.epd']['view'] = srv.endpoints['.epd']['edit'] = bridgeurl - apps[appname]['.epd'] = srv.endpoints['.epd'] log.info('msg="iopRegisterApp: Etherpad endpoints successfully configured" BridgeURL="%s"' % bridgeurl) - return flask.Response(json.dumps(list(apps[appname].keys())), mimetype='application/json') + return except ValueError: # bridge plugin could not be initialized - return 'Failed to initialize WOPI bridge plugin for app "%s"' % appname, http.client.INTERNAL_SERVER_ERROR + pass except requests.exceptions.ConnectionError: pass # in all other cases, fail log.error('msg="iopRegisterApp: app is not WOPI-compatible" appurl="%s"' % appurl) - return 'App is not WOPI-compatible', http.client.BAD_REQUEST def initappsregistry(): @@ -122,4 +108,6 @@ def initappsregistry(): codimd = srv.config.get('general', 'codimdurl', fallback=None) codimdint = srv.config.get('general', 'codimdinturl', fallback=None) if codimd: - registerapp('CodiMD', codimd, codimdint) + with open('/var/run/secrets/codimd_apikey') as f: + apikey = f.readline().strip('\n') + registerapp('CodiMD', codimd, codimdint, apikey) diff --git a/src/core/readme.md b/src/core/readme.md index bd711454..82ad19ca 100644 --- a/src/core/readme.md +++ b/src/core/readme.md @@ -1,7 +1,8 @@ ## WOPI server - core module This module includes the core WOPI protocol implementation, along with the discovery logic -in the `discovery.py` module and the interoperable lock APIs in the `ioplocks.py` module. +in the `discovery.py` module (to be moved to Reva) and the interoperable lock APIs +in the `ioplocks.py` module. To access the storage, three interfaces are provided: diff --git a/src/core/wopi.py b/src/core/wopi.py index ada74c61..35493ab0 100644 --- a/src/core/wopi.py +++ b/src/core/wopi.py @@ -15,7 +15,6 @@ from urllib.parse import quote_plus as url_quote_plus from urllib.parse import unquote as url_unquote import core.wopiutils as utils -import core.discovery # convenience references to global entities st = None @@ -66,12 +65,8 @@ def checkFileInfo(fileid): filemd['SupportsUpdate'] = filemd['UserCanWrite'] = filemd['SupportsLocks'] = filemd['SupportsRename'] = \ filemd['SupportsDeleteFile'] = filemd['UserCanRename'] = acctok['viewmode'] == utils.ViewMode.READ_WRITE filemd['UserCanNotWriteRelative'] = acctok['viewmode'] != utils.ViewMode.READ_WRITE - if acctok['appname'] in core.discovery.apps: - appurl = core.discovery.apps[acctok['appname']][fExt] - else: - appurl = srv.endpoints[fExt] # TODO deprecated, must make sure appname is always correct - filemd['HostViewUrl'] = '%s&%s' % (appurl['view'], wopiSrc) - filemd['HostEditUrl'] = '%s&%s' % (appurl['edit'], wopiSrc) + filemd['HostViewUrl'] = '%s&%s' % (acctok['appviewurl'], wopiSrc) + filemd['HostEditUrl'] = '%s&%s' % (acctok['appediturl'], wopiSrc) # populate app-specific metadata if acctok['appname'].find('Microsoft') > 0: @@ -310,22 +305,15 @@ def putRelative(fileid, reqheaders, acctok): log.info('msg="PutRelative: generating new access token" user="%s" filename="%s" ' \ 'mode="ViewMode.READ_WRITE" friendlyname="%s"' % (acctok['userid'], targetName, acctok['username'])) - inode, _, newacctok = utils.generateAccessToken(acctok['userid'], targetName, utils.ViewMode.READ_WRITE, acctok['username'], \ - acctok['folderurl'], acctok['endpoint'], acctok['appname']) + inode, newacctok = utils.generateAccessToken(acctok['userid'], targetName, utils.ViewMode.READ_WRITE, acctok['username'], \ + acctok['folderurl'], acctok['endpoint'], acctok['appname'], \ + acctok['appediturl'], acctok['appviewurl']) # prepare and send the response as JSON putrelmd = {} putrelmd['Name'] = os.path.basename(targetName) putrelmd['Url'] = '%s?access_token=%s' % (url_unquote(utils.generateWopiSrc(inode)), newacctok) - fExt = os.path.splitext(targetName)[1] - appurl = None - if acctok['appname'] in core.discovery.apps: - appurl = core.discovery.apps[acctok['appname']][fExt] - elif fExt in srv.endpoints: - appurl = srv.endpoints[fExt] # TODO deprecated - if appurl: - putrelmd['HostEditUrl'] = '%s&WOPISrc=%s&access_token=%s' % \ - (appurl['edit'], utils.generateWopiSrc(inode), newacctok) - # else we don't know the app to edit this file type, therefore we do not provide the info + putrelmd['HostEditUrl'] = '%s&WOPISrc=%s&access_token=%s' % \ + (acctok['appediturl'], utils.generateWopiSrc(inode), newacctok) log.debug('msg="PutRelative response" token="%s" metadata="%s"' % (newacctok[-20:], putrelmd)) return flask.Response(json.dumps(putrelmd), mimetype='application/json') diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 6a5461c0..fd0dd0b6 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -115,28 +115,33 @@ def randomString(size): return ''.join([choice(ascii_lowercase) for _ in range(size)]) -def generateAccessToken(userid, fileid, viewmode, username, folderurl, endpoint, appname): +def generateAccessToken(userid, fileid, viewmode, username, folderurl, endpoint, appname, appediturl, appviewurl): '''Generates an access token for a given file and a given user, and returns a tuple with the file's inode and the URL-encoded access token.''' 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. - statInfo = st.statx(endpoint, fileid, userid, versioninv=1) + statinfo = st.statx(endpoint, fileid, userid, versioninv=1) except IOError as e: log.info('msg="Requested file not found or not a file" fileid="%s" error="%s"' % (fileid, e)) raise # if write access is requested, probe whether there's already a lock file coming from Desktop applications exptime = int(time.time()) + srv.tokenvalidity - acctok = jwt.encode({'userid': userid, 'filename': statInfo['filepath'], 'username': username, + if not appediturl: + # for backwards compatibility + fext = os.path.splitext(statinfo['filepath'])[1] + appediturl = srv.endpoints[fext]['edit'] + appviewurl = srv.endpoints[fext]['view'] + acctok = jwt.encode({'userid': userid, 'filename': statinfo['filepath'], 'username': username, 'viewmode': viewmode.value, 'folderurl': folderurl, 'endpoint': endpoint, - 'appname': appname, 'exp': exptime}, + 'appname': appname, 'appediturl': appediturl, 'appviewurl': appviewurl, 'exp': exptime}, srv.wopisecret, algorithm='HS256') log.info('msg="Access token generated" userid="%s" mode="%s" endpoint="%s" filename="%s" inode="%s" ' \ 'mtime="%s" folderurl="%s" appname="%s" expiration="%d" token="%s"' % - (userid, viewmode, endpoint, statInfo['filepath'], statInfo['inode'], statInfo['mtime'], \ + (userid, 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'], statInfo['filepath'], acctok + return statinfo['inode'], acctok def getLockName(filename): diff --git a/src/wopiserver.py b/src/wopiserver.py index 32af6c06..ff4b9e12 100755 --- a/src/wopiserver.py +++ b/src/wopiserver.py @@ -16,7 +16,7 @@ from platform import python_version import logging import logging.handlers -import urllib.parse +from urllib.parse import unquote as url_unquote import http.client import json try: @@ -183,7 +183,7 @@ def index(): # -# open-in-app endpoint +# open-in-app endpoints # @Wopi.app.route("/wopi/iop/open", methods=['GET']) @Wopi.metrics.do_not_track() @@ -208,9 +208,6 @@ def iopOpen(): - string folderurl: the URL to come back to the containing folder for this file, typically shown by the Office app - string endpoint (optional): the storage endpoint to be used to look up the file or the storage id, in case of multi-instance underlying storage; defaults to 'default' - - string appname (optional): the application engine to be used to open the file, previously registered - via /wopi/iop/registerapp. When provided, the return is a HTTP redirect to the full URL, otherwise only - the WOPISrc and access_token parts of the URL are returned in the body. ''' Wopi.refreshconfig() req = flask.request @@ -235,7 +232,7 @@ def iopOpen(): Wopi.log.warning('msg="iopOpen: invalid or missing user/token in request" client="%s" user="%s"' % (req.remote_addr, userid)) return 'Client not authorized', http.client.UNAUTHORIZED - fileid = urllib.parse.unquote(req.args['filename']) if 'filename' in req.args else req.args.get('fileid', '') + fileid = url_unquote(req.args['filename']) if 'filename' in req.args else req.args.get('fileid', '') if fileid == '': Wopi.log.warning('msg="iopOpen: either filename or fileid must be provided" client="%s"' % req.remote_addr) return 'Invalid argument', http.client.BAD_REQUEST @@ -251,16 +248,12 @@ def iopOpen(): viewmode = utils.ViewMode.READ_WRITE if 'canedit' in req.args and req.args['canedit'].lower() == 'true' \ else utils.ViewMode.READ_ONLY username = req.args.get('username', '') - folderurl = urllib.parse.unquote(req.args.get('folderurl', '%%2F')) # defaults to `/` + folderurl = url_unquote(req.args.get('folderurl', '%%2F')) # defaults to `/` endpoint = req.args.get('endpoint', 'default') - appname = urllib.parse.unquote(req.args.get('appname', 'default')) try: - inode, fname, acctok = utils.generateAccessToken(userid, fileid, viewmode, username, folderurl, endpoint, appname) + inode, acctok = utils.generateAccessToken(userid, fileid, viewmode, username, folderurl, endpoint, '', '', '') # generate the URL-encoded payload for the app engine - url = '%s&access_token=%s' % (utils.generateWopiSrc(inode), acctok) # no need to URL-encode the JWT token - if appname not in core.discovery.apps: - return url - return flask.redirect('%s&WOPISrc=%s' % (core.discovery.apps[appname][os.path.splitext(fname)[1]]['edit' if viewmode == utils.ViewMode.READ_WRITE else 'view'], url)) + return '%s&access_token=%s' % (utils.generateWopiSrc(inode), acctok) # no need to URL-encode the JWT token except IOError as e: Wopi.log.info('msg="iopOpen: remote error on generating token" client="%s" user="%s" ' \ 'friendlyname="%s" mode="%s" endpoint="%s" reason="%s"' % @@ -274,6 +267,88 @@ def cboxOpen(): return iopOpen() +@Wopi.app.route("/wopi/iop/openinapp", methods=['GET']) +@Wopi.metrics.do_not_track() +@Wopi.metrics.counter('open_by_app', 'Number of /open calls by appname', + labels={ 'open_type': lambda: flask.request.args['appname'] }) +def iopOpenInApp(): + '''Generates a WOPISrc target and an access token to be passed to a WOPI-compatible Office-like app + for accessing a given file for a given user. + Required headers: + - Authorization: a bearer shared secret to protect this call as it provides direct access to any user's file + - TokenHeader: an x-access-token to serve as user identity towards Reva + - ApiKey (optional): a shared secret to be used with the end-user application if required + Request arguments: + - enum viewmode: how the user should access the file, according to utils.ViewMode/the CS3 app provider API + - string username (optional): user's full display name, typically shown by the Office app + - string filename OR fileid: the full path of the filename to be opened, or its fileid + - string folderurl: the URL to come back to the containing folder for this file, typically shown by the Office app + - string endpoint (optional): the storage endpoint to be used to look up the file or the storage id, in case of + multi-instance underlying storage; defaults to 'default' + - string appname: the identifier of the end-user application to be served + - string appediturl: the URL of the end-user application in edit mode + - string appviewurl (optional): the URL of the end-user application in view mode when different (defaults to appediturl) + - string appinturl (optional): the internal URL of the end-user application (applicable with containerized deployments) + ''' + Wopi.refreshconfig() + req = flask.request + if req.headers.get('Authorization') != 'Bearer ' + Wopi.iopsecret: + Wopi.log.warning('msg="iopOpenInApp: unauthorized access attempt, missing authorization token" ' \ + 'client="%s" clientAuth="%s"' % (req.remote_addr, req.headers.get('Authorization'))) + return 'Client not authorized', http.client.UNAUTHORIZED + # now validate the user identity and deny root access + try: + userid = req.headers['TokenHeader'] + except KeyError: + Wopi.log.warning('msg="iopOpenInApp: invalid or missing token in request" client="%s" user="%s"' % + (req.remote_addr, userid)) + return 'Client not authorized', http.client.UNAUTHORIZED + fileid = req.args.get('fileid', '') + if not fileid: + Wopi.log.warning('msg="iopOpenInApp: fileid must be provided" client="%s"' % req.remote_addr) + return 'Missing fileid argument', http.client.BAD_REQUEST + try: + viewmode = utils.ViewMode(req.args['viewmode']) + except (KeyError, ValueError) as e: + Wopi.log.warning('msg="iopOpenInApp: invalid viewmode parameter" client="%s" viewmode="%s" error="%s"' % + (req.remote_addr, req.args.get('viewmode'), e)) + return 'Missing or invalid viewmode argument', http.client.BAD_REQUEST + username = req.args.get('username', '') + folderurl = url_unquote(req.args.get('folderurl', '%%2F')) # defaults to `/` + endpoint = req.args.get('endpoint', 'default') + appname = url_unquote(req.args.get('appname', '')) + appediturl = url_unquote(req.args.get('appediturl', '')) + appviewurl = url_unquote(req.args.get('appviewurl', '')) + if bridge.issupported(appname): + # This is a WOPI-bridge application, get the extra info to enable it + apikey = req.headers.get('ApiKey') + appurl = appediturl + appinturl = req.headers.get('appinturl', appurl) # defaults to the external appurl + appediturl = appviewurl = Wopi.wopiurl + '/wopi/bridge/open?' + try: + bridge.WB.loadplugin(appname, appurl, appinturl, apikey) + except ValueError: + return 'Failed to load WOPI bridge plugin for %s' % appname, http.client.INTERNAL_SERVER_ERROR + elif not appname or not appediturl or not appviewurl: + Wopi.log.warning('msg="iopOpenInApp: app-related arguments must be provided" client="%s"' % req.remote_addr) + return 'Missing appname or appediturl or appviewurl arguments', http.client.BAD_REQUEST + + try: + inode, acctok = utils.generateAccessToken(userid, fileid, viewmode, username, folderurl, endpoint, + appname, appediturl, appviewurl) + except IOError as e: + Wopi.log.info('msg="iopOpenInApp: remote error on generating token" client="%s" user="%s" ' \ + 'friendlyname="%s" mode="%s" endpoint="%s" reason="%s"' % + (req.remote_addr, userid, username, viewmode, endpoint, e)) + return 'Remote error, file not found or file is a directory', http.client.NOT_FOUND + + if bridge.issupported(appname): + return bridge.appopen(utils.generateWopiSrc(inode), acctok) + return flask.redirect('%s&WOPISrc=%s&access_token=%s' % + (appediturl if viewmode == utils.ViewMode.READ_WRITE else appviewurl, + utils.generateWopiSrc(inode), acctok)) # no need to URL-encode the JWT token + + @Wopi.app.route("/wopi/iop/open/list", methods=['GET']) def iopGetOpenFiles(): '''Returns a list of all currently opened files, for operations purposes only. @@ -292,51 +367,6 @@ def iopGetOpenFiles(): return flask.Response(json.dumps(jlist), mimetype='application/json') -# -# WOPI discovery endpoints -# -@Wopi.app.route("/wopi/cbox/endpoints", methods=['GET']) -@Wopi.metrics.do_not_track() -def cboxEndPoints(): - '''Returns the office apps end-points registered with this WOPI server. This is used by the EFSS - client to discover which Apps frontends can be used with this WOPI server. - Note that if the end-points are relocated and the corresponding configuration entry updated, - the WOPI server must be restarted.''' - # TODO this endpoint should be moved to the Apps Registry service in Reva - Wopi.log.info('msg="cboxEndPoints: returning all registered office apps end-points" client="%s" mimetypesCount="%d"' % - (flask.request.remote_addr, len(Wopi.endpoints))) - return flask.Response(json.dumps(Wopi.endpoints), mimetype='application/json') - - -@Wopi.app.route("/wopi/iop/discoverapp", methods=['POST']) -@Wopi.metrics.do_not_track() -def iopDiscoverApp(): - '''Discover a new WOPI app - Required headers: - - Authorization: a bearer shared secret to protect this call - Request arguments: - - appname: a human-readable string to identify the app - - appurl: the (URL-encoded) URL of the app engine. It is expected that the WOPI discovery info can be gathered - by querying appurl + '/hosting/discovery' according to the WOPI specs, or that the app is supported via - the bridge extensions - - appinturl (optional): if provided, the internal URL to be used for reaching the app (defaults to the appurl) - The call returns: - - HTTP UNAUTHORIZED (401) if the 'Authorization: Bearer' secret is not provided in the header (cf. /wopi/cbox/open) - - HTTP NOT_FOUND (404) if there was an error contacting the appurl - - HTTP BAD_REQUEST (400) if the appurl is not a WOPI-compatible or supported application - - HTTP OK (200) if the appplication was properly registered: in this case, all supported file extensions - are returned as a JSON list - ''' - req = flask.request - if req.headers.get('Authorization') != 'Bearer ' + Wopi.iopsecret: - Wopi.log.warning('msg="iopRegisterApp: unauthorized access attempt, missing authorization token" ' \ - 'client="%s"' % req.remote_addr) - return 'Client not authorized', http.client.UNAUTHORIZED - return core.discovery.registerapp(req.args.get('appname', 'unnamed'), \ - urllib.parse.unquote(req.args.get('appurl', 'http://invalid')), - urllib.parse.unquote(req.args.get('appinturl', ''))) - - # # WOPI protocol implementation # @@ -459,7 +489,15 @@ def cboxUnlock(): # @Wopi.app.route("/wopi/bridge/open", methods=["GET"]) def bridgeOpen(): - return bridge.appopen() + try: + wopisrc = url_unquote(flask.request.args['WOPISrc']) + acctok = flask.request.args['access_token'] + Wopi.log.info('msg="BridgeOpen called" client="%s" user-agent="%s" token="%s"' % + (flask.request.remote_addr, flask.request.user_agent, acctok[-20:])) + except KeyError as e: + Wopi.log.error('msg="BridgeOpen: unable to open the file, missing WOPI context" error="%s"' % e) + return bridge.guireturn('Missing arguments'), http.client.BAD_REQUEST + return bridge.appopen(wopisrc, acctok) @Wopi.app.route("/wopi/bridge/", methods=["POST"]) @@ -483,6 +521,19 @@ def bridgeList(): # # deprecated # +@Wopi.app.route("/wopi/cbox/endpoints", methods=['GET']) +@Wopi.metrics.do_not_track() +def cboxEndPoints(): + '''Returns the office apps end-points registered with this WOPI server. This is used by the EFSS + client to discover which Apps frontends can be used with this WOPI server. + Note that if the end-points are relocated and the corresponding configuration entry updated, + the WOPI server must be restarted.''' + # TODO this endpoint should be moved to the Apps Registry service in Reva + Wopi.log.info('msg="cboxEndPoints: returning all registered office apps end-points" client="%s" mimetypesCount="%d"' % + (flask.request.remote_addr, len(Wopi.endpoints))) + return flask.Response(json.dumps(Wopi.endpoints), mimetype='application/json') + + @Wopi.app.route("/wopi/cbox/download", methods=['GET']) def cboxDownload(): '''Returns the file's content for a given valid access token. Used as a download URL,