view libervia.tac @ 91:5f845345176e

browser side: selecting widget now change header color instead of dashed borders
author Goffi <goffi@goffi.org>
date Tue, 28 Jun 2011 02:20:02 +0200
parents 975e6be24e11
children c3fb3292f582
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
Libervia: a Salut à Toi frontend
Copyright (C) 2011  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/>.
"""

#You need do adapt the following consts to your server
_REG_EMAIL_FROM = "NOREPLY@libervia.org"
_REG_EMAIL_SERVER = "localhost"
_REG_ADMIN_EMAIL = "goffi@goffi.org"
_NEW_ACCOUNT_SERVER = "localhost"
_NEW_ACCOUNT_DOMAIN = "tazar.int"
_NEW_ACCOUNT_RESOURCE = "libervia"

from twisted.application import internet, service
from twisted.internet import glib2reactor
glib2reactor.install()
from twisted.internet import reactor, defer
from twisted.mail.smtp import sendmail
from twisted.web import server
from twisted.web import error as weberror
from twisted.web.static import File
from twisted.web.resource import Resource, NoResource
from twisted.python.components import registerAdapter
from twisted.words.protocols.jabber.jid import JID
from txjsonrpc.web import jsonrpc
from txjsonrpc import jsonrpclib
from sat_frontends.bridge.DBus import DBusBridgeFrontend,BridgeExceptionNoService
from email.mime.text import MIMEText
from logging import debug, info, warning, error
import re
import glob
import os.path
import sys
from server_side.blog import MicroBlog
from zope.interface import Interface, Attribute, implements

TIMEOUT = 10 #Session's time out, after that the user will be disconnected
LIBERVIA_DIR = "output/"
MEDIA_DIR = "media/"
CARDS_DIR = "games/cards/tarot"

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 = 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 id lifecycle"""
    ID_LIFETIME = 30 #after this time (in seconds), id will be suppressed and action result will be ignored

    def __init__(self):
        self.waiting_ids = {}

    def waitForId(self, id, callback, *args, **kwargs):
        """Wait for an action result
        @param id: id to wait for
        @param callback: method to call when action gave a result back
        @param *args: additional argument to pass to callback
        @param **kwargs: idem"""
        self.waiting_ids[id] = (callback, args, kwargs)
        reactor.callLater(self.ID_LIFETIME, self.purgeID, id)

    def purgeID(self, id):
        """Called when an id has not be handled in time"""
        if id in self.waiting_ids:
            warning ("action of id %s has not been managed, id is now ignored" % id)
            del self.waiting_ids[id]

    def actionResultCb(self, answer_type, id, data):
        """Manage the actionResult signal"""
        if id in self.waiting_ids:
            callback, args, kwargs = self.waiting_ids[id]
            del self.waiting_ids[id]
            callback(answer_type, id, data, *args, **kwargs)

class MethodHandler(jsonrpc.JSONRPC):

    def __init__(self, sat_host):
        jsonrpc.JSONRPC.__init__(self)
        self.sat_host=sat_host

    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(0, "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_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, status):
        """Change the status"""
        profile = ISATSession(self.session).profile
        self.sat_host.bridge.setPresence('', '', 0, {'':status}, profile)

    
    def jsonrpc_sendMessage(self, to_jid, msg, subject, type):
        """send message"""
        profile = ISATSession(self.session).profile
        return self.sat_host.bridge.sendMessage(to_jid, msg, subject, type, profile)

    def jsonrpc_sendMblog(self, raw_text):
        """Parse raw_text of the microblog box, and send message consequently"""
        profile = ISATSession(self.session).profile
        match = re.match(r'@(.+?): *(.*$)', raw_text)
        if match:
            recip = match.group(1)
            text = match.group(2)
            if recip == '@' and text:
                #This text if for the public microblog
                return self.sat_host.bridge.sendPersonalEvent("MICROBLOG", {'content':text}, profile)
            else:
                return self.sat_host.bridge.sendGroupBlog([recip], text, profile)

    def jsonrpc_getPresenceStatus(self):
        """Get Presence information for connected contacts"""
        profile = ISATSession(self.session).profile
        return self.sat_host.bridge.getPresenceStatus(profile) 

    def jsonrpc_getHistory(self, from_jid, to_jid, size):
        """Return history for the from_jid/to_jid couple"""
        #FIXME: this method should definitely be asynchrone, need to fix it !!!
        sat_session = ISATSession(self.session)
        profile = sat_session.profile
        sat_jid = sat_session.jid
        if not sat_jid:
            error("No jid saved for this profile")
            return {}
        if JID(from_jid).userhost() != sat_jid.userhost() and JID(to_jid).userhost() != sat_jid.userhost():
            error("Trying to get history from a different jid, maybe a hack attempt ?")
            return {}
        return self.sat_host.bridge.getHistory(from_jid, to_jid, size)

    def jsonrpc_joinMUC(self, room_jid, nick):
        """Join a Multi-User Chat room"""
        profile = ISATSession(self.session).profile
        try:
            room_jid = JID(room_jid)
        except:
            warning('Invalid room jid')
            return
        self.sat_host.bridge.joinMUC(room_jid.host, room_jid.user, nick, profile)

    def jsonrpc_getRoomJoined(self):
        """Return list of room already joined by user"""
        profile = ISATSession(self.session).profile
        return self.sat_host.bridge.getRoomJoined(profile) 

    def jsonrpc_launchTarotGame(self, other_players):
        """Create a room, invite the other players and start a Tarot game"""
        profile = ISATSession(self.session).profile
        self.sat_host.bridge.tarotGameLaunch(other_players, 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(MEDIA_DIR, x[len(_media_dir):]),glob.glob(_join(_media_dir,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)

    def jsonrpc_tarotGameContratChoosed(self, player_nick, referee, contrat):
        """Tell to the server that we are ready to start the game"""
        profile = ISATSession(self.session).profile
        self.sat_host.bridge.tarotGameContratChoosed(player_nick, referee, contrat, profile)
    
    def jsonrpc_tarotGamePlayCards(self, player_nick, referee, cards):
        """Tell to the server that we are ready to start the game"""
        profile = ISATSession(self.session).profile
        self.sat_host.bridge.tarotGamePlayCards(player_nick, referee, cards, profile)

class Register(jsonrpc.JSONRPC):
    """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):
        jsonrpc.JSONRPC.__init__(self)
        self.sat_host=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 _fillMblogNodes(self, result, session):
        """Fill the microblog nodes association for this session"""
        session.sat_mblog_nodes = dict(result)

    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
        """
        if request.postpath==['login']:
            return self.login(request)
        _session = request.getSession()
        parsed = jsonrpclib.loads(request.content.read())
        if parsed.get("method")!="isRegistered":
            #if we don't call login or isRegistered, we need to be identified
            profile = ISATSession(_session).profile
            if not profile:
                #user is not identified, we return a jsonrpc fault
                fault = jsonrpclib.Fault(0, "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 login(self, request):
        """
        this method is called with the POST information from the registering form
        it test if the password is ok, and log in if it's the case,
        else it return an error
        @param request: request of the register formulaire, must have "login" and "password" as arguments
        @return: A constant indicating the state:
            - BAD REQUEST: something is wrong in the request (bad arguments, profile_key for login)
            - AUTH ERROR: either the profile or the password is wrong
            - ALREADY WAITING: a request has already be made 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:
            if request.args['submit_type'][0] == 'login':
                _login = request.args['login'][0]
                if _login.startswith('@'):
                    raise Exception('No profile_key allowed')
                _pass = request.args['login_password'][0]
            
            elif request.args['submit_type'][0] == 'register':
                return self._registerNewAccount(request.args)
            
            else:
                raise Exception('Unknown submit type')
        except KeyError:
            return "BAD REQUEST"

        _profile_check = self.sat_host.bridge.getProfileName(_login)
        _profile_pass = self.sat_host.bridge.getParamA("Password", "Connection", profile_key=_login)

        if not _profile_check or _profile_check != _login or _profile_pass != _pass:
            return "AUTH ERROR"
        
        if self.profiles_waiting.has_key(_login):
            return "ALREADY WAITING"
        
        if self.sat_host.bridge.isConnected(_login):
            return self._logged(_login, request, finish=False)

        self.profiles_waiting[_login] = request
        self.sat_host.bridge.connect(_login) 
        return server.NOT_DONE_YET

    def _postAccountCreation(self, answer_type, id, data, profile):
        """Called when a account has just been created,
        setup stuff has microblog access"""
        def _connected(ignore):
            mblog_d = defer.Deferred()
            self.sat_host.bridge.setMicroblogAccess("open", profile, lambda: mblog_d.callback(None), mblog_d.errback)
            mblog_d.addBoth(lambda ignore: self.sat_host.bridge.disconnect(profile))
       
        d = defer.Deferred()
        self.sat_host.bridge.asyncConnect(profile, lambda: d.callback(None), d.errback)
        d.addCallback(_connected)

    def _registerNewAccount(self, args):
        """Create a new account, or return error
        @param args: dict of args as given by the form
        @return: "REGISTRATION" in case of success"""
        #TODO: must be moved in SàT core
        try:
            profile = login = args['register_login'][0]
            password = args['register_password'][0] #FIXME: password is ignored so far
            email = 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):
            return "BAD REQUEST"
        #_charset = [chr(i) for i in range(0x21,0x7F)] #XXX: this charset seems to have some issues with openfire
        _charset = [chr(i) for i in range(0x30,0x3A) + range(0x41,0x5B) + range (0x61,0x7B)]
        import random
        random.seed()
        password = ''.join([random.choice(_charset) for i in range(15)])

        if login in self.sat_host.bridge.getProfilesList(): #FIXME: must use a deferred + create a new profile check method
            return "ALREADY EXISTS"

        #we now create the profile
        self.sat_host.bridge.createProfile(login)
        #FIXME: values must be in a config file instead of hardcoded
        self.sat_host.bridge.setParam("JabberID", "%s@%s/%s" % (login, _NEW_ACCOUNT_DOMAIN, _NEW_ACCOUNT_RESOURCE), "Connection", profile) 
        self.sat_host.bridge.setParam("Server", _NEW_ACCOUNT_SERVER, "Connection", profile)
        self.sat_host.bridge.setParam("Password", password, "Connection", profile)
        #and the account
        action_id = self.sat_host.bridge.registerNewAccount(login, password, email, _NEW_ACCOUNT_DOMAIN, 5222)
        self.sat_host.action_handler.waitForId(action_id, self._postAccountCreation, profile)

        #time to send the email

        _email_host = _REG_EMAIL_SERVER
        _email_from = _REG_EMAIL_FROM

        def email_ok(ignore):
            print ("Account creation email sent to %s" % email)

        def email_ko(ignore):
            #TODO: return error code to user
            error ("Failed to send email to %s" % email)
        
        body = (u"""Welcome to Libervia, a Salut à Toi project part

/!\\ WARNING, THIS IS ONLY A TECHNICAL DEMO, DON'T USE THIS ACCOUNT FOR ANY SERIOUS PURPOSE /!\\

Here are your connection informations:
login: %(login)s
password: %(password)s

Your Jabber ID (JID) is: %(jid)s

Any feedback welcome

Cheers
Goffi""" % { 'login': login, 'password': password, 'jid':"%s@%s" % (login, _NEW_ACCOUNT_DOMAIN) }).encode('utf-8')
        msg = MIMEText(body, 'plain', 'UTF-8')
        msg['Subject'] = 'Libervia account created'
        msg['From'] = _email_from
        msg['To'] = email

        d = sendmail(_email_host, _email_from, email, msg.as_string())
        d.addCallbacks(email_ok, email_ko)

        #email to the administrator

        body = (u"""New account created: %(login)s [%(email)s]""" % { 'login': login, 'email': email }).encode('utf-8')
        msg = MIMEText(body, 'plain', 'UTF-8')
        msg['Subject'] = 'Libervia new account created'
        msg['From'] = _email_from
        msg['To'] = _REG_ADMIN_EMAIL

        d = sendmail(_email_host, _email_from, _REG_ADMIN_EMAIL, msg.as_string())
        d.addCallbacks(email_ok, email_ko)
        return "REGISTRATION"

    def __cleanWaiting(self, login):
        """Remove login from waiting queue"""
        try:
            del self.profiles_waiting[login]
        except KeyError:
            pass

    def _logged(self, profile, request, finish=True):
        """Set everything when a user just logged
        and return "LOGGED" to the requester"""
        def result(answer):
            if finish:
                request.write(answer)
                request.finish()
            else:
                return answer
            
        self.__cleanWaiting(profile)
        _session = request.getSession()
        sat_session = ISATSession(_session)
        if sat_session.profile:
            error (('/!\\ Session has already a profile, this should NEVER happen !'))
            return result('SESSION_ACTIVE')
        sat_session.profile = profile
        self.sat_host.prof_connected.add(profile)
        
        def onExpire():
            try:
                #We purge the queue
                del self.sat_host.signal_handler.queue[profile]
            except KeyError:
                pass
            #and now we deconnect the profile
            self.sat_host.bridge.disconnect(profile) 
       
        _session.notifyOnExpire(onExpire)
        
        d = defer.Deferred()
        self.sat_host.bridge.getMblogNodes(profile, d.callback, d.errback)
        d.addCallback(self._fillMblogNodes, _session)
        return result('LOGGED')

    def _logginError(self, login, request, error_type):
        """Something went wrong during loggin, return an 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):
        """Tell if the user is already registered"""
        _session = self.request.getSession()
        profile = ISATSession(_session).profile
        return bool(profile)
       
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(ignore):
            _session.unlock()
        self.signalDeferred[profile] = defer.Deferred()
        self.request.notifyFinish().addBoth(unlock)
        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 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(0, "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 Libervia(service.Service):
   
    def __init__(self):
        root = ProtectedFile(LIBERVIA_DIR) 
        self.signal_handler = SignalHandler(self)
        _register = Register(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)
        self.bridge.register("connected", self.signal_handler.connected)
        self.bridge.register("connectionError", self.signal_handler.connectionError)
        self.bridge.register("actionResult", self.action_handler.actionResultCb, "request") 
        for signal_name in ['presenceUpdate', 'personalEvent', 'newMessage', 'roomJoined', 'roomUserJoined', 'roomUserLeft', 'tarotGameStarted', 'tarotGameNew',
                            'tarotGameChooseContrat', 'tarotGameShowCards', 'tarotGameInvalidCards', 'tarotGameCardsPlayed', 'tarotGameYourTurn', 'tarotGameScore',
                            'subscribe', 'contactDeleted', 'newContact']:
            self.bridge.register(signal_name, self.signal_handler.getGenericCb(signal_name))
        self.media_dir = self.bridge.getConfig('','media_dir')
        root.putChild('json_signal_api', self.signal_handler)
        root.putChild('json_api', MethodHandler(self))
        root.putChild('register_api', _register)
        root.putChild('blog', MicroBlog(self))
        root.putChild('css', ProtectedFile("server_css/"))
        root.putChild(os.path.dirname(MEDIA_DIR), ProtectedFile(self.media_dir))
        self.site = server.Site(root)
        self.site.sessionFactory = LiberviaSession

    def startService(self):
        reactor.listenTCP(8080, self.site)
    
    def run(self):
        reactor.run()
    
    def stop(self):
        reactor.stop()


registerAdapter(SATSession, server.Session, ISATSession)
application = service.Application('Libervia')
service = Libervia()
service.setServiceParent(application)