Mercurial > libervia-web
diff src/server/server.py @ 449:981ed669d3b3
/!\ reorganize all the file hierarchy, move the code and launching script to src:
- browser_side --> src/browser
- public --> src/browser_side/public
- libervia.py --> src/browser/libervia_main.py
- libervia_server --> src/server
- libervia_server/libervia.sh --> src/libervia.sh
- twisted --> src/twisted
- new module src/common
- split constants.py in 3 files:
- src/common/constants.py
- src/browser/constants.py
- src/server/constants.py
- output --> html (generated by pyjsbuild during the installation)
- new option/parameter "data_dir" (-d) to indicates the directory containing html and server_css
- setup.py installs libervia to the following paths:
- src/common --> <LIB>/libervia/common
- src/server --> <LIB>/libervia/server
- src/twisted --> <LIB>/twisted
- html --> <SHARE>/libervia/html
- server_side --> <SHARE>libervia/server_side
- LIBERVIA_INSTALL environment variable takes 2 new options with prompt confirmation:
- clean: remove previous installation directories
- purge: remove building and previous installation directories
You may need to update your sat.conf and/or launching script to update the following options/parameters:
- ssl_certificate
- data_dir
author | souliane <souliane@mailoo.org> |
---|---|
date | Tue, 20 May 2014 06:41:16 +0200 |
parents | libervia_server/__init__.py@14c35f7f1ef5 |
children | 1a0cec9b0f1e |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/server/server.py Tue May 20 06:41:16 2014 +0200 @@ -0,0 +1,1168 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 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 glib2reactor +glib2reactor.install() +from twisted.internet import reactor, defer +from twisted.web import server +from twisted.web.static import File +from twisted.web.resource import Resource, NoResource +from twisted.web.util import Redirect, redirectTo +from twisted.python.components import registerAdapter +from twisted.python.failure import Failure +from twisted.words.protocols.jabber.jid 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 import DBusBridgeFrontend, BridgeExceptionNoService +from sat.core.i18n import _, D_ +from sat.tools.xml_tools import paramsXML2XMLUI + +import re +import glob +import os.path +import sys +import tempfile +import shutil +import uuid +from zope.interface import Interface, Attribute, implements +from xml.dom import minidom +from httplib import HTTPS_PORT + +try: + import OpenSSL + from twisted.internet import ssl + ssl_available = True +except: + ssl_available = False + +from libervia.server.constants import Const as C +from libervia.server.blog import MicroBlog + + +class ISATSession(Interface): + profile = Attribute("Sat profile") + jid = Attribute("JID associated with the profile") + + +class SATSession(object): + implements(ISATSession) + def __init__(self, session): + self.profile = None + self.jid = None + + +class LiberviaSession(server.Session): + sessionTimeout = C.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(File): + """A File class which doens't show directory listing""" + + def directoryListing(self): + return NoResource() + +class SATActionIDHandler(object): + """Manage SàT action action_id lifecycle""" + ID_LIFETIME = 30 #after this time (in seconds), action_id will be suppressed and action result will be ignored + + def __init__(self): + self.waiting_ids = {} + + def waitForId(self, callback, action_id, profile, *args, **kwargs): + """Wait for an action result + @param callback: method to call when action gave a result back + @param action_id: action_id to wait for + @param profile: %(doc_profile)s + @param *args: additional argument to pass to callback + @param **kwargs: idem""" + action_tuple = (action_id, profile) + self.waiting_ids[action_tuple] = (callback, args, kwargs) + reactor.callLater(self.ID_LIFETIME, self.purgeID, action_tuple) + + def purgeID(self, action_tuple): + """Called when an action_id has not be handled in time""" + if action_tuple in self.waiting_ids: + log.warning ("action of action_id %s [%s] has not been managed, action_id is now ignored" % action_tuple) + del self.waiting_ids[action_tuple] + + def actionResultCb(self, answer_type, action_id, data, profile): + """Manage the actionResult signal""" + action_tuple = (action_id, profile) + if action_tuple in self.waiting_ids: + callback, args, kwargs = self.waiting_ids[action_tuple] + del self.waiting_ids[action_tuple] + callback(answer_type, action_id, data, *args, **kwargs) + +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): + """Call an asynchrone 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(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, unicode(result)))) + + kwargs["callback"] = _callback + kwargs["errback"] = _errback + getattr(self.sat_host.bridge, method_name)(*args, **kwargs) + return d + + +class MethodHandler(JSONRPCMethodManager): + + def __init__(self, sat_host): + JSONRPCMethodManager.__init__(self, sat_host) + self.authorized_params = None + + def render(self, request): + self.session = request.getSession() + profile = 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, "Not allowed") #FIXME: define some standard error codes for libervia + return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) + return jsonrpc.JSONRPC.render(self, request) + + def jsonrpc_getProfileJid(self): + """Return the jid of the profile""" + sat_session = ISATSession(self.session) + profile = sat_session.profile + sat_session.jid = JID(self.sat_host.bridge.getParamA("JabberID", "Connection", profile_key=profile)) + return sat_session.jid.full() + + def jsonrpc_disconnect(self): + """Disconnect the profile""" + sat_session = ISATSession(self.session) + profile = sat_session.profile + self.sat_host.bridge.disconnect(profile) + + def jsonrpc_getContacts(self): + """Return all passed args.""" + profile = 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 = 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 = ISATSession(self.session).profile + self.sat_host.bridge.delContact(entity, profile) + + def jsonrpc_updateContact(self, entity, name, groups): + """Update contact's roster item""" + profile = ISATSession(self.session).profile + self.sat_host.bridge.updateContact(entity, name, groups, profile) + + def jsonrpc_subscription(self, sub_type, entity, name, groups): + """Confirm (or infirm) subscription, + and setup user roster in case of subscription""" + profile = ISATSession(self.session).profile + self.sat_host.bridge.subscription(sub_type, entity, profile) + if sub_type == 'subscribed': + self.sat_host.bridge.updateContact(entity, name, groups, profile) + + def jsonrpc_getWaitingSub(self): + """Return list of room already joined by user""" + profile = 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 = ISATSession(self.session).profile + self.sat_host.bridge.setPresence('', presence, {'': status}, profile) + + + def jsonrpc_sendMessage(self, to_jid, msg, subject, type_, options={}): + """send message""" + profile = ISATSession(self.session).profile + return self.asyncBridgeCall("sendMessage", to_jid, msg, subject, type_, options, profile) + + def jsonrpc_sendMblog(self, type_, dest, text, extra={}): + """ Send microblog message + @param type_: one of "PUBLIC", "GROUP" + @param dest: destinees (list of groups, ignored for "PUBLIC") + @param text: microblog's text + """ + profile = 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 + print "sending public blog" + return self.sat_host.bridge.sendGroupBlog("PUBLIC", [], text, extra, profile) + else: + print "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 = 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 = 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 = 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): + """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 = ISATSession(self.session).profile + d = self.asyncBridgeCall("getGroupBlogs", publisher_jid, item_ids, profile) + return d + + def jsonrpc_getMblogsWithComments(self, publisher_jid, item_ids): + """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 = ISATSession(self.session).profile + d = self.asyncBridgeCall("getGroupBlogsWithComments", publisher_jid, item_ids, profile) + return d + + def jsonrpc_getLastMblogs(self, publisher_jid, max_item): + """Get last microblogs posted by a contact + @param publisher_jid: jid of the publisher + @param max_item: number of items to ask + @return list of microblog data (dict)""" + profile = ISATSession(self.session).profile + d = self.asyncBridgeCall("getLastGroupBlogs", publisher_jid, max_item, profile) + return d + + def jsonrpc_getMassiveLastMblogs(self, publishers_type, publishers_list, max_item): + """Get lasts microblogs posted by several contacts at once + @param publishers_type: one of "ALL", "GROUP", "JID" + @param publishers_list: list of publishers type (empty list of all, list of groups or list of jids) + @param max_item: number of items to ask + @return: dictionary key=publisher's jid, value=list of microblog data (dict)""" + profile = ISATSession(self.session).profile + d = self.asyncBridgeCall("getMassiveLastGroupBlogs", publishers_type, publishers_list, max_item, profile) + self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers_list, profile) + return d + + def jsonrpc_getMblogComments(self, service, node): + """Get all comments of given node + @param service: jid of the service hosting the node + @param node: comments node + """ + profile = ISATSession(self.session).profile + d = self.asyncBridgeCall("getGroupBlogComments", service, node, profile) + return d + + + def jsonrpc_getPresenceStatuses(self): + """Get Presence information for connected contacts""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getPresenceStatuses(profile) + + def jsonrpc_getHistory(self, from_jid, to_jid, size, between): + """Return history for the from_jid/to_jid couple""" + sat_session = ISATSession(self.session) + profile = sat_session.profile + sat_jid = sat_session.jid + if not sat_jid: + log.error("No jid saved for this profile") + return {} + if JID(from_jid).userhost() != sat_jid.userhost() and JID(to_jid).userhost() != sat_jid.userhost(): + log.error("Trying to get history from a different jid, maybe a hack attempt ?") + return {} + d = self.asyncBridgeCall("getHistory", from_jid, to_jid, size, between, 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 + timestamp, from_jid, to_jid, message, mess_type, extra = line + result.append((float(timestamp), unicode(from_jid), unicode(to_jid), unicode(message), unicode(mess_type), dict(extra))) + return result + d.addCallback(show) + return d + + def jsonrpc_joinMUC(self, room_jid, nick): + """Join a Multi-User Chat room + @room_jid: leave empty string to generate a unique name + """ + profile = ISATSession(self.session).profile + try: + if room_jid != "": + room_jid = JID(room_jid).userhost() + except: + log.warning('Invalid room jid') + return + 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""" + profile = ISATSession(self.session).profile + try: + room_jid = JID(room_jid).userhost() + except: + log.warning('Invalid room jid') + return + 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 = ISATSession(self.session).profile + try: + room_jid = JID(room_jid) + except: + log.warning('Invalid room jid') + return + self.sat_host.bridge.mucLeave(room_jid.userhost(), profile) + + def jsonrpc_getRoomsJoined(self): + """Return list of room already joined by user""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getRoomsJoined(profile) + + def jsonrpc_launchTarotGame(self, other_players, room_jid=""): + """Create a room, invite the other players and start a Tarot game + @param room_jid: leave empty string to generate a unique room name + """ + profile = ISATSession(self.session).profile + try: + if room_jid != "": + room_jid = JID(room_jid).userhost() + except: + log.warning('Invalid room jid') + return + 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 = 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 = 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 room_jid: leave empty string to generate a unique room name + """ + profile = ISATSession(self.session).profile + try: + if room_jid != "": + room_jid = JID(room_jid).userhost() + except: + log.warning('Invalid room jid') + return + self.sat_host.bridge.radiocolLaunch(invited, room_jid, profile) + + def jsonrpc_getEntityData(self, jid, keys): + """Get cached data for an entit + @param jid: jid of contact from who we want data + @param keys: name of data we want (list) + @return: requested data""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getEntityData(jid, keys, profile) + + 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 = ISATSession(self.session).profile + return self.sat_host.bridge.getCard(jid, profile) + + def jsonrpc_getAccountDialogUI(self): + """Get the dialog for managing user account + @return: XML string of the XMLUI""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getAccountDialogUI(profile) + + def jsonrpc_getParamsUI(self): + """Return the parameters XML for profile""" + profile = ISATSession(self.session).profile + d = self.asyncBridgeCall("getParams", C.SECURITY_LIMIT, C.APP_NAME, profile) + + def setAuthorizedParams(params_xml): + if self.authorized_params is None: + self.authorized_params = {} + for cat in minidom.parseString(params_xml.encode('utf-8')).getElementsByTagName("category"): + params = cat.getElementsByTagName("param") + params_list = [param.getAttribute("name") for param in params] + self.authorized_params[cat.getAttribute("name")] = params_list + if self.authorized_params: + return params_xml + else: + return None + + d.addCallback(setAuthorizedParams) + + d.addCallback(lambda params_xml: paramsXML2XMLUI(params_xml) if params_xml else "") + + return d + + def jsonrpc_asyncGetParamA(self, param, category, attribute="value"): + """Return the parameter value for profile""" + profile = ISATSession(self.session).profile + d = self.asyncBridgeCall("asyncGetParamA", param, category, attribute, C.SECURITY_LIMIT, profile_key=profile) + return d + + def jsonrpc_setParam(self, name, value, category): + profile = ISATSession(self.session).profile + if category in self.authorized_params and name in self.authorized_params[category]: + return self.sat_host.bridge.setParam(name, value, category, C.SECURITY_LIMIT, profile) + else: + log.warning("Trying to set parameter '%s' in category '%s' without authorization!!!" + % (name, category)) + + 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 = 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 = 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_confirmationAnswer(self, confirmation_id, result, answer_data): + """Send the user's answer to any previous 'askConfirmation' signal""" + profile = ISATSession(self.session).profile + self.sat_host.bridge.confirmationAnswer(confirmation_id, result, answer_data, profile) + + 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 = ISATSession(self.session).profile + return self.sat_host.bridge.syntaxConvert(text, syntax_from, syntax_to, True, profile) + + +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 getWaitingRequest(self, profile): + """Tell if a profile is trying to log in""" + if self.profiles_waiting.has_key(profile): + return self.profiles_waiting[profile] + else: + return 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") + if method not in ['isRegistered', 'registerParams', 'getMenus']: + #if we don't call these methods, we need to be identified + profile = ISATSession(_session).profile + if not profile: + #user is not identified, we return a jsonrpc fault + fault = jsonrpclib.Fault(C.ERRNUM_LIBERVIA, "Not allowed") #FIXME: define some standard error codes for libervia + return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) + 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: + - 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 "BAD REQUEST" + + if submit_type == 'register': + return self._registerNewAccount(request) + elif submit_type == 'login': + return self._loginAccount(request) + return Exception('Unknown submit type') + + def _loginAccount(self, request): + """Try to authenticate the user with the request information. + @param request: request of the register form + @return: a constant indicating the state: + - BAD REQUEST: something is wrong in the request (bad arguments) + - AUTH ERROR: either the profile (login) or the password is wrong + - ALREADY WAITING: a request has already been submitted for this profile + - server.NOT_DONE_YET: the profile is being processed, the return + value will be given by self._logged or self._logginError + """ + try: + login_ = request.args['login'][0] + password_ = request.args['login_password'][0] + except KeyError: + return "BAD REQUEST" + + if login_.startswith('@'): + raise Exception('No profile_key allowed') + + profile_check = self.sat_host.bridge.getProfileName(login_) + if not profile_check or profile_check != login_ or not password_: + # profiles with empty passwords are restricted to local frontends + return "AUTH ERROR" + + if login_ in self.profiles_waiting: + return "ALREADY WAITING" + + def auth_eb(ignore=None): + self.__cleanWaiting(login_) + log.info("Profile %s doesn't exist or the submitted password is wrong" % login_) + request.write("AUTH ERROR") + request.finish() + + self.profiles_waiting[login_] = request + d = self.asyncBridgeCall("asyncConnect", login_, password_) + d.addCallbacks(lambda connected: self._logged(login_, request) if connected else None, auth_eb) + + return server.NOT_DONE_YET + + def _registerNewAccount(self, request): + """Create a new account, or return error + @param request: request of the register form + @return: a constant indicating the state: + - BAD REQUEST: something is wrong in the request (bad arguments) + - REGISTRATION: new account has been successfully registered + - ALREADY EXISTS: the given profile already exists + - INTERNAL or 'Unknown error (...)' + - server.NOT_DONE_YET: the profile is being processed, the return + value will be given later (one of those previously described) + """ + try: + profile = login = request.args['register_login'][0] + password = request.args['register_password'][0] + email = request.args['email'][0] + except KeyError: + return "BAD REQUEST" + if not re.match(r'^[a-z0-9_-]+$', login, re.IGNORECASE) or \ + not re.match(r'^.+@.+\..+', email, re.IGNORECASE) or \ + len(password) < C.PASSWORD_MIN_LENGTH: + return "BAD REQUEST" + + def registered(result): + request.write('REGISTRATION') + request.finish() + + def registeringError(failure): + reason = failure.value.faultString + if reason == "ConflictError": + request.write('ALREADY EXISTS') + elif reason == "InternalError": + request.write('INTERNAL') + else: + log.error('Unknown registering error: %s' % (reason,)) + request.write('Unknown error (%s)' % reason) + request.finish() + + d = self.asyncBridgeCall("registerSatAccount", email, password, profile) + d.addCallback(registered) + d.addErrback(registeringError) + return server.NOT_DONE_YET + + def __cleanWaiting(self, login): + """Remove login from waiting queue""" + try: + del self.profiles_waiting[login] + except KeyError: + pass + + def _logged(self, profile, request): + """Set everything when a user just logged in + + @param profile + @param request + @return: a constant indicating the state: + - LOGGED + - SESSION_ACTIVE + """ + self.__cleanWaiting(profile) + _session = request.getSession() + sat_session = ISATSession(_session) + if sat_session.profile: + log.error(('/!\\ Session has already a profile, this should NEVER happen!')) + request.write('SESSION_ACTIVE') + request.finish() + return + sat_session.profile = profile + self.sat_host.prof_connected.add(profile) + + def onExpire(): + log.info("Session expired (profile=%s)" % (profile,)) + try: + #We purge the queue + del self.sat_host.signal_handler.queue[profile] + except KeyError: + pass + #and now we disconnect the profile + self.sat_host.bridge.disconnect(profile) + + _session.notifyOnExpire(onExpire) + + request.write('LOGGED') + request.finish() + + def _logginError(self, login, request, error_type): + """Something went wrong during logging in + @return: error + """ + self.__cleanWaiting(login) + return error_type + + def jsonrpc_isConnected(self): + _session = self.request.getSession() + profile = ISATSession(_session).profile + return self.sat_host.bridge.isConnected(profile) + + def jsonrpc_connect(self): + _session = self.request.getSession() + profile = ISATSession(_session).profile + if self.profiles_waiting.has_key(profile): + raise jsonrpclib.Fault(1,'Already waiting') #FIXME: define some standard error codes for libervia + self.profiles_waiting[profile] = self.request + self.sat_host.bridge.connect(profile) + return server.NOT_DONE_YET + + def jsonrpc_isRegistered(self): + """ + @return: a couple (registered, message) with: + - registered: True if the user is already registered, False otherwise + - message: a security warning message if registered is False *and* the connection is unsecure, None otherwise + """ + _session = self.request.getSession() + profile = ISATSession(_session).profile + if bool(profile): + return (True, None) + return (False, self.__getSecurityWarning()) + + def jsonrpc_registerParams(self): + """Register the frontend specific parameters""" + params = """ + <params> + <individual> + <category name="%(category_name)s" label="%(category_label)s"> + <param name="%(param_name)s" label="%(param_label)s" value="false" type="bool" security="0"/> + </category> + </individual> + </params> + """ % { + 'category_name': C.ENABLE_UNIBOX_KEY, + 'category_label': _(C.ENABLE_UNIBOX_KEY), + 'param_name': C.ENABLE_UNIBOX_PARAM, + 'param_label': _(C.ENABLE_UNIBOX_PARAM) + } + + self.sat_host.bridge.paramsRegisterApp(params, C.SECURITY_LIMIT, C.APP_NAME) + + def jsonrpc_getMenus(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.getMenus('', 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.security_warning: + return None + text = D_("You are about to connect to an unsecured service.") + if self.sat_host.connection_type == 'both': + new_port = (':%s' % self.sat_host.port_https_ext) if self.sat_host.port_https_ext != HTTPS_PORT else '' + url = "https://%s" % self.request.URLPath().netloc.replace(':%s' % self.sat_host.port, new_port) + text += D_('<br />Secure version of this website: <a href="%(url)s">%(url)s</a>') % {'url': url} + return text + + +class SignalHandler(jsonrpc.JSONRPC): + + def __init__(self, sat_host): + Resource.__init__(self) + self.register=None + self.sat_host=sat_host + self.signalDeferred = {} + 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 = 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,)) + _session.expire() + 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 + if profile in self.signalDeferred: + self.signalDeferred[profile].callback((function_name,args[:-1])) + del self.signalDeferred[profile] + else: + if not self.queue.has_key(profile): + self.queue[profile] = [] + self.queue[profile].append((function_name, args[:-1])) + return genericCb + + def connected(self, profile): + assert(self.register) # register must be plugged + request = self.register.getWaitingRequest(profile) + if request: + self.register._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 not self.queue.has_key(profile): + self.queue[profile] = [] + self.queue[profile].append(("disconnected",)) + + + def connectionError(self, error_type, profile): + assert(self.register) #register must be plugged + request = self.register.getWaitingRequest(profile) + if request: #The user is trying to log in + if error_type == "AUTH_ERROR": + _error_t = "AUTH ERROR" + else: + _error_t = "UNKNOWN" + self.register._logginError(profile, request, _error_t) + + def render(self, request): + """ + Render method wich reject access if user is not identified + """ + _session = request.getSession() + parsed = jsonrpclib.loads(request.content.read()) + profile = ISATSession(_session).profile + if not profile: + #user is not identified, we return a jsonrpc fault + fault = jsonrpclib.Fault(C.ERRNUM_LIBERVIA, "Not allowed") #FIXME: define some standard error codes for libervia + return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) + self.request = request + return jsonrpc.JSONRPC.render(self, request) + +class UploadManager(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 isRegistered, 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) + request.write('KO' if error else '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 = 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 = ISATSession(request.getSession()).profile + return ("setAvatar", filepath, profile) + + +def coerceConnectionType(value): # called from Libervia.OPT_PARAMETERS + allowed_values = ('http', 'https', 'both') + if value not in allowed_values: + raise ValueError("%(given)s not in %(expected)s" % {'given': value, 'expected': str(allowed_values)}) + return value + + +def coerceDataDir(value): # called from Libervia.OPT_PARAMETERS + html = os.path.join(value, C.HTML_DIR) + if not os.path.isfile(os.path.join(html, 'libervia.html')): + raise ValueError("%s is not a Libervia's browser HTML directory" % os.path.realpath(html)) + server_css = os.path.join(value, C.SERVER_CSS_DIR) + if not os.path.isfile(os.path.join(server_css, 'blog.css')): + raise ValueError("%s is not a Libervia's server data directory" % os.path.realpath(server_css)) + return value + + +class Libervia(service.Service): + + DATA_DIR_DEFAULT = '' + OPT_PARAMETERS = [['connection_type', 't', 'https', "'http', 'https' or 'both' (to launch both servers).", coerceConnectionType], + ['port', 'p', 8080, 'The port number to listen HTTP on.', int], + ['port_https', 's', 8443, 'The port number to listen HTTPS on.', int], + ['port_https_ext', 'e', 0, 'The external port number used for HTTPS (0 means port_https value).', int], + ['ssl_certificate', 'c', 'libervia.pem', 'PEM certificate with both private and public parts.', str], + ['redirect_to_https', 'r', 1, 'Automatically redirect from HTTP to HTTPS.', int], + ['security_warning', 'w', 1, 'Warn user that he is about to connect on HTTP.', int], + # FIXME: twistd bugs when printing 'à' on "Unknown command" error (works on normal command listing) + ['passphrase', 'k', '', u"Passphrase for the SaT profile named '%s'" % C.SERVICE_PROFILE, str], + ['data_dir', 'd', DATA_DIR_DEFAULT, u'Data directory for Libervia', coerceDataDir], + ] + + def __init__(self, *args, **kwargs): + if not kwargs: + # During the loading of the twisted plugins, we just need the default values. + # This part is not executed when the plugin is actually started. + for name, value in [(option[0], option[2]) for option in self.OPT_PARAMETERS]: + kwargs[name] = value + self.initialised = defer.Deferred() + self.connection_type = kwargs['connection_type'] + self.port = kwargs['port'] + self.port_https = kwargs['port_https'] + self.port_https_ext = kwargs['port_https_ext'] + if not self.port_https_ext: + self.port_https_ext = self.port_https + self.ssl_certificate = kwargs['ssl_certificate'] + self.redirect_to_https = kwargs['redirect_to_https'] + self.security_warning = kwargs['security_warning'] + self.passphrase = kwargs['passphrase'] + self.data_dir = kwargs['data_dir'] + if self.data_dir == Libervia.DATA_DIR_DEFAULT: + coerceDataDir(self.data_dir) # this is not done when using the default value + self.html_dir = os.path.join(self.data_dir, C.HTML_DIR) + self.server_css_dir = os.path.join(self.data_dir, C.SERVER_CSS_DIR) + self._cleanup = [] + root = ProtectedFile(self.html_dir) + self.signal_handler = SignalHandler(self) + _register = Register(self) + _upload_radiocol = UploadManagerRadioCol(self) + _upload_avatar = UploadManagerAvatar(self) + self.signal_handler.plugRegister(_register) + self.sessions = {} #key = session value = user + self.prof_connected = set() #Profiles connected + self.action_handler = SATActionIDHandler() + ## bridge ## + try: + self.bridge=DBusBridgeFrontend() + except BridgeExceptionNoService: + print(u"Can't connect to SàT backend, are you sure it's launched ?") + sys.exit(1) + def backendReady(dummy): + self.bridge.register("connected", self.signal_handler.connected) + self.bridge.register("disconnected", self.signal_handler.disconnected) + self.bridge.register("connectionError", self.signal_handler.connectionError) + self.bridge.register("actionResult", self.action_handler.actionResultCb) + #core + for signal_name in ['presenceUpdate', 'newMessage', 'subscribe', 'contactDeleted', 'newContact', 'entityDataUpdated', 'askConfirmation', 'newAlert', 'paramUpdate']: + self.bridge.register(signal_name, self.signal_handler.getGenericCb(signal_name)) + #plugins + for signal_name in ['personalEvent', 'roomJoined', 'roomUserJoined', 'roomUserLeft', 'tarotGameStarted', 'tarotGameNew', 'tarotGameChooseContrat', + 'tarotGameShowCards', 'tarotGameInvalidCards', 'tarotGameCardsPlayed', 'tarotGameYourTurn', 'tarotGameScore', 'tarotGamePlayers', + 'radiocolStarted', 'radiocolPreload', 'radiocolPlay', 'radiocolNoUpload', 'radiocolUploadOk', 'radiocolSongRejected', 'radiocolPlayers', + 'roomLeft', 'roomUserChangedNick', 'chatStateReceived']: + self.bridge.register(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') + root.putChild('', Redirect('libervia.html')) + root.putChild('json_signal_api', self.signal_handler) + root.putChild('json_api', MethodHandler(self)) + root.putChild('register_api', _register) + root.putChild('upload_radiocol', _upload_radiocol) + root.putChild('upload_avatar', _upload_avatar) + root.putChild('blog', MicroBlog(self)) + root.putChild('css', ProtectedFile(self.server_css_dir)) + root.putChild(os.path.dirname(C.MEDIA_DIR), ProtectedFile(self.media_dir)) + root.putChild(os.path.dirname(C.AVATARS_DIR), ProtectedFile(os.path.join(self.local_dir, C.AVATARS_DIR))) + root.putChild('radiocol', ProtectedFile(_upload_radiocol.getTmpDir(), defaultType="audio/ogg")) # We cheat for PoC because we know we are on the same host, so we use directly upload dir + self.site = server.Site(root) + self.site.sessionFactory = LiberviaSession + + self.bridge.getReady(lambda: self.initialised.callback(None), + lambda failure: self.initialised.errback(Exception(failure))) + self.initialised.addCallback(backendReady) + self.initialised.addErrback(lambda failure: log.error("Init error: %s" % failure)) + + 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(_("Connection failed: %s") % e) + self.stop() + + def initOk(dummy): + if not self.bridge.isConnected(C.SERVICE_PROFILE): + self.bridge.asyncConnect(C.SERVICE_PROFILE, self.passphrase, + callback=self._startService, errback=eb) + + self.initialised.addCallback(initOk) + + def _startService(self, dummy): + """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. + @raise IOError: the certificate file doesn't exist + @raise OpenSSL.crypto.Error: the certificate file is invalid + """ + if self.connection_type in ('https', 'both'): + if not ssl_available: + raise(ImportError(_("Python module pyOpenSSL is not installed!"))) + try: + with open(os.path.expanduser(self.ssl_certificate)) as keyAndCert: + try: + cert = ssl.PrivateCertificate.loadPEM(keyAndCert.read()) + except OpenSSL.crypto.Error as e: + log.error(_("The file '%s' must contain both private and public parts of the certificate") % self.ssl_certificate) + raise e + except IOError as e: + log.error(_("The file '%s' doesn't exist") % self.ssl_certificate) + raise e + reactor.listenSSL(self.port_https, self.site, cert.options()) + if self.connection_type in ('http', 'both'): + if self.connection_type == 'both' and self.redirect_to_https: + reactor.listenTCP(self.port, server.Site(RedirectToHTTPS(self.port, self.port_https_ext))) + else: + reactor.listenTCP(self.port, self.site) + + def stopService(self): + print "launching cleaning methods" + for callback, args, kwargs in self._cleanup: + callback(*args, **kwargs) + self.bridge.disconnect(C.SERVICE_PROFILE) + + def run(self): + reactor.run() + + def stop(self): + reactor.stop() + + +class RedirectToHTTPS(Resource): + + def __init__(self, old_port, new_port): + 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 redirectTo(url, request) + + +registerAdapter(SATSession, server.Session, ISATSession)