Mercurial > libervia-web
view src/server/server.py @ 983:8c9fdb58de5f
server (libervia.sh): force python 2 to launch twistd, and avoid launching wrong version if twistd is installed for python 3 too
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 17 Nov 2017 11:01:34 +0100 |
parents | bcacf970f970 |
children | f0fc28b3bd1e |
line wrap: on
line source
#!/usr/bin/python # -*- coding: utf-8 -*- # Libervia: a Salut à Toi frontend # Copyright (C) 2011-2017 Jérôme Poisson <goffi@goffi.org> # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from twisted.application import service from twisted.internet import reactor, defer from twisted.web import server from twisted.web import static from twisted.web import resource as web_resource from twisted.web import util as web_util from twisted.web import http from twisted.python.components import registerAdapter from twisted.python import failure from twisted.words.protocols.jabber import jid from txjsonrpc.web import jsonrpc from txjsonrpc import jsonrpclib from sat.core.log import getLogger log = getLogger(__name__) from sat_frontends.bridge.dbus_bridge import Bridge, BridgeExceptionNoService, const_TIMEOUT as BRIDGE_TIMEOUT from sat.core.i18n import _, D_ from sat.core import exceptions from sat.tools import utils from sat.tools.common import regex from sat.tools.common import template from sat.tools.common import uri as common_uri import re import glob import os.path import sys import tempfile import shutil import uuid import urlparse import urllib from httplib import HTTPS_PORT import libervia try: import OpenSSL from twisted.internet import ssl except ImportError: ssl = None from libervia.server.constants import Const as C from libervia.server.blog import MicroBlog from libervia.server import session_iface # following value are set from twisted.plugins.libervia_server initialise (see the comment there) DATA_DIR_DEFAULT = OPT_PARAMETERS_BOTH = OPT_PARAMETERS_CFG = coerceDataDir = None def quote(value): """shortcut to quote an unicode value for URL""" return urllib.quote_plus(value.encode('utf-8')).replace('%40','@') class LiberviaSession(server.Session): sessionTimeout = C.SESSION_TIMEOUT def __init__(self, *args, **kwargs): self.__lock = False server.Session.__init__(self, *args, **kwargs) def lock(self): """Prevent session from expiring""" self.__lock = True self._expireCall.reset(sys.maxint) def unlock(self): """Allow session to expire again, and touch it""" self.__lock = False self.touch() def touch(self): if not self.__lock: server.Session.touch(self) class ProtectedFile(static.File): """A static.File class which doens't show directory listing""" def directoryListing(self): return web_resource.NoResource() class LiberviaRootResource(ProtectedFile): """Specialized resource for Libervia root handle redirections declared in sat.conf """ def __init__(self, options, *args, **kwargs): """ @param options(dict): configuration options, same as Libervia.options """ super(LiberviaRootResource, self).__init__(*args, **kwargs) ## redirections self.redirections = {} if options['url_redirections_dict'] and not options['url_redirections_profile']: # FIXME: url_redirections_profile should not be needed. It is currently used to # redirect to an URL which associate the profile with the service, but this # is not clean, and service should be explicitly specified raise ValueError(u"url_redirections_profile need to be filled if you want to use url_redirections_dict") for old, new_data in options['url_redirections_dict'].iteritems(): # new_data can be a dictionary or a unicode url if isinstance(new_data, dict): # new_data dict must contain either "url", "page" or "path" key (exclusive) # if "path" is used, a file url is constructed with it if len({'path', 'url', 'page'}.intersection(new_data.keys())) != 1: raise ValueError(u'You must have one and only one of "url", "page" or "path" key in your url_redirections_dict data') if 'url' in new_data: new = new_data['url'] elif 'page' in new_data: new = new_data new['type'] = 'page' new.setdefault('path_args', []) if not isinstance(new['path_args'], list): log.error(_(u'"path_args" in redirection of {old} must be a list. Ignoring the redirection'.format( old = old))) continue new.setdefault('query_args', {}) if not isinstance(new['query_args'], dict): log.error(_(u'"query_args" in redirection of {old} must be a dictionary. Ignoring the redirection'.format( old = old))) continue new['path_args'] = [quote(a) for a in new['path_args']] # we keep an inversed dict of page redirection (page/path_args => redirecting URL) # so getURL can return the redirecting URL if the same arguments are used # making the URL consistent args_hash = tuple(new['path_args']) LiberviaPage.pages_redirects.setdefault(new_data['page'], {})[args_hash] = old # we need lists in query_args because it will be used # as it in request.path_args for k,v in new['query_args'].iteritems(): if isinstance(v, basestring): new['query_args'][k] = [v] elif 'path' in new_data: new = 'file:{}'.format(urllib.quote(new_data['path'])) else: new = new_data new_data = {} # some normalization if not old.strip(): # root URL special case old = '' elif not old.startswith('/'): raise ValueError(u"redirected url must start with '/', got {}".format(old)) else: old = self._normalizeURL(old) if isinstance(new, dict): # dict are handled differently, they contain data # which are use dynamically when the request is done self.redirections[old] = new if not old: if new['type'] == 'page': log.info(_(u"Root URL redirected to page {name}").format(name=new['page'])) continue # at this point we have a rediction URL in new, we can parse it new_url = urlparse.urlsplit(new.encode('utf-8')) # we handle the known URL schemes if new_url.scheme == 'xmpp': # XMPP URI parsed_qs = urlparse.parse_qs(new_url.geturl()) try: item = parsed_qs['item'][0] if not item: raise KeyError except (IndexError, KeyError): raise NotImplementedError(u"only item for PubSub URI is handled for the moment for url_redirections_dict") location = "/blog/{profile}/{item}".format( profile=quote(options['url_redirections_profile']), item = urllib.quote_plus(item), ).decode('utf-8') request_data = self._getRequestData(location) elif new_url.scheme in ('', 'http', 'https'): # direct redirection if new_url.netloc: raise NotImplementedError(u"netloc ({netloc}) is not implemented yet for url_redirections_dict, it is not possible to redirect to an external website".format( netloc = new_url.netloc)) location = urlparse.urlunsplit(('', '', new_url.path, new_url.query, new_url.fragment)).decode('utf-8') request_data = self._getRequestData(location) elif new_url.scheme in ('file'): # file or directory if new_url.netloc: raise NotImplementedError(u"netloc ({netloc}) is not implemented for url redirection to file system, it is not possible to redirect to an external host".format( netloc = new_url.netloc)) path = urllib.unquote(new_url.path) if not os.path.isabs(path): raise ValueError(u'file redirection must have an absolute path: e.g. file:/path/to/my/file') # for file redirection, we directly put child here segments, dummy, last_segment = old.rpartition('/') url_segments = segments.split('/') if segments else [] current = self for segment in url_segments: resource = web_resource.NoResource() current.putChild(segment, resource) current = resource resource_class = ProtectedFile if new_data.get('protected',True) else static.File current.putChild(last_segment, resource_class(path)) log.debug(u"Added redirection from /{old} to file system path {path}".format(old=old.decode('utf-8'), path=path.decode('utf-8'))) continue # we don't want to use redirection system, so we continue here else: raise NotImplementedError(u"{scheme}: scheme is not managed for url_redirections_dict".format(scheme=new_url.scheme)) self.redirections[old] = request_data if not old: log.info(_(u"Root URL redirected to {uri}").format(uri=request_data[1].decode('utf-8'))) # no need to keep url_redirections*, they will not be used anymore del options['url_redirections_dict'] del options['url_redirections_profile'] # the default root URL, if not redirected if not '' in self.redirections: self.redirections[''] = self._getRequestData(C.LIBERVIA_MAIN_PAGE) def _normalizeURL(self, url, lower=True): """Return URL normalized for self.redirections dict @param url(unicode): URL to normalize @param lower(bool): lower case of url if True @return (str): normalized URL """ if lower: url = url.lower() return '/'.join((p for p in url.encode('utf-8').split('/') if p)) def _getRequestData(self, uri): """Return data needed to redirect request @param url(unicode): destination url @return (tuple(list[str], str, str, dict): tuple with splitted path as in Request.postpath uri as in Request.uri path as in Request.path args as in Request.args """ uri = uri.encode('utf-8') # XXX: we reuse code from twisted.web.http.py here # as we need to have the same behaviour x = uri.split(b'?', 1) if len(x) == 1: path = uri args = {} else: path, argstring = x args = http.parse_qs(argstring, 1) # XXX: splitted path case must not be changed, as it may be significant # (e.g. for blog items) return self._normalizeURL(path, lower=False).split('/'), uri, path, args def _redirect(self, request, request_data): """Redirect an URL by rewritting request this is *NOT* a HTTP redirection, but equivalent to URL rewritting @param request(web.http.request): original request @param request_data(tuple): data returned by self._getRequestData @return (web_resource.Resource): resource to use """ # recursion check try: request._redirected except AttributeError: pass else: try: dummy, uri, dummy, dummy = request_data except ValueError: uri = u'' log.warning(D_(u"recursive redirection, please fix this URL:\n{old} ==> {new}").format( old=request.uri.decode('utf-8'), new=uri.decode('utf-8'), )) return web_resource.NoResource() request._redirected = True # here to avoid recursive redirections if isinstance(request_data, dict): if request_data['type'] == 'page': try: page = LiberviaPage.getPageByName(request_data['page']) except KeyError: log.error(_(u"Can't find page named \"{name}\" requested in redirection").format( name = request_data['page'])) return web_resource.NoResource() request.postpath = request_data['path_args'][:] + request.postpath try: request.args.update(request_data['query_args']) except (TypeError, ValueError): log.error(_(u"Invalid args in redirection: {query_args}").format( query_args=request_data['query_args'])) return web_resource.NoResource() return page else: raise exceptions.InternalError(u'unknown request_data type') else: path_list, uri, path, args = request_data log.debug(u"Redirecting URL {old} to {new}".format( old=request.uri.decode('utf-8'), new=uri.decode('utf-8'), )) # we change the request to reflect the new url request.postpath = path_list[1:] request.uri = uri request.path = path request.args = args # we start again to look for a child with the new url return self.getChildWithDefault(path_list[0], request) def getChildWithDefault(self, name, request): # XXX: this method is overriden only for root url # which is the only ones who need to be handled before other children if name == '' and not request.postpath: return self._redirect(request, self.redirections['']) return super(LiberviaRootResource, self).getChildWithDefault(name, request) def getChild(self, name, request): resource = super(LiberviaRootResource, self).getChild(name, request) if isinstance(resource, web_resource.NoResource): # if nothing was found, we try our luck with redirections # XXX: we want redirections to happen only if everything else failed # first we check with current URL # there our last try with the remaining URL for path_elts in (request.prepath, [name] + request.postpath): current_url = '/'.join(path_elts).lower() try: request_data = self.redirections[current_url] except KeyError: # no redirection for this url pass else: return self._redirect(request, request_data) return resource def createSimilarFile(self, path): # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource f = LiberviaRootResource.__base__(path, self.defaultType, self.ignoredExts, self.registry) # refactoring by steps, here - constructor should almost certainly take these f.processors = self.processors f.indexNames = self.indexNames[:] f.childNotFound = self.childNotFound return f class JSONRPCMethodManager(jsonrpc.JSONRPC): def __init__(self, sat_host): jsonrpc.JSONRPC.__init__(self) self.sat_host = sat_host def asyncBridgeCall(self, method_name, *args, **kwargs): return self.sat_host.bridgeCall(method_name, *args, **kwargs) class MethodHandler(JSONRPCMethodManager): def __init__(self, sat_host): JSONRPCMethodManager.__init__(self, sat_host) def render(self, request): self.session = request.getSession() profile = session_iface.ISATSession(self.session).profile if not profile: #user is not identified, we return a jsonrpc fault parsed = jsonrpclib.loads(request.content.read()) fault = jsonrpclib.Fault(C.ERRNUM_LIBERVIA, C.NOT_ALLOWED) # FIXME: define some standard error codes for libervia return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) # pylint: disable=E1103 return jsonrpc.JSONRPC.render(self, request) def jsonrpc_getVersion(self): """Return SàT version""" try: return self._version_cache except AttributeError: self._version_cache = self.sat_host.bridge.getVersion() return self._version_cache def jsonrpc_getLiberviaVersion(self): """Return Libervia version""" return self.sat_host.full_version def jsonrpc_disconnect(self): """Disconnect the profile""" sat_session = session_iface.ISATSession(self.session) profile = sat_session.profile self.sat_host.bridge.disconnect(profile) def jsonrpc_getContacts(self): """Return all passed args.""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getContacts(profile) def jsonrpc_addContact(self, entity, name, groups): """Subscribe to contact presence, and add it to the given groups""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.addContact(entity, profile) self.sat_host.bridge.updateContact(entity, name, groups, profile) def jsonrpc_delContact(self, entity): """Remove contact from contacts list""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.delContact(entity, profile) def jsonrpc_updateContact(self, entity, name, groups): """Update contact's roster item""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.updateContact(entity, name, groups, profile) def jsonrpc_subscription(self, sub_type, entity): """Confirm (or infirm) subscription, and setup user roster in case of subscription""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.subscription(sub_type, entity, profile) def jsonrpc_getWaitingSub(self): """Return list of room already joined by user""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getWaitingSub(profile) def jsonrpc_setStatus(self, presence, status): """Change the presence and/or status @param presence: value from ("", "chat", "away", "dnd", "xa") @param status: any string to describe your status """ profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.setPresence('', presence, {'': status}, profile) def jsonrpc_messageSend(self, to_jid, msg, subject, type_, extra={}): """send message""" profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("messageSend", to_jid, msg, subject, type_, extra, profile) ## PubSub ## def jsonrpc_psNodeDelete(self, service, node): """Delete a whole node @param service (unicode): service jid @param node (unicode): node to delete """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("psNodeDelete", service, node, profile) # def jsonrpc_psRetractItem(self, service, node, item, notify): # """Delete a whole node # @param service (unicode): service jid # @param node (unicode): node to delete # @param items (iterable): id of item to retract # @param notify (bool): True if notification is required # """ # profile = session_iface.ISATSession(self.session).profile # return self.asyncBridgeCall("psRetractItem", service, node, item, notify, profile) # def jsonrpc_psRetractItems(self, service, node, items, notify): # """Delete a whole node # @param service (unicode): service jid # @param node (unicode): node to delete # @param items (iterable): ids of items to retract # @param notify (bool): True if notification is required # """ # profile = session_iface.ISATSession(self.session).profile # return self.asyncBridgeCall("psRetractItems", service, node, items, notify, profile) ## microblogging ## def jsonrpc_mbSend(self, service, node, mb_data): """Send microblog data @param service (unicode): service jid or empty string to use profile's microblog @param node (unicode): publishing node, or empty string to use microblog node @param mb_data(dict): microblog data @return: a deferred """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("mbSend", service, node, mb_data, profile) def jsonrpc_mbRetract(self, service, node, items): """Delete a whole node @param service (unicode): service jid, empty string for PEP @param node (unicode): node to delete, empty string for default node @param items (iterable): ids of items to retract """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("mbRetract", service, node, items, profile) def jsonrpc_mbGet(self, service_jid, node, max_items, item_ids, extra): """Get last microblogs from publisher_jid @param service_jid (unicode): pubsub service, usually publisher jid @param node(unicode): mblogs node, or empty string to get the defaut one @param max_items (int): maximum number of item to get or C.NO_LIMIT to get everything @param item_ids (list[unicode]): list of item IDs @param rsm (dict): TODO @return: a deferred couple with the list of items and metadatas. """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("mbGet", service_jid, node, max_items, item_ids, extra, profile) def jsonrpc_mbGetFromMany(self, publishers_type, publishers, max_items, extra): """Get many blog nodes at once @param publishers_type (unicode): one of "ALL", "GROUP", "JID" @param publishers (tuple(unicode)): tuple of publishers (empty list for all, list of groups or list of jids) @param max_items (int): maximum number of item to get or C.NO_LIMIT to get everything @param extra (dict): TODO @return (str): RT Deferred session id """ profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.mbGetFromMany(publishers_type, publishers, max_items, extra, profile) def jsonrpc_mbGetFromManyRTResult(self, rt_session): """Get results from RealTime mbGetFromMany session @param rt_session (str): RT Deferred session id """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("mbGetFromManyRTResult", rt_session, profile) def jsonrpc_mbGetFromManyWithComments(self, publishers_type, publishers, max_items, max_comments, rsm_dict, rsm_comments_dict): """Helper method to get the microblogs and their comments in one shot @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL") @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids) @param max_items (int): optional limit on the number of retrieved items. @param max_comments (int): maximum number of comments to retrieve @param rsm_dict (dict): RSM data for initial items only @param rsm_comments_dict (dict): RSM data for comments only @param profile_key: profile key @return (str): RT Deferred session id """ profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.mbGetFromManyWithComments(publishers_type, publishers, max_items, max_comments, rsm_dict, rsm_comments_dict, profile) def jsonrpc_mbGetFromManyWithCommentsRTResult(self, rt_session): """Get results from RealTime mbGetFromManyWithComments session @param rt_session (str): RT Deferred session id """ profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("mbGetFromManyWithCommentsRTResult", rt_session, profile) # def jsonrpc_sendMblog(self, type_, dest, text, extra={}): # """ Send microblog message # @param type_ (unicode): one of "PUBLIC", "GROUP" # @param dest (tuple(unicode)): recipient groups (ignored for "PUBLIC") # @param text (unicode): microblog's text # """ # profile = session_iface.ISATSession(self.session).profile # extra['allow_comments'] = 'True' # if not type_: # auto-detect # type_ = "PUBLIC" if dest == [] else "GROUP" # if type_ in ("PUBLIC", "GROUP") and text: # if type_ == "PUBLIC": # #This text if for the public microblog # log.debug("sending public blog") # return self.sat_host.bridge.sendGroupBlog("PUBLIC", (), text, extra, profile) # else: # log.debug("sending group blog") # dest = dest if isinstance(dest, list) else [dest] # return self.sat_host.bridge.sendGroupBlog("GROUP", dest, text, extra, profile) # else: # raise Exception("Invalid data") # def jsonrpc_deleteMblog(self, pub_data, comments): # """Delete a microblog node # @param pub_data: a tuple (service, comment node identifier, item identifier) # @param comments: comments node identifier (for main item) or False # """ # profile = session_iface.ISATSession(self.session).profile # return self.sat_host.bridge.deleteGroupBlog(pub_data, comments if comments else '', profile) # def jsonrpc_updateMblog(self, pub_data, comments, message, extra={}): # """Modify a microblog node # @param pub_data: a tuple (service, comment node identifier, item identifier) # @param comments: comments node identifier (for main item) or False # @param message: new message # @param extra: dict which option name as key, which can be: # - allow_comments: True to accept an other level of comments, False else (default: False) # - rich: if present, contain rich text in currently selected syntax # """ # profile = session_iface.ISATSession(self.session).profile # if comments: # extra['allow_comments'] = 'True' # return self.sat_host.bridge.updateGroupBlog(pub_data, comments if comments else '', message, extra, profile) # def jsonrpc_sendMblogComment(self, node, text, extra={}): # """ Send microblog message # @param node: url of the comments node # @param text: comment # """ # profile = session_iface.ISATSession(self.session).profile # if node and text: # return self.sat_host.bridge.sendGroupBlogComment(node, text, extra, profile) # else: # raise Exception("Invalid data") # def jsonrpc_getMblogs(self, publisher_jid, item_ids, max_items=C.RSM_MAX_ITEMS): # """Get specified microblogs posted by a contact # @param publisher_jid: jid of the publisher # @param item_ids: list of microblogs items IDs # @return list of microblog data (dict)""" # profile = session_iface.ISATSession(self.session).profile # d = self.asyncBridgeCall("getGroupBlogs", publisher_jid, item_ids, {'max_': unicode(max_items)}, False, profile) # return d # def jsonrpc_getMblogsWithComments(self, publisher_jid, item_ids, max_comments=C.RSM_MAX_COMMENTS): # """Get specified microblogs posted by a contact and their comments # @param publisher_jid: jid of the publisher # @param item_ids: list of microblogs items IDs # @return list of couple (microblog data, list of microblog data)""" # profile = session_iface.ISATSession(self.session).profile # d = self.asyncBridgeCall("getGroupBlogsWithComments", publisher_jid, item_ids, {}, max_comments, profile) # return d # def jsonrpc_getMassiveMblogs(self, publishers_type, publishers, rsm=None): # """Get lasts microblogs posted by several contacts at once # @param publishers_type (unicode): one of "ALL", "GROUP", "JID" # @param publishers (tuple(unicode)): tuple of publishers (empty list for all, list of groups or list of jids) # @param rsm (dict): TODO # @return: dict{unicode: list[dict]) # key: publisher's jid # value: list of microblog data (dict) # """ # profile = session_iface.ISATSession(self.session).profile # if rsm is None: # rsm = {'max_': unicode(C.RSM_MAX_ITEMS)} # d = self.asyncBridgeCall("getMassiveGroupBlogs", publishers_type, publishers, rsm, profile) # self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers, profile) # return d # def jsonrpc_getMblogComments(self, service, node, rsm=None): # """Get all comments of given node # @param service: jid of the service hosting the node # @param node: comments node # """ # profile = session_iface.ISATSession(self.session).profile # if rsm is None: # rsm = {'max_': unicode(C.RSM_MAX_COMMENTS)} # d = self.asyncBridgeCall("getGroupBlogComments", service, node, rsm, profile) # return d def jsonrpc_getPresenceStatuses(self): """Get Presence information for connected contacts""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getPresenceStatuses(profile) def jsonrpc_historyGet(self, from_jid, to_jid, size, between, search=''): """Return history for the from_jid/to_jid couple""" sat_session = session_iface.ISATSession(self.session) profile = sat_session.profile sat_jid = sat_session.jid if not sat_jid: # we keep a session cache for jid to avoir jid spoofing sat_jid = sat_session.jid = jid.JID(self.sat_host.bridge.getParamA("JabberID", "Connection", profile_key=profile)) if jid.JID(from_jid).userhost() != sat_jid.userhost() and jid.JID(to_jid).userhost() != sat_jid.userhost(): log.error(u"Trying to get history from a different jid (given (browser): {}, real (backend): {}), maybe a hack attempt ?".format(from_jid, sat_jid)) return {} d = self.asyncBridgeCall("historyGet", from_jid, to_jid, size, between, search, profile) def show(result_dbus): result = [] for line in result_dbus: #XXX: we have to do this stupid thing because Python D-Bus use its own types instead of standard types # and txJsonRPC doesn't accept D-Bus types, resulting in a empty query uuid, timestamp, from_jid, to_jid, message, subject, mess_type, extra = line result.append((unicode(uuid), float(timestamp), unicode(from_jid), unicode(to_jid), dict(message), dict(subject), unicode(mess_type), dict(extra))) return result d.addCallback(show) return d def jsonrpc_mucJoin(self, room_jid, nick): """Join a Multi-User Chat room @param room_jid (unicode): room JID or empty string to generate a unique name @param nick (unicode): user nick """ profile = session_iface.ISATSession(self.session).profile d = self.asyncBridgeCall("joinMUC", room_jid, nick, {}, profile) return d def jsonrpc_inviteMUC(self, contact_jid, room_jid): """Invite a user to a Multi-User Chat room @param contact_jid (unicode): contact to invite @param room_jid (unicode): room JID or empty string to generate a unique name """ profile = session_iface.ISATSession(self.session).profile room_id = room_jid.split("@")[0] service = room_jid.split("@")[1] self.sat_host.bridge.inviteMUC(contact_jid, service, room_id, {}, profile) def jsonrpc_mucLeave(self, room_jid): """Quit a Multi-User Chat room""" profile = session_iface.ISATSession(self.session).profile try: room_jid = jid.JID(room_jid) except: log.warning('Invalid room jid') return self.sat_host.bridge.mucLeave(room_jid.userhost(), profile) def jsonrpc_mucGetRoomsJoined(self): """Return list of room already joined by user""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.mucGetRoomsJoined(profile) def jsonrpc_mucGetDefaultService(self): """@return: the default MUC""" d = self.asyncBridgeCall("mucGetDefaultService") return d def jsonrpc_launchTarotGame(self, other_players, room_jid=""): """Create a room, invite the other players and start a Tarot game. @param other_players (list[unicode]): JIDs of the players to play with @param room_jid (unicode): room JID or empty string to generate a unique name """ profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.tarotGameLaunch(other_players, room_jid, profile) def jsonrpc_getTarotCardsPaths(self): """Give the path of all the tarot cards""" _join = os.path.join _media_dir = _join(self.sat_host.media_dir, '') return map(lambda x: _join(C.MEDIA_DIR, x[len(_media_dir):]), glob.glob(_join(_media_dir, C.CARDS_DIR, '*_*.png'))) def jsonrpc_tarotGameReady(self, player, referee): """Tell to the server that we are ready to start the game""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.tarotGameReady(player, referee, profile) def jsonrpc_tarotGamePlayCards(self, player_nick, referee, cards): """Tell to the server the cards we want to put on the table""" profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.tarotGamePlayCards(player_nick, referee, cards, profile) def jsonrpc_launchRadioCollective(self, invited, room_jid=""): """Create a room, invite people, and start a radio collective. @param invited (list[unicode]): JIDs of the contacts to play with @param room_jid (unicode): room JID or empty string to generate a unique name """ profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.radiocolLaunch(invited, room_jid, profile) def jsonrpc_getEntitiesData(self, jids, keys): """Get cached data for several entities at once @param jids: list jids from who we wants data, or empty list for all jids in cache @param keys: name of data we want (list) @return: requested data""" if not C.ALLOWED_ENTITY_DATA.issuperset(keys): raise exceptions.PermissionError("Trying to access unallowed data (hack attempt ?)") profile = session_iface.ISATSession(self.session).profile try: return self.sat_host.bridge.getEntitiesData(jids, keys, profile) except Exception as e: raise failure.Failure(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, unicode(e))) def jsonrpc_getEntityData(self, jid, keys): """Get cached data for an entity @param jid: jid of contact from who we want data @param keys: name of data we want (list) @return: requested data""" if not C.ALLOWED_ENTITY_DATA.issuperset(keys): raise exceptions.PermissionError("Trying to access unallowed data (hack attempt ?)") profile = session_iface.ISATSession(self.session).profile try: return self.sat_host.bridge.getEntityData(jid, keys, profile) except Exception as e: raise failure.Failure(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, unicode(e))) def jsonrpc_getCard(self, jid_): """Get VCard for entiry @param jid_: jid of contact from who we want data @return: id to retrieve the profile""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getCard(jid_, profile) @defer.inlineCallbacks def jsonrpc_avatarGet(self, entity, cache_only, hash_only): session_data = session_iface.ISATSession(self.session) profile = session_data.profile # profile_uuid = session_data.uuid avatar = yield self.asyncBridgeCall("avatarGet", entity, cache_only, hash_only, profile) if hash_only: defer.returnValue(avatar) else: filename = os.path.basename(avatar) avatar_url = os.path.join(C.CACHE_DIR, session_data.uuid, filename) defer.returnValue(avatar_url) def jsonrpc_getAccountDialogUI(self): """Get the dialog for managing user account @return: XML string of the XMLUI""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getAccountDialogUI(profile) def jsonrpc_getParamsUI(self): """Return the parameters XML for profile""" profile = session_iface.ISATSession(self.session).profile return self.asyncBridgeCall("getParamsUI", C.SECURITY_LIMIT, C.APP_NAME, profile) def jsonrpc_asyncGetParamA(self, param, category, attribute="value"): """Return the parameter value for profile""" profile = session_iface.ISATSession(self.session).profile if category == "Connection": # we need to manage the followings params here, else SECURITY_LIMIT would block them if param == "JabberID": return self.asyncBridgeCall("asyncGetParamA", param, category, attribute, profile_key=profile) elif param == "autoconnect": return defer.succeed(C.BOOL_TRUE) d = self.asyncBridgeCall("asyncGetParamA", param, category, attribute, C.SECURITY_LIMIT, profile_key=profile) return d def jsonrpc_setParam(self, name, value, category): profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.setParam(name, value, category, C.SECURITY_LIMIT, profile) def jsonrpc_launchAction(self, callback_id, data): #FIXME: any action can be launched, this can be a huge security issue if callback_id can be guessed # a security system with authorised callback_id must be implemented, similar to the one for authorised params profile = session_iface.ISATSession(self.session).profile d = self.asyncBridgeCall("launchAction", callback_id, data, profile) return d def jsonrpc_chatStateComposing(self, to_jid_s): """Call the method to process a "composing" state. @param to_jid_s: contact the user is composing to """ profile = session_iface.ISATSession(self.session).profile self.sat_host.bridge.chatStateComposing(to_jid_s, profile) def jsonrpc_getNewAccountDomain(self): """@return: the domain for new account creation""" d = self.asyncBridgeCall("getNewAccountDomain") return d def jsonrpc_syntaxConvert(self, text, syntax_from=C.SYNTAX_XHTML, syntax_to=C.SYNTAX_CURRENT): """ Convert a text between two syntaxes @param text: text to convert @param syntax_from: source syntax (e.g. "markdown") @param syntax_to: dest syntax (e.g.: "XHTML") @param safe: clean resulting XHTML to avoid malicious code if True (forced here) @return: converted text """ profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.syntaxConvert(text, syntax_from, syntax_to, True, profile) def jsonrpc_getLastResource(self, jid_s): """Get the last active resource of that contact.""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getLastResource(jid_s, profile) def jsonrpc_getFeatures(self): """Return the available features in the backend for profile""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.getFeatures(profile) def jsonrpc_skipOTR(self): """Tell the backend to leave OTR handling to Libervia.""" profile = session_iface.ISATSession(self.session).profile return self.sat_host.bridge.skipOTR(profile) class WaitingRequests(dict): def setRequest(self, request, profile, register_with_ext_jid=False): """Add the given profile to the waiting list. @param request (server.Request): the connection request @param profile (str): %(doc_profile)s @param register_with_ext_jid (bool): True if we will try to register the profile with an external XMPP account credentials """ dc = reactor.callLater(BRIDGE_TIMEOUT, self.purgeRequest, profile) self[profile] = (request, dc, register_with_ext_jid) def purgeRequest(self, profile): """Remove the given profile from the waiting list. @param profile (str): %(doc_profile)s """ try: dc = self[profile][1] except KeyError: return if dc.active(): dc.cancel() del self[profile] def getRequest(self, profile): """Get the waiting request for the given profile. @param profile (str): %(doc_profile)s @return: the waiting request or None """ return self[profile][0] if profile in self else None def getRegisterWithExtJid(self, profile): """Get the value of the register_with_ext_jid parameter. @param profile (str): %(doc_profile)s @return: bool or None """ return self[profile][2] if profile in self else None class Register(JSONRPCMethodManager): """This class manage the registration procedure with SàT It provide an api for the browser, check password and setup the web server""" def __init__(self, sat_host): JSONRPCMethodManager.__init__(self, sat_host) self.profiles_waiting = {} self.request = None def render(self, request): """ Render method with some hacks: - if login is requested, try to login with form data - except login, every method is jsonrpc - user doesn't need to be authentified for explicitely listed methods, but must be for all others """ if request.postpath == ['login']: return self.loginOrRegister(request) _session = request.getSession() parsed = jsonrpclib.loads(request.content.read()) method = parsed.get("method") # pylint: disable=E1103 if method not in ['getSessionMetadata', 'registerParams', 'menusGet']: #if we don't call these methods, we need to be identified profile = session_iface.ISATSession(_session).profile if not profile: #user is not identified, we return a jsonrpc fault fault = jsonrpclib.Fault(C.ERRNUM_LIBERVIA, C.NOT_ALLOWED) # FIXME: define some standard error codes for libervia return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) # pylint: disable=E1103 self.request = request return jsonrpc.JSONRPC.render(self, request) def loginOrRegister(self, request): """This method is called with the POST information from the registering form. @param request: request of the register form @return: a constant indicating the state: - C.BAD_REQUEST: something is wrong in the request (bad arguments) - a return value from self._loginAccount or self._registerNewAccount """ try: submit_type = request.args['submit_type'][0] except KeyError: return C.BAD_REQUEST if submit_type == 'register': self._registerNewAccount(request) return server.NOT_DONE_YET elif submit_type == 'login': self._loginAccount(request) return server.NOT_DONE_YET return Exception('Unknown submit type') @defer.inlineCallbacks def _registerNewAccount(self, request): try: login = request.args['register_login'][0] password = request.args['register_password'][0] email = request.args['email'][0] except KeyError: request.write(C.BAD_REQUEST) request.finish() return status = yield self.sat_host.registerNewAccount(request, login, password, email) request.write(status) request.finish() @defer.inlineCallbacks def _loginAccount(self, request): """Try to authenticate the user with the request information. will write to request a constant indicating the state: - C.PROFILE_LOGGED: profile is connected - C.PROFILE_LOGGED_EXT_JID: profile is connected and an external jid has been used - C.SESSION_ACTIVE: session was already active - C.BAD_REQUEST: something is wrong in the request (bad arguments) - C.PROFILE_AUTH_ERROR: either the profile (login) or the profile password is wrong - C.XMPP_AUTH_ERROR: the profile is authenticated but the XMPP password is wrong - C.ALREADY_WAITING: a request has already been submitted for this profil, C.PROFILE_LOGGED_EXT_JID)e - C.NOT_CONNECTED: connection has not been established the request will then be finished @param request: request of the register form """ try: login = request.args['login'][0] password = request.args['login_password'][0] except KeyError: request.write(C.BAD_REQUEST) request.finish() return assert login try: status = yield self.sat_host.connect(request, login, password) except (exceptions.DataError, exceptions.ProfileUnknownError, exceptions.PermissionError): request.write(C.PROFILE_AUTH_ERROR) request.finish() return except exceptions.NotReady: request.write(C.ALREADY_WAITING) request.finish() return except exceptions.TimeOutError: request.write(C.NO_REPLY) request.finish() return except exceptions.InternalError as e: request.write(e.message) request.finish() return except exceptions.ConflictError: request.write(C.SESSION_ACTIVE) request.finish() return except ValueError as e: if e.message in (C.PROFILE_AUTH_ERROR, C.XMPP_AUTH_ERROR): request.write(e.message) request.finish() return else: raise e assert status request.write(status) request.finish() def jsonrpc_isConnected(self): _session = self.request.getSession() profile = session_iface.ISATSession(_session).profile return self.sat_host.bridge.isConnected(profile) def jsonrpc_connect(self): _session = self.request.getSession() profile = session_iface.ISATSession(_session).profile if self.waiting_profiles.getRequest(profile): raise jsonrpclib.Fault(1, C.ALREADY_WAITING) # FIXME: define some standard error codes for libervia self.waiting_profiles.setRequest(self.request, profile) self.sat_host.bridge.connect(profile) return server.NOT_DONE_YET def jsonrpc_getSessionMetadata(self): """Return metadata useful on session start @return (dict): metadata which can have the following keys: "plugged" (bool): True if a profile is already plugged "warning" (unicode): a security warning message if plugged is False and if it make sense this key may not be present "allow_registration" (bool): True if registration is allowed this key is only present if profile is unplugged @return: a couple (registered, message) with: - registered: - message: """ metadata = {} _session = self.request.getSession() profile = session_iface.ISATSession(_session).profile if profile: metadata["plugged"] = True else: metadata["plugged"] = False metadata["warning"] = self._getSecurityWarning() metadata["allow_registration"] = self.sat_host.options["allow_registration"] return metadata def jsonrpc_registerParams(self): """Register the frontend specific parameters""" # params = """<params><individual>...</category></individual>""" # self.sat_host.bridge.paramsRegisterApp(params, C.SECURITY_LIMIT, C.APP_NAME) def jsonrpc_menusGet(self): """Return the parameters XML for profile""" # XXX: we put this method in Register because we get menus before being logged return self.sat_host.bridge.menusGet('', C.SECURITY_LIMIT) def _getSecurityWarning(self): """@return: a security warning message, or None if the connection is secure""" if self.request.URLPath().scheme == 'https' or not self.sat_host.options['security_warning']: return None text = "<p>" + D_("You are about to connect to an unsecure service.") + "</p><p> </p><p>" if self.sat_host.options['connection_type'] == 'both': new_port = (':%s' % self.sat_host.options['port_https_ext']) if self.sat_host.options['port_https_ext'] != HTTPS_PORT else '' url = "https://%s" % self.request.URLPath().netloc.replace(':%s' % self.sat_host.options['port'], new_port) text += D_('Please read our %(faq_prefix)ssecurity notice%(faq_suffix)s regarding HTTPS') % {'faq_prefix': '<a href="http://salut-a-toi.org/faq.html#https" target="#">', 'faq_suffix': '</a>'} text += "</p><p>" + D_('and use the secure version of this website:') text += '</p><p> </p><p align="center"><a href="%(url)s">%(url)s</a>' % {'url': url} else: text += D_('You should ask your administrator to turn on HTTPS.') return text + "</p><p> </p>" class SignalHandler(jsonrpc.JSONRPC): def __init__(self, sat_host): web_resource.Resource.__init__(self) self.register = None self.sat_host = sat_host self.signalDeferred = {} # dict of deferred (key: profile, value: Deferred) # which manages the long polling HTTP request with signals self.queue = {} def plugRegister(self, register): self.register = register def jsonrpc_getSignals(self): """Keep the connection alive until a signal is received, then send it @return: (signal, *signal_args)""" _session = self.request.getSession() profile = session_iface.ISATSession(_session).profile if profile in self.queue: # if we have signals to send in queue if self.queue[profile]: return self.queue[profile].pop(0) else: #the queue is empty, we delete the profile from queue del self.queue[profile] _session.lock() # we don't want the session to expire as long as this connection is active def unlock(signal, profile): _session.unlock() try: source_defer = self.signalDeferred[profile] if source_defer.called and source_defer.result[0] == "disconnected": log.info(u"[%s] disconnected" % (profile,)) try: _session.expire() except KeyError: # FIXME: happen if session is ended using login page # when pyjamas page is also launched log.warning(u'session is already expired') except IndexError: log.error("Deferred result should be a tuple with fonction name first") self.signalDeferred[profile] = defer.Deferred() self.request.notifyFinish().addBoth(unlock, profile) return self.signalDeferred[profile] def getGenericCb(self, function_name): """Return a generic function which send all params to signalDeferred.callback function must have profile as last argument""" def genericCb(*args): profile = args[-1] if not profile in self.sat_host.prof_connected: return signal_data = (function_name, args[:-1]) try: signal_callback = self.signalDeferred[profile].callback except KeyError: self.queue.setdefault(profile,[]).append(signal_data) else: signal_callback(signal_data) del self.signalDeferred[profile] return genericCb def actionNewHandler(self, action_data, action_id, security_limit, profile): """actionNew handler XXX: We need need a dedicated handler has actionNew use a security_limit which must be managed @param action_data(dict): see bridge documentation @param action_id(unicode): identitifer of the action @param security_limit(int): %(doc_security_limit)s @param profile(unicode): %(doc_profile)s """ if not profile in self.sat_host.prof_connected: return # FIXME: manage security limit in a dedicated method # raise an exception if it's not OK # and read value in sat.conf if security_limit >= C.SECURITY_LIMIT: log.debug(u"Ignoring action {action_id}, blocked by security limit".format(action_id=action_id)) return signal_data = ("actionNew", (action_data, action_id, security_limit)) try: signal_callback = self.signalDeferred[profile].callback except KeyError: self.queue.setdefault(profile,[]).append(signal_data) else: signal_callback(signal_data) del self.signalDeferred[profile] def connected(self, profile, jid_s): """Connection is done. @param profile (unicode): %(doc_profile)s @param jid_s (unicode): the JID that we were assigned by the server, as the resource might differ from the JID we asked for. """ # FIXME: _logged should not be called from here, check this code # FIXME: check if needed to connect with external jid # jid_s is handled in QuickApp.connectionHandler already # assert self.register # register must be plugged # request = self.sat_host.waiting_profiles.getRequest(profile) # if request: # self.sat_host._logged(profile, request) def disconnected(self, profile): if not profile in self.sat_host.prof_connected: log.error("'disconnected' signal received for a not connected profile") return self.sat_host.prof_connected.remove(profile) if profile in self.signalDeferred: self.signalDeferred[profile].callback(("disconnected",)) del self.signalDeferred[profile] else: if profile not in self.queue: self.queue[profile] = [] self.queue[profile].append(("disconnected",)) def render(self, request): """ Render method wich reject access if user is not identified """ _session = request.getSession() parsed = jsonrpclib.loads(request.content.read()) profile = session_iface.ISATSession(_session).profile if not profile: #user is not identified, we return a jsonrpc fault fault = jsonrpclib.Fault(C.ERRNUM_LIBERVIA, C.NOT_ALLOWED) # FIXME: define some standard error codes for libervia return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) # pylint: disable=E1103 self.request = request return jsonrpc.JSONRPC.render(self, request) class UploadManager(web_resource.Resource): """This class manage the upload of a file It redirect the stream to SàT core backend""" isLeaf = True NAME = 'path' # name use by the FileUpload def __init__(self, sat_host): self.sat_host = sat_host self.upload_dir = tempfile.mkdtemp() self.sat_host.addCleanup(shutil.rmtree, self.upload_dir) def getTmpDir(self): return self.upload_dir def _getFileName(self, request): """Generate unique filename for a file""" raise NotImplementedError def _fileWritten(self, request, filepath): """Called once the file is actually written on disk @param request: HTTP request object @param filepath: full filepath on the server @return: a tuple with the name of the async bridge method to be called followed by its arguments. """ raise NotImplementedError def render(self, request): """ Render method with some hacks: - if login is requested, try to login with form data - except login, every method is jsonrpc - user doesn't need to be authentified for getSessionMetadata, but must be for all other methods """ filename = self._getFileName(request) filepath = os.path.join(self.upload_dir, filename) #FIXME: the uploaded file is fully loaded in memory at form parsing time so far # (see twisted.web.http.Request.requestReceived). A custom requestReceived should # be written in the futur. In addition, it is not yet possible to get progression informations # (see http://twistedmatrix.com/trac/ticket/288) with open(filepath, 'w') as f: f.write(request.args[self.NAME][0]) def finish(d): error = isinstance(d, Exception) or isinstance(d, failure.Failure) request.write(C.UPLOAD_KO if error else C.UPLOAD_OK) # TODO: would be great to re-use the original Exception class and message # but it is lost in the middle of the backtrace and encapsulated within # a DBusException instance --> extract the data from the backtrace? request.finish() d = JSONRPCMethodManager(self.sat_host).asyncBridgeCall(*self._fileWritten(request, filepath)) d.addCallbacks(lambda d: finish(d), lambda failure: finish(failure)) return server.NOT_DONE_YET class UploadManagerRadioCol(UploadManager): NAME = 'song' def _getFileName(self, request): extension = os.path.splitext(request.args['filename'][0])[1] return "%s%s" % (str(uuid.uuid4()), extension) # XXX: chromium doesn't seem to play song without the .ogg extension, even with audio/ogg mime-type def _fileWritten(self, request, filepath): """Called once the file is actually written on disk @param request: HTTP request object @param filepath: full filepath on the server @return: a tuple with the name of the async bridge method to be called followed by its arguments. """ profile = session_iface.ISATSession(request.getSession()).profile return ("radiocolSongAdded", request.args['referee'][0], filepath, profile) class UploadManagerAvatar(UploadManager): NAME = 'avatar_path' def _getFileName(self, request): return str(uuid.uuid4()) def _fileWritten(self, request, filepath): """Called once the file is actually written on disk @param request: HTTP request object @param filepath: full filepath on the server @return: a tuple with the name of the async bridge method to be called followed by its arguments. """ profile = session_iface.ISATSession(request.getSession()).profile return ("setAvatar", filepath, profile) class LiberviaPage(web_resource.Resource): isLeaf = True # we handle subpages ourself named_pages = {} uri_callbacks = {} pages_redirects = {} def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, parse_url=None, prepare_render=None, render=None, template=None, on_data_post=None): """initiate LiberviaPages LiberviaPages are the main resources of Libervia, using easy to set python files The arguments are the variables found in page_meta.py @param host(Libervia): the running instance of Libervia @param root_dir(unicode): aboslute file path of the page @param url(unicode): relative URL to the page this URL may not be valid, as pages may require path arguments @param name(unicode, None): if not None, a unique name to identify the page can then be used for e.g. redirection "/" is not allowed in names (as it can be used to construct URL paths) @param redirect(unicode, None): if not None, this page will be redirected. A redirected parameter is used as in self.pageRedirect. parse_url will not be skipped using this redirect parameter is called "full redirection" using self.pageRedirect is called "partial redirection" (because some rendering method can still be used, e.g. parse_url) @param access(unicode, None): permission needed to access the page None means public access. Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins, and if "settings/blog" is public, it still can only be accessed by admins. see C.PAGES_ACCESS_* for details @param parse_url(callable, None): if set it will be called to handle the URL path after this method, the page will be rendered if noting is left in path (request.postpath) else a the request will be transmitted to a subpage @param prepare_render(callable, None): if set, will be used to prepare the rendering that often means gathering data using the bridge @param render(callable, None): if not template is set, this method will be called and what it returns will be rendered. This method is mutually exclusive with template and must return a unicode string. @param template(unicode, None): path to the template to render. This method is mutually exclusive with render @param on_data_post(callable, None): method to call when data is posted None if not post is handled on_data_post can return a string with following value: - C.POST_NO_CONFIRM: confirm flag will not be set """ web_resource.Resource.__init__(self) self.host = host self.root_dir = root_dir self.url = url self.name = name if name is not None: if name in self.named_pages: raise exceptions.ConflictError(_(u'a Libervia page named "{}" already exists'.format(name))) if u'/' in name: raise ValueError(_(u'"/" is not allowed in page names')) if not name: raise ValueError(_(u"a page name can't be empty")) self.named_pages[name] = self if access is None: access = C.PAGES_ACCESS_PUBLIC if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE): raise NotImplementedError(_(u"{} access is not implemented yet").format(access)) self.access = access if redirect is not None: # only page access and name make sense in case of full redirection # so we check that rendering methods/values are not set if not all(lambda x: x is not None for x in (parse_url, prepare_render, render, template)): raise ValueError(_(u"you can't use full page redirection with other rendering method," u"check self.pageRedirect if you need to use them")) self.redirect = redirect else: self.redirect = None self.parse_url = parse_url self.prepare_render = prepare_render self.template = template self.render_method = render self.on_data_post = on_data_post if access == C.PAGES_ACCESS_NONE: # none pages just return a 404, no further check is needed return if template is None: if not callable(render): log.error(_(u"render must be implemented and callable if template is not set")) else: if render is not None: log.error(_(u"render can't be used at the same time as template")) if parse_url is not None and not callable(parse_url): log.error(_(u"parse_url must be a callable")) @classmethod def importPages(cls, host, parent=None, path=None): """Recursively import Libervia pages""" if path is None: path = [] if parent is None: root_dir = os.path.join(os.path.dirname(libervia.__file__), C.PAGES_DIR) parent = host else: root_dir = parent.root_dir for d in os.listdir(root_dir): dir_path = os.path.join(root_dir, d) if not os.path.isdir(dir_path): continue meta_path = os.path.join(dir_path, C.PAGES_META_FILE) if os.path.isfile(meta_path): page_data = {} new_path = path + [d] # we don't want to force the presence of __init__.py # so we use execfile instead of import. # TODO: when moved to Python 3, __init__.py is not mandatory anymore # so we can switch to import execfile(meta_path, page_data) resource = LiberviaPage( host, dir_path, u'/' + u'/'.join(new_path), name=page_data.get('name'), redirect=page_data.get('redirect'), access=page_data.get('access'), parse_url=page_data.get('parse_url'), prepare_render=page_data.get('prepare_render'), render=page_data.get('render'), template=page_data.get('template'), on_data_post=page_data.get('on_data_post')) parent.putChild(d, resource) log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path))) if 'uri_handlers' in page_data: if not isinstance(page_data, dict): log.error(_(u'uri_handlers must be a dict')) else: for uri_tuple, cb_name in page_data['uri_handlers'].iteritems(): if len(uri_tuple) != 2 or not isinstance(cb_name, basestring): log.error(_(u"invalid uri_tuple")) continue log.info(_(u'setting {}/{} URIs handler').format(*uri_tuple)) try: cb = page_data[cb_name] except KeyError: log.error(_(u'missing {name} method to handle {1}/{2}').format( name = cb_name, *uri_tuple)) continue else: cls.registerURI(uri_tuple, cb, new_path) LiberviaPage.importPages(host, resource, new_path) @classmethod def registerURI(cls, uri_tuple, get_uri_cb, pre_path): """register a URI handler @param uri_tuple(tuple[unicode, unicode]): type or URIs handler type/subtype as returned by tools/common/parseXMPPUri @param get_uri_cb(callable): method which take uri_data dict as only argument and return path with correct arguments relative to page itself @param pre_path(list[unicode]): prefix path to reference the handler page """ if uri_tuple in cls.uri_callbacks: log.info(_(u"{}/{} URIs are already handled, replacing by the new handler").format(*uri_tuple)) cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb, u'pre_path': pre_path} def getPagePathFromURI(self, uri): """Retrieve page URL from xmpp: URI @param uri(unicode): URI with a xmpp: scheme @return (unicode,None): absolute path (starting from root "/") to page handling the URI None is returned if not page has been registered for this URI """ uri_data = common_uri.parseXMPPUri(uri) try: callback_data = self.uri_callbacks[uri_data['type'], uri_data.get('sub_type')] except KeyError: return else: url = os.path.join(u'/', u'/'.join(callback_data['pre_path']), callback_data['callback'](self, uri_data)) return url @classmethod def getPageByName(cls, name): """retrieve page instance from its name @param name(unicode): name of the page @return (LiberviaPage): page instance @raise KeyError: the page doesn't exist """ return cls.named_pages[name] def getPageRedirectURL(self, request, page_name=u'login', url=None): """generate URL for a page with redirect_url parameter set mainly used for login page with redirection to current page @param request(server.Request): current HTTP request @param page_name(unicode): name of the page to go @param url(None, unicode): url to redirect to None to use request path (i.e. current page) @return (unicode): URL to use """ return u'{root_url}?redirect_url={redirect_url}'.format( root_url = self.getPageByName(page_name).url, redirect_url=urllib.quote_plus(request.uri) if url is None else url.encode('utf-8')) def getURL(self, *args): """retrieve URL of the page set arguments *args(list[unicode]): argument to add to the URL as path elements """ url_args = [quote(a) for a in args] if self.name is not None and self.name in self.pages_redirects: # we check for redirection redirect_data = self.pages_redirects[self.name] args_hash = tuple(args) for limit in xrange(len(args)+1): current_hash = args_hash[:limit] if current_hash in redirect_data: url_base = redirect_data[current_hash] remaining = args[limit:] remaining_url = '/'.join(remaining) return os.path.join('/', url_base, remaining_url) return os.path.join(self.url, *url_args) def getSubPageURL(self, request, page_name, *args): """retrieve a page in direct children and build its URL according to request request's current path is used as base (at current parsing point, i.e. it's more prepath than path). Requested page is checked in children and an absolute URL is then built by the resulting combination. This method is useful to construct absolute URLs for children instead of using relative path, which may not work in subpages, and are linked to the names of directories (i.e. relative URL will break if subdirectory is renamed while getSubPageURL won't as long as page_name is consistent). Also, request.path is used, keeping real path used by user, and potential redirections. @param request(server.Request): current HTTP request @param page_name(unicode): name of the page to retrieve it must be a direct children of current page @param *args(list[unicode]): arguments to add as path elements @return unicode: absolute URL to the sub page """ # we get url in the following way (splitting request.path instead of using # request.prepath) because request.prepath may have been modified by # redirection (if redirection args have been specified), while path reflect # the real request # we ignore empty path elements (i.e. double '/' or '/' at the end) path_elts = [p for p in request.path.split('/') if p] if request.postpath: if not request.postpath[-1]: # we remove trailing slash request.postpath = request.postpath[:-1] if request.postpath: # getSubPageURL must return subpage from the point where # the it is called, so we have to remove remanining # path elements path_elts = path_elts[:-len(request.postpath)] current_url = '/' + '/'.join(path_elts).decode('utf-8') for path, child in self.children.iteritems(): try: child_name = child.name except AttributeError: # LiberviaPage have a name, but maybe this is an other Resource continue if child_name == page_name: return os.path.join(u'/', current_url, path, *args) raise exceptions.NotFound(_(u'requested sub page has not been found')) def getChildWithDefault(self, path, request): # we handle children ourselves raise exceptions.InternalError(u"this method should not be used with LiberviaPage") def nextPath(self, request): """get next URL path segment, and update request accordingly will move first segment of postpath in prepath @param request(server.Request): current HTTP request @return (unicode): unquoted segment @raise IndexError: there is no segment left """ pathElement = request.postpath.pop(0) request.prepath.append(pathElement) return urllib.unquote(pathElement).decode('utf-8') def HTTPRedirect(self, request, url): """redirect to an URL using HTTP redirection @param request(server.Request): current HTTP request @param url(unicode): url to redirect to """ web_util.redirectTo(url.encode('utf-8'), request) request.finish() raise failure.Failure(exceptions.CancelError(u'HTTP redirection is used')) def redirectOrContinue(self, request, redirect_arg=u'redirect_url'): """helper method to redirect a page to an url given as arg if the arg is not present, the page will continue normal workflow @param request(server.Request): current HTTP request @param redirect_arg(unicode): argument to use to get redirection URL @interrupt: redirect the page to requested URL @interrupt pageError(C.HTTP_BAD_REQUEST): empty or non local URL is used """ try: url = self.getPostedData(request, 'redirect_url') except KeyError: pass else: # a redirection is requested if not url or url[0] != u'/': # we only want local urls self.pageError(request, C.HTTP_BAD_REQUEST) else: self.HTTPRedirect(request, url) def pageRedirect(self, page_path, request, skip_parse_url=True): """redirect a page to a named page the workflow will continue with the workflow of the named page, skipping named page's parse_url method if it exist. If you want to do a HTTP redirection, use HTTPRedirect @param page_path(unicode): path to page (elements are separated by "/"): if path starts with a "/": path is a full path starting from root else: - first element is name as registered in name variable - following element are subpages path e.g.: "blog" redirect to page named "blog" "blog/atom.xml" redirect to atom.xml subpage of "blog" "/common/blog/atom.xml" redirect to the page at the fiven full path @param request(server.Request): current HTTP request @param skip_parse_url(bool): if True, parse_url method on redirect page will be skipped @raise KeyError: there is no known page with this name """ # FIXME: render non LiberviaPage resources path = page_path.rstrip(u'/').split(u'/') if not path[0]: redirect_page = self.host.root else: redirect_page = self.named_pages[path[0]] for subpage in path[1:]: if redirect_page is self.host.root: redirect_page = redirect_page.children[subpage] else: redirect_page = redirect_page.original.children[subpage] redirect_page.renderPage(request, skip_parse_url=True) raise failure.Failure(exceptions.CancelError(u'page redirection is used')) def pageError(self, request, code=C.HTTP_NOT_FOUND): """generate an error page and terminate the request @param request(server.Request): HTTP request @param core(int): error code to use """ template = u'error/' + unicode(code) + '.html' request.setResponseCode(code) rendered = self.host.renderer.render( template, root_path = '/templates/', error_code = code, **request.template_data) self.writeData(rendered, request) raise failure.Failure(exceptions.CancelError(u'error page is used')) def writeData(self, data, request): """write data to transport and finish the request""" if data is None: self.pageError(request) request.write(data.encode('utf-8')) request.finish() def _subpagesHandler(self, dummy, request): """render subpage if suitable this method checks if there is still an unmanaged part of the path and check if it corresponds to a subpage. If so, it render the subpage else it render a NoResource. If there is no unmanaged part of the segment, current page workflow is pursued """ if request.postpath: subpage = self.nextPath(request) try: child = self.children[subpage] except KeyError: self.pageError(request) else: child.render(request) raise failure.Failure(exceptions.CancelError(u'subpage page is used')) def _prepare_render(self, dummy, request): return defer.maybeDeferred(self.prepare_render, self, request) def _render_method(self, dummy, request): return defer.maybeDeferred(self.render_method, self, request) def _render_template(self, dummy, request): template_data = request.template_data # if confirm variable is set in case of successfuly data post session_data = self.host.getSessionData(request, session_iface.ISATSession) if session_data.popPageFlag(self, C.FLAG_CONFIRM): template_data[u'confirm'] = True return self.host.renderer.render( self.template, root_path = '/templates/', media_path = '/' + C.MEDIA_DIR, **template_data) def _renderEb(self, failure_, request): """don't raise error on CancelError""" failure_.trap(exceptions.CancelError) def _internalError(self, failure_, request): """called if an error is not catched""" log.error(_(u"Uncatched error for HTTP request on {url}: {msg}").format( url = request.URLPath(), msg = failure_)) self.pageError(request, C.HTTP_INTERNAL_ERROR) def _on_data_post_redirect(self, ret, request): """called when page's on_data_post has been done successfuly This will do a Post/Redirect/Get pattern. this method redirect to the same page or to request.data['post_redirect_page'] post_redirect_page can be either a page or a tuple with page as first item, then a list of unicode arguments to append to the url. if post_redirect_page is not used, initial request.uri (i.e. the same page as where the data have been posted) will be used for redirection. HTTP status code "See Other" (303) is used as it is the recommanded code in this case. @param ret(None, unicode, iterable): on_data_post return value see LiberviaPage.__init__ on_data_post docstring """ if ret is None: ret = () elif isinstance(ret, basestring): ret = (ret,) else: ret = tuple(ret) raise NotImplementedError(_(u'iterable in on_data_post return value is not used yet')) session_data = self.host.getSessionData(request, session_iface.ISATSession) request_data = self.getRData(request) if 'post_redirect_page' in request_data: redirect_page_data = request_data['post_redirect_page'] if isinstance(redirect_page_data, tuple): redirect_page = redirect_page_data[0] redirect_page_args = redirect_page_data[1:] redirect_uri = redirect_page.getURL(*redirect_page_args) else: redirect_page = redirect_page_data redirect_uri = redirect_page.url else: redirect_page = self redirect_uri = request.uri if not C.POST_NO_CONFIRM in ret: session_data.setPageFlag(redirect_page, C.FLAG_CONFIRM) request.setResponseCode(C.HTTP_SEE_OTHER) request.setHeader("location", redirect_uri) request.finish() raise failure.Failure(exceptions.CancelError(u'Post/Redirect/Get is used')) def _on_data_post(self, dummy, request): csrf_token = self.host.getSessionData(request, session_iface.ISATSession).csrf_token try: given_csrf = self.getPostedData(request, u'csrf_token') except KeyError: given_csrf = None if given_csrf is None or given_csrf != csrf_token: log.warning(_(u"invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format( url=request.uri, ip=request.getClientIP())) self.pageError(request, C.HTTP_UNAUTHORIZED) d = defer.maybeDeferred(self.on_data_post, self, request) d.addCallback(self._on_data_post_redirect, request) return d def getPostedData(self, request, keys, multiple=False): """get data from a POST request and decode it @param request(server.Request): request linked to the session @param keys(unicode, iterable[unicode]): name of the value(s) to get unicode to get one value iterable to get more than one @param multiple(bool): True if multiple values are possible/expected if False, the first value is returned @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): values received for this(these) key(s) @raise KeyError: one specific key has been requested, and it is missing """ if isinstance(keys, basestring): keys = [keys] get_first = True else: get_first = False ret = [] for key in keys: gen = (urllib.unquote(v).decode('utf-8') for v in request.args.get(key,[])) if multiple: ret.append(gen) else: try: ret.append(next(gen)) except StopIteration: raise KeyError(key) return ret[0] if get_first else ret def getAllPostedData(self, request, except_=()): """get all posted data @param request(server.Request): request linked to the session @param except_(iterable[unicode]): key of values to ignore csrf_token will always be ignored @return (dict[unicode, list[unicode]]): post values """ except_ = tuple(except_) + (u'csrf_token',) ret = {} for key, values in request.args.iteritems(): key = urllib.unquote(key).decode('utf-8') if key in except_: continue ret[key] = [urllib.unquote(v).decode('utf-8') for v in values] return ret def getProfile(self, request): """helper method to easily get current profile @return (unicode, None): current profile None if no profile session is started """ sat_session = self.host.getSessionData(request, session_iface.ISATSession) return sat_session.profile def getRData(self, request): """helper method to get request data dict this dictionnary if for the request only, it is not saved in session It is mainly used to pass data between pages/methods called during request workflow @return (dict): request data """ try: return request.data except AttributeError: request.data = {} return request.data def _checkAccess(self, data, request): """Check access according to self.access if access is not granted, show a HTTP_UNAUTHORIZED pageError and stop request, else return data (so it can be inserted in deferred chain """ if self.access == C.PAGES_ACCESS_PUBLIC: pass elif self.access == C.PAGES_ACCESS_PROFILE: profile = self.getProfile(request) if not profile: # no session started if not self.host.options["allow_registration"]: # registration not allowed, access is not granted self.pageError(request, C.HTTP_UNAUTHORIZED) else: # registration allowed, we redirect to login page login_url = self.getPageRedirectURL(request) self.HTTPRedirect(request, login_url) return data def renderPage(self, request, skip_parse_url=False): """Main method to handle the workflow of a LiberviaPage""" # template_data are the variables passed to template if not hasattr(request, 'template_data'): session_data = self.host.getSessionData(request, session_iface.ISATSession) csrf_token = session_data.csrf_token request.template_data = {u'csrf_token': csrf_token} # XXX: here is the code which need to be executed once # at the beginning of the request hanling if request.postpath and not request.postpath[-1]: # we don't differenciate URLs finishing with '/' or not del request.postpath[-1] d = defer.Deferred() d.addCallback(self._checkAccess, request) if self.redirect is not None: self.pageRedirect(self.redirect, request, skip_parse_url=False) if self.parse_url is not None and not skip_parse_url: d.addCallback(self.parse_url, request) d.addCallback(self._subpagesHandler, request) if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST): # only HTTP GET and POST are handled so far d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) if request.method == C.HTTP_METHOD_POST: if self.on_data_post is None: # if we don't have on_data_post, the page was not expecting POST # so we return an error d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) else: d.addCallback(self._on_data_post, request) # by default, POST follow normal behaviour after on_data_post is called # this can be changed by a redirection or other method call in on_data_post if self.prepare_render: d.addCallback(self._prepare_render, request) if self.template: d.addCallback(self._render_template, request) elif self.render_method: d.addCallback(self._render_method, request) d.addCallback(self.writeData, request) d.addErrback(self._renderEb, request) d.addErrback(self._internalError, request) d.callback(self) return server.NOT_DONE_YET def render_GET(self, request): return self.renderPage(request) def render_POST(self, request): return self.renderPage(request) class Libervia(service.Service): def __init__(self, options): self.options = options self.initialised = defer.Deferred() self.waiting_profiles = WaitingRequests() # FIXME: should be removed if self.options['base_url_ext']: self.base_url_ext = self.options.pop('base_url_ext') if self.base_url_ext[-1] != '/': self.base_url_ext += '/' self.base_url_ext_data = urlparse.urlsplit(self.base_url_ext) else: self.base_url_ext = None # we split empty string anyway so we can do things like # scheme = self.base_url_ext_data.scheme or 'https' self.base_url_ext_data = urlparse.urlsplit('') if not self.options['port_https_ext']: self.options['port_https_ext'] = self.options['port_https'] if self.options['data_dir'] == DATA_DIR_DEFAULT: coerceDataDir(self.options['data_dir']) # this is not done when using the default value self.html_dir = os.path.join(self.options['data_dir'], C.HTML_DIR) self.themes_dir = os.path.join(self.options['data_dir'], C.THEMES_DIR) self._cleanup = [] self.signal_handler = SignalHandler(self) self.sessions = {} # key = session value = user self.prof_connected = set() # Profiles connected ## bridge ## try: self.bridge = Bridge() except BridgeExceptionNoService: print(u"Can't connect to SàT backend, are you sure it's launched ?") sys.exit(1) self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb) def backendReady(self, dummy): self.root = root = LiberviaRootResource(self.options, self.html_dir) _register = Register(self) _upload_radiocol = UploadManagerRadioCol(self) _upload_avatar = UploadManagerAvatar(self) self.signal_handler.plugRegister(_register) self.bridge.register_signal("connected", self.signal_handler.connected) self.bridge.register_signal("disconnected", self.signal_handler.disconnected) #core for signal_name in ['presenceUpdate', 'messageNew', 'subscribe', 'contactDeleted', 'newContact', 'entityDataUpdated', 'paramUpdate']: self.bridge.register_signal(signal_name, self.signal_handler.getGenericCb(signal_name)) # XXX: actionNew is handled separately because the handler must manage security_limit self.bridge.register_signal('actionNew', self.signal_handler.actionNewHandler) #plugins for signal_name in ['psEvent', 'mucRoomJoined', 'tarotGameStarted', 'tarotGameNew', 'tarotGameChooseContrat', 'tarotGameShowCards', 'tarotGameInvalidCards', 'tarotGameCardsPlayed', 'tarotGameYourTurn', 'tarotGameScore', 'tarotGamePlayers', 'radiocolStarted', 'radiocolPreload', 'radiocolPlay', 'radiocolNoUpload', 'radiocolUploadOk', 'radiocolSongRejected', 'radiocolPlayers', 'mucRoomLeft', 'mucRoomUserChangedNick', 'chatStateReceived']: self.bridge.register_signal(signal_name, self.signal_handler.getGenericCb(signal_name), "plugin") self.media_dir = self.bridge.getConfig('', 'media_dir') self.local_dir = self.bridge.getConfig('', 'local_dir') self.cache_root_dir = os.path.join( self.local_dir, C.CACHE_DIR) # JSON APIs self.putChild('json_signal_api', self.signal_handler) self.putChild('json_api', MethodHandler(self)) self.putChild('register_api', _register) # files upload self.putChild('upload_radiocol', _upload_radiocol) self.putChild('upload_avatar', _upload_avatar) # static pages self.putChild('blog', MicroBlog(self)) self.putChild(C.THEMES_URL, ProtectedFile(self.themes_dir)) LiberviaPage.importPages(self) # media dirs # FIXME: get rid of dirname and "/" in C.XXX_DIR self.putChild(os.path.dirname(C.MEDIA_DIR), ProtectedFile(self.media_dir)) self.cache_resource = web_resource.NoResource() self.putChild(C.CACHE_DIR, self.cache_resource) # special self.putChild('radiocol', ProtectedFile(_upload_radiocol.getTmpDir(), defaultType="audio/ogg")) # FIXME: We cheat for PoC because we know we are on the same host, so we use directly upload dir # pyjamas tests, redirected only for dev versions if self.version[-1] == 'D': self.putChild('test', web_util.Redirect('/libervia_test.html')) wrapped = web_resource.EncodingResourceWrapper(root, [server.GzipEncoderFactory()]) self.site = server.Site(wrapped) self.site.sessionFactory = LiberviaSession self.renderer = template.Renderer(self) self.putChild('templates', ProtectedFile(self.renderer.base_dir)) def _bridgeCb(self): self.bridge.getReady(lambda: self.initialised.callback(None), lambda failure: self.initialised.errback(Exception(failure))) self.initialised.addCallback(self.backendReady) self.initialised.addErrback(lambda failure: log.error(u"Init error: %s" % failure)) def _bridgeEb(self, failure): log.error(u"Can't connect to bridge: {}".format(failure)) @property def version(self): """Return the short version of Libervia""" return C.APP_VERSION @property def full_version(self): """Return the full version of Libervia (with extra data when in development mode)""" version = self.version if version[-1] == 'D': # we are in debug version, we add extra data try: return self._version_cache except AttributeError: self._version_cache = u"{} ({})".format(version, utils.getRepositoryData(libervia)) return self._version_cache else: return version def bridgeCall(self, method_name, *args, **kwargs): """Call an asynchronous bridge method and return a deferred @param method_name: name of the method as a unicode @return: a deferred which trigger the result """ d = defer.Deferred() def _callback(*args): if not args: d.callback(None) else: if len(args) != 1: Exception("Multiple return arguments not supported") d.callback(args[0]) def _errback(result): d.errback(failure.Failure(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, result.classname))) kwargs["callback"] = _callback kwargs["errback"] = _errback getattr(self.bridge, method_name)(*args, **kwargs) return d def _logged(self, profile, request): """Set everything when a user just logged in @param profile @param request @return: a constant indicating the state: - C.PROFILE_LOGGED - C.PROFILE_LOGGED_EXT_JID @raise exceptions.ConflictError: session is already active """ register_with_ext_jid = self.waiting_profiles.getRegisterWithExtJid(profile) self.waiting_profiles.purgeRequest(profile) _session = request.getSession() sat_session = session_iface.ISATSession(_session) if sat_session.profile: log.error(_(u'/!\\ Session has already a profile, this should NEVER happen!')) raise failure.Failure(exceptions.ConflictError("Already active")) sat_session.profile = profile self.prof_connected.add(profile) cache_dir = os.path.join(self.cache_root_dir, regex.pathEscape(profile)) # FIXME: would be better to have a global /cache URL which redirect to profile's cache directory, without uuid self.cache_resource.putChild(sat_session.uuid, ProtectedFile(cache_dir)) log.debug(_(u"profile cache resource added from {uuid} to {path}").format(uuid=sat_session.uuid, path=cache_dir)) def onExpire(): log.info(u"Session expired (profile={profile})".format(profile=profile,)) self.cache_resource.delEntity(sat_session.uuid) log.debug(_(u"profile cache resource {uuid} deleted").format(uuid = sat_session.uuid)) try: #We purge the queue del self.signal_handler.queue[profile] except KeyError: pass #and now we disconnect the profile self.bridge.disconnect(profile) _session.notifyOnExpire(onExpire) return C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED @defer.inlineCallbacks def connect(self, request, login, password): """log user in If an other user was already logged, it will be unlogged first @param request(server.Request): request linked to the session @param login(unicode): user login can be profile name can be profile@[libervia_domain.ext] can be a jid (a new profile will be created with this jid if needed) @param password(unicode): user password @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else self._logged value @raise exceptions.DataError: invalid login @raise exceptions.ProfileUnknownError: this login doesn't exist @raise exceptions.PermissionError: a login is not accepted (e.g. empty password not allowed) @raise exceptions.NotReady: a profile connection is already waiting @raise exceptions.TimeoutError: didn't received and answer from Bridge @raise exceptions.InternalError: unknown error @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password """ # XXX: all security checks must be done here, even if present in javascript if login.startswith('@'): raise failure.Failure(exceptions.DataError('No profile_key allowed')) if '@' in login: try: login_jid = jid.JID(login) except (RuntimeError, jid.InvalidFormat, AttributeError): raise failure.Failure(exceptions.DataError('No profile_key allowed')) # FIXME: should it be cached? new_account_domain = yield self.bridgeCall("getNewAccountDomain") if login_jid.host == new_account_domain: # redirect "user@libervia.org" to the "user" profile login = login_jid.user login_jid = None else: login_jid = None try: profile = yield self.bridgeCall("profileNameGet", login) except Exception: # XXX: ProfileUnknownError wouldn't work, it's encapsulated # FIXME: find a better way to handle bridge errors if login_jid is not None and login_jid.user: # try to create a new sat profile using the XMPP credentials if not self.options["allow_registration"]: log.warning(u"Trying to register JID account while registration is not allowed") raise failure.Failure(exceptions.DataError(u"JID login while registration is not allowed")) profile = login # FIXME: what if there is a resource? connect_method = "asyncConnectWithXMPPCredentials" register_with_ext_jid = True else: # non existing username raise failure.Failure(exceptions.ProfileUnknownError()) else: if profile != login or (not password and profile not in self.options['empty_password_allowed_warning_dangerous_list']): # profiles with empty passwords are restricted to local frontends raise exceptions.PermissionError register_with_ext_jid = False connect_method = "connect" # we check if there is not already an active session sat_session = session_iface.ISATSession(request.getSession()) if sat_session.profile: # yes, there is if sat_session.profile != profile: # it's a different profile, we need to disconnect it log.warning(_(u"{new_profile} requested login, but {old_profile} was already connected, disconnecting {old_profile}").format( old_profile = sat_session.profile, new_profile = profile)) self.purgeSession(request) if self.waiting_profiles.getRequest(profile): # FIXME: check if and when this can happen raise failure.Failure(exceptions.NotReady("Already waiting")) self.waiting_profiles.setRequest(request, profile, register_with_ext_jid) try: connected = yield self.bridgeCall(connect_method, profile, password) except Exception as failure_: fault = failure_.faultString self.waiting_profiles.purgeRequest(profile) if fault in ('PasswordError', 'ProfileUnknownError'): log.info(u"Profile {profile} doesn't exist or the submitted password is wrong".format(profile=profile)) raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR)) elif fault == 'SASLAuthError': log.info(u"The XMPP password of profile {profile} is wrong".format(profile=profile)) raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR)) elif fault == 'NoReply': log.info(_("Did not receive a reply (the timeout expired or the connection is broken)")) raise exceptions.TimeOutError else: log.error(u'Unmanaged fault string "{fault}" in errback for the connection of profile {profile}'.format( fault=fault, profile=profile)) raise failure.Failure(exceptions.InternalError(fault)) if connected: # profile is already connected in backend # do we have a corresponding session in Libervia? sat_session = session_iface.ISATSession(request.getSession()) if sat_session.profile: # yes, session is active if sat_session.profile != profile: # existing session should have been ended above # so this line should never be reached log.error(_(u'session profile [{session_profile}] differs from login profile [{profile}], this should not happen!').format( session_profile = sat_session.profile, profile = profile )) raise exceptions.InternalError("profile mismatch") defer.returnValue(C.SESSION_ACTIVE) log.info(_(u"profile {profile} was already connected in backend".format(profile=profile))) # no, we have to create it defer.returnValue(self._logged(profile, request)) def registerNewAccount(self, request, login, password, email): """Create a new account, or return error @param request(server.Request): request linked to the session @param login(unicode): new account requested login @param email(unicode): new account email @param password(unicode): new account password @return(unicode): a constant indicating the state: - C.BAD_REQUEST: something is wrong in the request (bad arguments) - C.INVALID_INPUT: one of the data is not valid - C.REGISTRATION_SUCCEED: new account has been successfully registered - C.ALREADY_EXISTS: the given profile already exists - C.INTERNAL_ERROR or any unmanaged fault string @raise PermissionError: registration is now allowed in server configuration """ if not self.options["allow_registration"]: log.warning(_(u"Registration received while it is not allowed, hack attempt?")) raise failure.Failure(exceptions.PermissionError(u"Registration is not allowed on this server")) if not re.match(C.REG_LOGIN_RE, login) or \ not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE) or \ len(password) < C.PASSWORD_MIN_LENGTH: return C.INVALID_INPUT def registered(result): return C.REGISTRATION_SUCCEED def registeringError(failure): status = failure.value.faultString if status == "ConflictError": return C.ALREADY_EXISTS elif status == "InternalError": return C.INTERNAL_ERROR else: log.error(_(u'Unknown registering error status: {status }').format( status = status)) return status d = self.bridgeCall("registerSatAccount", email, password, login) d.addCallback(registered) d.addErrback(registeringError) return d def addCleanup(self, callback, *args, **kwargs): """Add cleaning method to call when service is stopped cleaning method will be called in reverse order of they insertion @param callback: callable to call on service stop @param *args: list of arguments of the callback @param **kwargs: list of keyword arguments of the callback""" self._cleanup.insert(0, (callback, args, kwargs)) def startService(self): """Connect the profile for Libervia and start the HTTP(S) server(s)""" def eb(e): log.error(_(u"Connection failed: %s") % e) self.stop() def initOk(dummy): try: connected = self.bridge.isConnected(C.SERVICE_PROFILE) except Exception as e: # we don't want the traceback msg = [l for l in unicode(e).split('\n') if l][-1] log.error(u"Can't check service profile ({profile}), are you sure it exists ?\n{error}".format( profile=C.SERVICE_PROFILE, error=msg)) self.stop() return if not connected: self.bridge.connect(C.SERVICE_PROFILE, self.options['passphrase'], {}, callback=self._startService, errback=eb) else: self._startService() self.initialised.addCallback(initOk) ## URLs ## def putChild(self, path, resource): """Add a child to the root resource""" # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) self.root.putChild(path, web_resource.EncodingResourceWrapper(resource, [server.GzipEncoderFactory()])) ## Sessions ## def purgeSession(self, request): """helper method to purge a session during request handling""" session = request.session if session is not None: log.debug(_(u"session purge")) session.expire() # FIXME: not clean but it seems that it's the best way to reset # session during request handling request._secureSession = request._insecureSession = None def getSessionData(self, request, *args): """helper method to retrieve session data @param request(server.Request): request linked to the session @param *args(zope.interface.Interface): interface of the session to get @return (iterator(data)): requested session data """ session = request.getSession() if len(args) == 1: return args[0](session) else: return (iface(session) for iface in args) ## TLS related methods ## def _TLSOptionsCheck(self): """Check options coherence if TLS is activated, and update missing values Must be called only if TLS is activated """ if not self.options['tls_certificate']: log.error(u"a TLS certificate is needed to activate HTTPS connection") self.quit(1) if not self.options['tls_private_key']: self.options['tls_private_key'] = self.options['tls_certificate'] if not self.options['tls_private_key']: self.options['tls_private_key'] = self.options['tls_certificate'] def _loadCertificates(self, f): """Read a .pem file with a list of certificates @param f (file): file obj (opened .pem file) @return (list[OpenSSL.crypto.X509]): list of certificates @raise OpenSSL.crypto.Error: error while parsing the file """ # XXX: didn't found any method to load a .pem file with several certificates # so the certificates split is done here certificates = [] buf = [] while True: line = f.readline() buf.append(line) if '-----END CERTIFICATE-----' in line: certificates.append(OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, ''.join(buf))) buf=[] elif not line: log.debug(u"{} certificate(s) found".format(len(certificates))) return certificates def _loadPKey(self, f): """Read a private key from a .pem file @param f (file): file obj (opened .pem file) @return (list[OpenSSL.crypto.PKey]): private key object @raise OpenSSL.crypto.Error: error while parsing the file """ return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, f.read()) def _loadCertificate(self, f): """Read a public certificate from a .pem file @param f (file): file obj (opened .pem file) @return (list[OpenSSL.crypto.X509]): public certificate @raise OpenSSL.crypto.Error: error while parsing the file """ return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, f.read()) def _getTLSContextFactory(self): """Load TLS certificate and build the context factory needed for listenSSL""" if ssl is None: raise ImportError(u"Python module pyOpenSSL is not installed!") cert_options = {} for name, option, method in [('privateKey', 'tls_private_key', self._loadPKey), ('certificate', 'tls_certificate', self._loadCertificate), ('extraCertChain', 'tls_chain', self._loadCertificates)]: path = self.options[option] if not path: assert option=='tls_chain' continue log.debug(u"loading {option} from {path}".format(option=option, path=path)) try: with open(path) as f: cert_options[name] = method(f) except IOError as e: log.error(u"Error while reading file {path} for option {option}: {error}".format(path=path, option=option, error=e)) self.quit(2) except OpenSSL.crypto.Error: log.error(u"Error while parsing file {path} for option {option}, are you sure it is a valid .pem file?".format(path=path, option=option)) if option=='tls_private_key' and self.options['tls_certificate'] == path: log.error(u"You are using the same file for private key and public certificate, make sure that both a in {path} or use --tls_private_key option".format(path=path)) self.quit(2) return ssl.CertificateOptions(**cert_options) ## service management ## def _startService(self, dummy=None): """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. @raise ImportError: OpenSSL is not available @raise IOError: the certificate file doesn't exist @raise OpenSSL.crypto.Error: the certificate file is invalid """ # now that we have service profile connected, we add resource for its cache service_path = regex.pathEscape(C.SERVICE_PROFILE) cache_dir = os.path.join(self.cache_root_dir, service_path) self.cache_resource.putChild(service_path, ProtectedFile(cache_dir)) self.service_cache_url = os.path.join(C.CACHE_DIR, service_path) if self.options['connection_type'] in ('https', 'both'): self._TLSOptionsCheck() context_factory = self._getTLSContextFactory() reactor.listenSSL(self.options['port_https'], self.site, context_factory) if self.options['connection_type'] in ('http', 'both'): if self.options['connection_type'] == 'both' and self.options['redirect_to_https']: reactor.listenTCP(self.options['port'], server.Site(RedirectToHTTPS(self.options['port'], self.options['port_https_ext']))) else: reactor.listenTCP(self.options['port'], self.site) def stopService(self): log.info(_("launching cleaning methods")) for callback, args, kwargs in self._cleanup: callback(*args, **kwargs) try: self.bridge.disconnect(C.SERVICE_PROFILE) except Exception: log.warning(u"Can't disconnect service profile") def run(self): reactor.run() def stop(self): reactor.stop() def quit(self, exit_code=None): """Exit app when reactor is running @param exit_code(None, int): exit code """ self.stop() sys.exit(exit_code or 0) class RedirectToHTTPS(web_resource.Resource): def __init__(self, old_port, new_port): web_resource.Resource.__init__(self) self.isLeaf = True self.old_port = old_port self.new_port = new_port def render(self, request): netloc = request.URLPath().netloc.replace(':%s' % self.old_port, ':%s' % self.new_port) url = "https://" + netloc + request.uri return web_util.redirectTo(url, request) registerAdapter(session_iface.SATSession, server.Session, session_iface.ISATSession) registerAdapter(session_iface.SATGuestSession, server.Session, session_iface.ISATGuestSession)