File indexing completed on 2024-04-14 03:59:13

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 SPDX-License-Identifier: GPL-2.0
0007 
0008 
0009 The DBPasswordChecker is based on an example from the book
0010 Twisted Network Programming Essentials by Abe Fettig, 2006
0011 O'Reilly Media, Inc., ISBN 0-596-10032-9
0012 """
0013 
0014 # pylint: disable=wrong-import-order, wrong-import-position
0015 
0016 import sys
0017 import os
0018 import logging
0019 import datetime
0020 
0021 from zope.interface import implementer
0022 
0023 
0024 def cleanExit(*unusedArgs): # pylint: disable=unused-argument
0025     """we want to cleanly close sqlite3 files"""
0026     if Debug.quit:
0027         logDebug('cleanExit')
0028     if Options.socket and os.name != 'nt':
0029         if os.path.exists(Options.socket):
0030             os.remove(Options.socket)
0031     try:
0032         if Internal.db:
0033             Internal.db.close()
0034                               # setting to None does not call close(), do we
0035                               # need close?
0036         logging.shutdown()
0037         os._exit(0)  # pylint: disable=protected-access
0038     except NameError:
0039         logging.shutdown()
0040     try:
0041         reactor.stop()
0042     except NameError:
0043         sys.exit(0)
0044     except ReactorNotRunning:
0045         pass
0046 
0047 from common import handleSignals
0048 handleSignals(cleanExit)
0049 
0050 from common import Options, Internal, Debug
0051 Internal.isServer = True
0052 Internal.logPrefix = 'S'
0053 
0054 from twisted.spread import pb
0055 from twisted.internet import error
0056 from twisted.internet.defer import maybeDeferred, fail, succeed
0057 from twisted.cred import checkers, portal, credentials, error as credError
0058 from twisted.internet import reactor
0059 from twisted.internet.error import ReactorNotRunning
0060 reactor.addSystemEventTrigger('before', 'shutdown', cleanExit)
0061 Internal.reactor = reactor
0062 
0063 from player import Players
0064 from query import Query, initDb
0065 from log import logDebug, logWarning, logError, logInfo, SERVERMARK
0066 from mi18n import i18n, i18nE
0067 from util import elapsedSince
0068 from message import Message, ChatMessage
0069 from deferredutil import DeferredBlock
0070 from rule import Ruleset
0071 from servercommon import srvError, srvMessage
0072 from user import User
0073 from servertable import ServerTable, ServerGame
0074 
0075 
0076 @implementer(checkers.ICredentialsChecker)
0077 class DBPasswordChecker:
0078 
0079     """checks against our sqlite3 databases"""
0080     credentialInterfaces = (credentials.IUsernamePassword,
0081                             credentials.IUsernameHashedPassword)
0082 
0083     def requestAvatarId(self, cred):  # pylint: disable=no-self-use
0084         """get user id from database"""
0085         cred.username = cred.username.decode('utf-8')
0086         args = cred.username.split(SERVERMARK)
0087         if len(args) > 1:
0088             if args[0] == 'adduser':
0089                 cred.username = args[1]
0090                 password = args[2]
0091                 query = Query(
0092                     'insert or ignore into player(name,password) values(?,?)',
0093                     (cred.username,
0094                      password))
0095             elif args[1] == 'deluser':
0096                 pass
0097         query = Query(
0098             'select id, password from player where name=?', (cred.username,))
0099         if not query.records:
0100             template = 'Wrong username: %1'
0101             if Debug.connections:
0102                 logDebug(i18n(template, cred.username))
0103             return fail(credError.UnauthorizedLogin(srvMessage(template, cred.username)))
0104         userid, password = query.records[0]
0105         defer1 = maybeDeferred(cred.checkPassword, password.encode('utf-8'))
0106         defer1.addCallback(DBPasswordChecker._checkedPassword, userid)
0107         return defer1
0108 
0109     @staticmethod
0110     def _checkedPassword(matched, userid):
0111         """after the password has been checked"""
0112         if not matched:
0113             return fail(credError.UnauthorizedLogin(srvMessage(i18nE('Wrong password'))))
0114         return userid
0115 
0116 
0117 class MJServer:
0118 
0119     """the real mah jongg server"""
0120 
0121     def __init__(self):
0122         self.tables = {}
0123         self.srvUsers = list()
0124         Players.load()
0125         self.lastPing = datetime.datetime.now()
0126         self.checkPings()
0127 
0128     def chat(self, chatString):
0129         """a client sent us a chat message"""
0130         chatLine = ChatMessage(chatString)
0131         if Debug.chat:
0132             logDebug('server got chat message %s' % chatLine)
0133         self.tables[chatLine.tableid].sendChatMessage(chatLine)
0134 
0135     def login(self, user):
0136         """accept a new user"""
0137         if user not in self.srvUsers:
0138             self.srvUsers.append(user)
0139             self.loadSuspendedTables(user)
0140 
0141     def callRemote(self, user, *args, **kwargs):
0142         """if we still have a connection, call remote, otherwise clean up"""
0143         if user.mind:
0144             try:
0145                 args2, kwargs2 = Message.jellyAll(args, kwargs)
0146                 return user.mind.callRemote(*args2, **kwargs2).addErrback(MJServer.ignoreLostConnection)
0147             except (pb.DeadReferenceError, pb.PBConnectionLost):
0148                 user.mind = None
0149                 self.logout(user)
0150         return None
0151 
0152     @staticmethod
0153     def __stopAfterLastDisconnect():
0154         """as the name says"""
0155         if Options.socket and not Options.continueServer:
0156             try:
0157                 reactor.stop()
0158                 if Debug.connections:
0159                     logDebug('local server terminates from %s. Reason: last client disconnected' % (
0160                         Options.socket))
0161             except ReactorNotRunning:
0162                 pass
0163 
0164     def checkPings(self):
0165         """are all clients still alive? If not log them out"""
0166         since = elapsedSince(self.lastPing)
0167         if self.srvUsers and since > 30:
0168             if Debug.quit:
0169                 logDebug('no ping since {} seconds but we still have users:{}'.format(
0170                     elapsedSince(self.lastPing), self.srvUsers))
0171         if not self.srvUsers and since > 30:
0172             # no user at all since 30 seconds, but we did already have a user
0173             self.__stopAfterLastDisconnect()
0174         for user in self.srvUsers:
0175             if elapsedSince(user.lastPing) > 60:
0176                 logInfo(
0177                     'No messages from %s since 60 seconds, clearing connection now' %
0178                     user.name)
0179                 user.mind = None
0180                 self.logout(user)
0181         reactor.callLater(10, self.checkPings)
0182 
0183     @staticmethod
0184     def ignoreLostConnection(failure):
0185         """if the client went away correctly, do not dump error messages on stdout."""
0186         msg = failure.getErrorMessage()
0187         if 'twisted.internet.error.ConnectionDone' not in msg:
0188             logError(msg)
0189         failure.trap(pb.PBConnectionLost)
0190 
0191     def sendTables(self, user, tables=None):
0192         """send tables to user. If tables is None, he gets all new tables and those
0193         suspended tables he was sitting on"""
0194         if tables is None:
0195             tables = [
0196                 x for x in self.tables.values()
0197                 if not x.running and (not x.suspendedAt or x.hasName(user.name))]
0198         if tables:
0199             data = [x.asSimpleList() for x in tables]
0200             if Debug.table:
0201                 logDebug(
0202                     'sending %d tables to %s: %s' %
0203                     (len(tables), user.name, data))
0204             return self.callRemote(user, 'newTables', data)
0205         return succeed([])
0206 
0207     def _lookupTable(self, tableid):
0208         """return table by id or raise exception"""
0209         if tableid not in self.tables:
0210             raise srvError(
0211                 pb.Error,
0212                 i18nE('table with id <numid>%1</numid> not found'),
0213                 tableid)
0214         return self.tables[tableid]
0215 
0216     def generateTableId(self):
0217         """generates a new table id: the first free one"""
0218         usedIds = set(self.tables or [0])
0219         availableIds = set(x for x in range(1, 2 + max(usedIds)))
0220         return min(availableIds - usedIds)
0221 
0222     def newTable(self, user, ruleset, playOpen,
0223                  autoPlay, wantedGame, tableId=None):
0224         """user creates new table and joins it"""
0225         def gotRuleset(ruleset):
0226             """now we have the full ruleset definition from the client"""
0227             Ruleset.cached(
0228                 ruleset).save()  # make it known to the cache and save in db
0229         if tableId in self.tables:
0230             return fail(srvError(pb.Error,
0231                                  'You want a new table with id=%d but that id is already used for table %s' % (
0232                                      tableId, self.tables[tableId])))
0233         if Ruleset.hashIsKnown(ruleset):
0234             return self.__newTable(None, user, ruleset, playOpen, autoPlay, wantedGame, tableId)
0235         return self.callRemote(user, 'needRuleset', ruleset).addCallback(
0236             gotRuleset).addCallback(
0237                 self.__newTable, user, ruleset, playOpen, autoPlay, wantedGame, tableId)
0238 
0239     def __newTable(self, unused, user, ruleset,
0240                    playOpen, autoPlay, wantedGame, tableId=None):
0241         """now we know the ruleset"""
0242         def sent(unused):
0243             """new table sent to user who created it"""
0244             return table.tableid
0245         table = ServerTable(
0246             self,
0247             user,
0248             ruleset,
0249             None,
0250             playOpen,
0251             autoPlay,
0252             wantedGame,
0253             tableId)
0254         result = None
0255         for srvUser in self.srvUsers:
0256             deferred = self.sendTables(srvUser, [table])
0257             if user == srvUser:
0258                 result = deferred
0259                 deferred.addCallback(sent)
0260         assert result
0261         return result
0262 
0263     def needRulesets(self, rulesetHashes):
0264         """the client wants those full rulesets"""
0265         result = []
0266         for table in self.tables.values():
0267             if table.ruleset.hash in rulesetHashes:
0268                 result.append(table.ruleset.toList())
0269         return result
0270 
0271     def joinTable(self, user, tableid):
0272         """user joins table"""
0273         table = self._lookupTable(tableid)
0274         table.addUser(user)
0275         block = DeferredBlock(table)
0276         block.tell(
0277             None,
0278             self.srvUsers,
0279             Message.TableChanged,
0280             source=table.asSimpleList())
0281         if len(table.users) == table.maxSeats():
0282             if Debug.table:
0283                 logDebug('Table %s: All seats taken, starting' % table)
0284 
0285             def startTable(unused):
0286                 """now all players know about our join"""
0287                 table.readyForGameStart(table.owner)
0288             block.callback(startTable)
0289         else:
0290             block.callback(False)
0291         return True
0292 
0293     def tablesWith(self, user):
0294         """table ids with user, except table 'without'"""
0295         return [x.tableid for x in self.tables.values() if user in x.users]
0296 
0297     def leaveTable(self, user, tableid, message, *args):
0298         """user leaves table. If no human user is left on a new table, remove it"""
0299         if tableid in self.tables:
0300             table = self.tables[tableid]
0301             if user in table.users:
0302                 if len(table.users) == 1 and not table.suspendedAt:
0303                     # silent: do not tell the user who left the table that he
0304                     # did
0305                     self.removeTable(table, 'silent', message, *args)
0306                 else:
0307                     table.delUser(user)
0308                     if self.srvUsers:
0309                         block = DeferredBlock(table)
0310                         block.tell(
0311                             None,
0312                             self.srvUsers,
0313                             Message.TableChanged,
0314                             source=table.asSimpleList())
0315                         block.callback(False)
0316         return True
0317 
0318     def startGame(self, user, tableid):
0319         """try to start the game"""
0320         return self._lookupTable(tableid).readyForGameStart(user)
0321 
0322     def removeTable(self, table, reason, message, *args):
0323         """remove a table"""
0324         assert reason in ('silent', 'tableRemoved', 'gameOver', 'abort')
0325         # HumanClient implements methods remote_tableRemoved etc.
0326         message = message or ''
0327         if Debug.connections or reason == 'abort':
0328             logDebug(
0329                 '%s%s ' % (('%s:' % table.game.seed) if table.game else '',
0330                            i18n(message, *args)), withGamePrefix=None)
0331         if table.tableid in self.tables:
0332             del self.tables[table.tableid]
0333             if reason == 'silent':
0334                 tellUsers = []
0335             else:
0336                 tellUsers = table.users if table.running else self.srvUsers
0337             for user in tellUsers:
0338                 # this may in turn call removeTable again!
0339                 self.callRemote(user, reason, table.tableid, message, *args)
0340             for user in table.users:
0341                 table.delUser(user)
0342             if Debug.table:
0343                 logDebug(
0344                     'removing table %d: %s %s' %
0345                     (table.tableid, i18n(message, *args), reason))
0346         if table.game:
0347             table.game.close()
0348 
0349     def logout(self, user):
0350         """remove user from all tables"""
0351         if user not in self.srvUsers:
0352             return
0353         self.srvUsers.remove(user)
0354         for tableid in self.tablesWith(user):
0355             self.leaveTable(
0356                 user,
0357                 tableid,
0358                 i18nE('Player %1 has logged out'),
0359                 user.name)
0360         # wait a moment. We want the leaveTable message to arrive everywhere before
0361         # we say serverDisconnects. Sometimes the order was reversed.
0362         reactor.callLater(1, self.__logout2, user)
0363 
0364     def __logout2(self, user):
0365         """now the leaveTable message had a good chance to get to the clients first"""
0366         self.callRemote(user, 'serverDisconnects')
0367         user.mind = None
0368         for block in DeferredBlock.blocks:
0369             for request in block.requests:
0370                 if request.user == user:
0371                     request.answer = Message.Abort
0372 
0373     def loadSuspendedTables(self, user):
0374         """loads all yet unloaded suspended tables where this
0375         user is participating. We do not unload them if the
0376         user logs out, there are filters anyway returning only
0377         the suspended games for a certain user.
0378         Never load old autoplay games."""
0379         query = Query("select distinct g.id, g.starttime, "
0380                       "g.seed, "
0381                       "ruleset, s.scoretime "
0382                       "from game g, player p0, score s,"
0383                       "player p1, player p2, player p3 "
0384                       "where autoplay=0 "
0385                       " and p0.id=g.p0 and p1.id=g.p1 "
0386                       " and p2.id=g.p2 and p3.id=g.p3 "
0387                       " and (p0.name=? or p1.name=? or p2.name=? or p3.name=?) "
0388                       " and s.game=g.id"
0389                       " and g.endtime is null"
0390                       " and exists(select 1 from ruleset where ruleset.id=g.ruleset)"
0391                       " and exists(select 1 from score where game=g.id)"
0392                       " and s.scoretime = (select max(scoretime) from score where game=g.id) limit 10",
0393                       (user.name, user.name, user.name, user.name))
0394         for gameid, _, seed, ruleset, suspendTime in query.records:
0395             if gameid not in (x.game.gameid for x in self.tables.values() if x.game):
0396                 table = ServerTable(
0397                     self, None, ruleset, suspendTime, playOpen=False,
0398                     autoPlay=False, wantedGame=str(seed))
0399                 table.game = ServerGame.loadFromDB(gameid)
0400 
0401 
0402 @implementer(portal.IRealm)
0403 class MJRealm:
0404 
0405     """connects mind and server"""
0406 
0407     def __init__(self):
0408         self.server = None
0409 
0410     def requestAvatar(self, avatarId, mind, *interfaces):
0411         """as the tutorials do..."""
0412         if pb.IPerspective not in interfaces:
0413             raise NotImplementedError("No supported avatar interface")
0414         avatar = User(avatarId)
0415         avatar.server = self.server
0416         avatar.attached(mind)
0417         if Debug.connections:
0418             logDebug('Connection from %s ' % avatar.source())
0419         return pb.IPerspective, avatar, lambda a=avatar: a.detached(mind)
0420 
0421 
0422 def parseArgs():
0423     """as the name says"""
0424     from optparse import OptionParser
0425     parser = OptionParser()
0426     defaultPort = Internal.defaultPort
0427     parser.add_option('', '--port', dest='port',
0428                       help=i18n(
0429                           'the server will listen on PORT (%d)' %
0430                           defaultPort),
0431                       type=int, default=defaultPort)
0432     parser.add_option('', '--socket', dest='socket',
0433                       help=i18n('the server will listen on SOCKET'), default=None)
0434     parser.add_option(
0435         '',
0436         '--db',
0437         dest='dbpath',
0438         help=i18n('name of the database'),
0439         default=None)
0440     parser.add_option(
0441         '', '--continue', dest='continueServer', action='store_true',
0442         help=i18n('do not terminate local game server after last client disconnects'), default=False)
0443     parser.add_option('', '--debug', dest='debug',
0444                       help=Debug.help())
0445     (options, args) = parser.parse_args()
0446     if args and ''.join(args):
0447         logWarning(i18n('unrecognized arguments:%1', ' '.join(args)))
0448         sys.exit(2)
0449     Options.continueServer |= options.continueServer
0450     if options.dbpath:
0451         Options.dbPath = os.path.expanduser(options.dbpath)
0452     if options.socket:
0453         Options.socket = options.socket
0454     Debug.setOptions(options.debug)
0455     Options.fixed = True  # may not be changed anymore
0456     del parser           # makes Debug.gc quieter
0457     return options
0458 
0459 
0460 def kajonggServer():
0461     """start the server"""
0462     # pylint: disable=too-many-branches
0463     options = parseArgs()
0464     if not initDb():
0465         sys.exit(1)
0466     realm = MJRealm()
0467     realm.server = MJServer()
0468     kajonggPortal = portal.Portal(realm, [DBPasswordChecker()])
0469     import predefined
0470     predefined.load()
0471     try:
0472         if Options.socket:
0473             # we do not want tracebacks to go from server to client,
0474             # please check on the server side instead
0475             factory = pb.PBServerFactory(kajonggPortal, unsafeTracebacks=False)
0476             if os.name == 'nt':
0477                 if Debug.connections:
0478                     logDebug(
0479                         'local server listening on 127.0.0.1 port %d' %
0480                         options.port)
0481                 reactor.listenTCP(options.port, factory, interface='127.0.0.1')
0482             else:
0483                 if Debug.connections:
0484                     logDebug(
0485                         'local server listening on UNIX socket %s' %
0486                         Options.socket)
0487                 reactor.listenUNIX(Options.socket, factory)
0488         else:
0489             if Debug.connections:
0490                 logDebug('server listening on port %d' % options.port)
0491             reactor.listenTCP(options.port, pb.PBServerFactory(kajonggPortal))
0492     except error.CannotListenError as errObj:
0493         logWarning(errObj)
0494         sys.exit(1)
0495     else:
0496         reactor.run()
0497 
0498 
0499 def profileMe():
0500     """where do we lose time?"""
0501     import cProfile
0502     cProfile.run('kajonggServer()', 'prof')
0503     import pstats
0504     statistics = pstats.Stats('prof')
0505     statistics.sort_stats('cumulative')
0506     statistics.print_stats(40)