Skip to content

Commit

Permalink
Alternate model for discovery
Browse files Browse the repository at this point in the history
See cs3org/reva#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.
  • Loading branch information
glpatcern committed Jun 25, 2021
1 parent 214cf6a commit 6c8df04
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 145 deletions.
55 changes: 25 additions & 30 deletions src/bridge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}

Expand Down Expand Up @@ -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))
Expand All @@ -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 '<div align="center" style="color:#808080; padding-top:50px; font-family:Verdana">%s</div>' % msg

Expand All @@ -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]

Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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')

Expand Down
5 changes: 2 additions & 3 deletions src/bridge/codimd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 2 additions & 3 deletions src/bridge/etherpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,15 @@ 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
global apikey
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']
Expand Down
36 changes: 12 additions & 24 deletions src/core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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'
Expand All @@ -61,53 +57,43 @@ 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
try:
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
srv.endpoints['.zmd'] = {}
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():
Expand All @@ -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)
3 changes: 2 additions & 1 deletion src/core/readme.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
26 changes: 7 additions & 19 deletions src/core/wopi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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')

Expand Down
17 changes: 11 additions & 6 deletions src/core/wopiutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 6c8df04

Please sign in to comment.