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,