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,