1import codecs
2import datetime
3import logging
4import os
5import random
6import signal
7import subprocess
8import time
9import zlib
10
11import tornado.ioloop
12import tornado.template
13import tornado.websocket
14from tornado.escape import json_decode
15from tornado.escape import json_encode
16from tornado.escape import to_unicode
17from tornado.escape import utf8
18from tornado.ioloop import IOLoop
19
20import auth
21import checkoutput
22import config
23import userdb
24import util
25import load_games
26from util import *
27
28try:
29    from typing import Dict, Set, Tuple, Any, Union, Optional
30except:
31    pass
32
33sockets = set() # type: Set[CrawlWebSocket]
34current_id = 0
35shutting_down = False
36rand = random.SystemRandom()
37
38def shutdown():
39    global shutting_down
40    shutting_down = True
41    for socket in list(sockets):
42        socket.shutdown()
43
44def update_global_status():
45    write_dgl_status_file()
46
47def update_all_lobbys(game):
48    lobby_entry = game.lobby_entry()
49    for socket in list(sockets):
50        if socket.is_in_lobby():
51            socket.send_message("lobby_entry", **lobby_entry)
52def remove_in_lobbys(process):
53    for socket in list(sockets):
54        if socket.is_in_lobby():
55            socket.send_message("lobby_remove", id=process.id,
56                                reason=process.exit_reason,
57                                message=process.exit_message,
58                                dump=process.exit_dump_url)
59
60def global_announce(text):
61    for socket in list(sockets):
62        socket.send_announcement(text)
63
64def write_dgl_status_file():
65    process_info = ["%s#%s#%s#0x0#%s#%s#" %
66                            (socket.username, socket.game_id,
67                             (socket.process.human_readable_where()),
68                             str(socket.process.idle_time()),
69                             str(socket.process.watcher_count()))
70                        for socket in list(sockets)
71                        if socket.username and socket.is_running()]
72    try:
73        with open(config.dgl_status_file, "w") as f:
74            f.write("\n".join(process_info))
75    except (OSError, IOError) as e:
76        logging.warning("Could not write dgl status file: %s", e)
77
78def status_file_timeout():
79    write_dgl_status_file()
80    IOLoop.current().add_timeout(time.time() + config.status_file_update_rate,
81                                 status_file_timeout)
82
83def find_user_sockets(username):
84    for socket in list(sockets):
85        if socket.username and socket.username.lower() == username.lower():
86            yield socket
87
88def find_running_game(charname, start):
89    from process_handler import processes
90    for process in list(processes.values()):
91        if (process.where.get("name") == charname and
92            process.where.get("start") == start):
93            return process
94    return None
95
96
97def _milestone_files():
98    # First we collect all milestone files explicitly specified in the config.
99    files = set()
100
101    top_level_milestones = getattr(config, 'milestone_file', None)
102    if top_level_milestones is not None:
103        if not isinstance(top_level_milestones, list):
104            top_level_milestones = [top_level_milestones]
105        files.update(top_level_milestones)
106
107    for game_config in config.games.values():
108        milestone_file = game_config.get('milestone_file')
109        if milestone_file is None and 'dir_path' in game_config:
110            # milestone appears in this dir by default
111            milestone_file = os.path.join(game_config['dir_path'], 'milestones')
112        if milestone_file is not None:
113            files.add(milestone_file)
114
115    # Then, make sure for every milestone we have the -seeded and non-seeded
116    # variant.
117    new_files = set()
118    for f in files:
119        if f.endswith('-seeded'):
120            new_files.add(f[:-7])
121        else:
122            new_files.add(f + '-seeded')
123    files.update(new_files)
124
125    # Finally, drop any files that don't exist
126    files = [f for f in files if os.path.isfile(f)]
127
128    return files
129
130milestone_file_tailers = []
131def start_reading_milestones():
132    milestone_files = _milestone_files()
133    for f in milestone_files:
134        milestone_file_tailers.append(FileTailer(f, handle_new_milestone))
135
136def handle_new_milestone(line):
137    data = parse_where_data(line)
138    if "name" not in data: return
139    game = find_running_game(data.get("name"), data.get("start"))
140    if game: game.log_milestone(data)
141
142# decorator for admin calls
143def admin_required(f):
144    def wrapper(self, *args, **kwargs):
145        if not self.is_admin():
146            logging.error("Non-admin user '%s' attempted admin function '%s'" %
147                (self.username and self.username or "[Anon]", f.__name__))
148            return
149        return f(self, *args, **kwargs)
150    return wrapper
151
152class CrawlWebSocket(tornado.websocket.WebSocketHandler):
153    def __init__(self, app, req, **kwargs):
154        tornado.websocket.WebSocketHandler.__init__(self, app, req, **kwargs)
155        self.username = None
156        self.user_id = None
157        self.user_email = None
158        self.timeout = None
159        self.watched_game = None
160        self.process = None
161        self.game_id = None
162        self.received_pong = None
163        self.save_info = dict()
164
165        tornado.ioloop.IOLoop.current()
166
167        global current_id
168        self.id = current_id
169        current_id += 1
170
171        self.deflate = True
172        self._compressobj = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION,
173                                             zlib.DEFLATED,
174                                             -zlib.MAX_WBITS)
175        self.total_message_bytes = 0
176        self.compressed_bytes_sent = 0
177        self.uncompressed_bytes_sent = 0
178        self.message_queue = []  # type: List[str]
179
180        self.subprotocol = None
181
182        self.chat_hidden = False
183
184        self.logger = logging.LoggerAdapter(logging.getLogger(), {})
185        self.logger.process = self._process_log_msg
186
187        self.message_handlers = {
188            "login": self.login,
189            "token_login": self.token_login,
190            "set_login_cookie": self.set_login_cookie,
191            "forget_login_cookie": self.forget_login_cookie,
192            "play": self.start_crawl,
193            "pong": self.pong,
194            "watch": self.watch,
195            "chat_msg": self.post_chat_message,
196            "register": self.register,
197            "start_change_email": self.start_change_email,
198            "change_email": self.change_email,
199            "start_change_password": self.start_change_password,
200            "change_password": self.change_password,
201            "forgot_password": self.forgot_password,
202            "reset_password": self.reset_password,
203            "go_lobby": self.go_lobby,
204            "go_admin": self.go_admin,
205            "get_rc": self.get_rc,
206            "set_rc": self.set_rc,
207            "admin_announce": self.admin_announce,
208            "admin_pw_reset": self.admin_pw_reset,
209            "admin_pw_reset_clear": self.admin_pw_reset_clear
210            }
211
212    @admin_required
213    def admin_announce(self, text):
214        global_announce(text)
215        self.logger.info("User '%s' sent serverwide announcement: %s", self.username, text)
216        self.send_message("admin_log", text="Announcement made ('" + text + "')")
217
218    @admin_required
219    def admin_pw_reset(self, username):
220        user_info = userdb.get_user_info(username)
221        if not user_info:
222            self.send_message("admin_pw_reset_done", error="Invalid user")
223            return
224        ok, msg = userdb.generate_forgot_password(username)
225        if not ok:
226            self.send_message("admin_pw_reset_done", error=msg)
227        else:
228            self.logger.info("Admin user '%s' set a password token on account '%s'", self.username, username)
229            self.send_message("admin_pw_reset_done", email_body=msg, username=username, email=user_info[1])
230
231    @admin_required
232    def admin_pw_reset_clear(self, username):
233        ok, err = userdb.clear_password_token(username)
234        if ok:
235            self.logger.info("Admin user '%s' cleared the reset token on account '%s'", self.username, username)
236        else:
237            self.logger.info("Error trying to clear reset token for '%s': %s", username, err)
238
239    client_closed = property(lambda self: (not self.ws_connection) or self.ws_connection.client_terminated)
240
241    def _process_log_msg(self, msg, kwargs):
242        return "#%-5s %s" % (self.id, msg), kwargs
243
244    def __hash__(self):
245        return self.id
246
247    def __eq__(self, other):
248        return self.id == other.id
249
250    def allow_draft76(self):
251        return True
252
253    def select_subprotocol(self, subprotocols):
254        if "no-compression" in subprotocols:
255            self.deflate = False
256            self.subprotocol = "no-compression"
257            return "no-compression"
258        return None
259
260    def open(self):
261        compression = "on"
262        if isinstance(self.ws_connection, getattr(tornado.websocket, "WebSocketProtocol76", ())):
263            # Old Websocket versions don't support binary messages
264            self.deflate = False
265            compression = "off, old websockets"
266        elif self.subprotocol == "no-compression":
267            compression = "off, client request"
268        if hasattr(self, "get_extensions"):
269            if any(s.endswith("deflate-frame") for s in self.get_extensions()):
270                self.deflate = False
271                compression = "deflate-frame extension"
272
273        self.logger.info("Socket opened from ip %s (fd%s, compression: %s).",
274                         self.request.remote_ip,
275                         self.ws_connection.stream.socket.fileno(),
276                         compression)
277        sockets.add(self)
278
279        self.reset_timeout()
280
281        if config.max_connections < len(sockets):
282            self.append_message("connection_closed('The maximum number of "
283                              + "connections has been reached, sorry :(');")
284            self.close()
285        elif shutting_down:
286            self.close()
287        else:
288            if config.dgl_mode:
289                if hasattr(config, "autologin") and config.autologin:
290                    self.do_login(config.autologin)
291                self.send_lobby()
292            else:
293                self.start_crawl(None)
294
295    def check_origin(self, origin):
296        return True
297
298    def idle_time(self):
299        return self.process.idle_time()
300
301    def is_running(self):
302        return self.process is not None
303
304    def is_in_lobby(self):
305        return not self.is_running() and self.watched_game is None
306
307    def send_lobby(self):
308        self.queue_message("lobby_clear")
309        from process_handler import processes
310        for process in list(processes.values()):
311            self.queue_message("lobby_entry", **process.lobby_entry())
312        self.send_message("lobby_complete")
313        self.send_lobby_html()
314
315    def send_announcement(self, text):
316        # TODO: something in lobby?
317        if not self.is_in_lobby():
318            # show in chat window
319            self.send_message("server_announcement", text=text)
320            # show in player message window
321            if self.is_running():
322                self.process.handle_announcement(text)
323
324    def invalidate_saveslot_cache(self, slot):
325        # TODO: the following will get false positives. However, to do any
326        # better would need some non-trivial refactoring of how crawl handles
327        # save slots (which is a bit insane). A heuristic might be to check the
328        # binary, but in practice this doesn't help as most servers launch
329        # crawl via a wrapper script, not via a direct call.
330        if self.save_info.get(slot, None) is not None:
331            cache_check = "" if self.save_info[slot] == "" else "[slot full]"
332            for g in self.save_info:
333                if self.save_info[g] == cache_check:
334                    self.save_info[g] = None
335        self.save_info[slot] = None
336
337    # collect save info for the player from all binaries that support save
338    # info json. Cached on self.save_info. This is asynchronously done using
339    # a somewhat involved callback chain.
340    def collect_save_info(self, final_callback):
341        if not self.username:
342            return
343
344        # this code would be much simpler refactored using async
345        def build_callback(game_key, call, next_callback):
346            def update_save_info(data, returncode):
347                global sockets
348                if not self in sockets:
349                    return
350                if returncode == 0:
351                    try:
352                        save_dict = json_decode(data)[load_games.game_modes[game_key]]
353                        if not save_dict["loadable"]:
354                            # the save in this slot is in use.
355                            self.save_info[game_key] = "[playing]" # TODO: something better??
356                        elif load_games.game_modes[game_key] == save_dict["game_type"]:
357                            # save in the slot matches the game type we are
358                            # checking.
359                            self.save_info[game_key] = "[" + save_dict["short_desc"] + "]"
360                        else:
361                            # There is a save, but it has a different game type.
362                            # This happens if multiple game types share a slot.
363                            self.save_info[game_key] = "[slot full]"
364                    except Exception:
365                        # game key missing (or other error). This will mainly
366                        # happen if there are no saves at all for the player
367                        # name. It can also happen under some dgamelaunch-config
368                        # setups if escape codes are incorrectly inserted into
369                        # the output for some calls. See:
370                        # https://github.com/crawl/dgamelaunch-config/commit/6ad788ceb5614b3c83d65b61bf26a122e592b98d
371                        self.save_info[game_key] = ""
372                else:
373                    # error in the subprocess: this will happen if the binary
374                    # does not support `-save-json`. Print a warning so that
375                    # the admin can see that they have save info enabled
376                    # incorrectly for this binary.
377                    logging.warn("Save info check for '%s' failed" % game_key)
378                    self.save_info[game_key] = ""
379                next_callback()
380            return lambda: checkoutput.check_output(call, update_save_info)
381
382        callback = final_callback
383        for g in config.games:
384            game = config.games[g]
385            if not game.get("show_save_info", False):
386                self.save_info[g] = ""
387                continue
388            if self.save_info.get(g, None) is None:
389                # cache for g is invalid, add a callback for it to the callback
390                # chain
391                call = [game["crawl_binary"]]
392                if "pre_options" in game:
393                    call += game["pre_options"]
394                call += ["-save-json", self.username]
395                callback = build_callback(g, call, callback)
396
397        callback()
398
399    def send_lobby_html(self):
400        # Rerender Banner
401        # TODO: don't really need to do this every time the lobby is loaded?
402        banner_html = to_unicode(self.render_string("banner.html",
403                                                    username = self.username))
404        self.queue_message("html", id = "banner", content = banner_html)
405
406        if not self.username:
407            return
408        def disable_check(s):
409            return s == "[slot full]"
410        def send_game_links():
411            global sockets
412            if not self in sockets:
413                return # socket closed by the time this info was collected
414            self.queue_message("html", id = "banner", content = banner_html)
415            # TODO: dynamically send this info as it comes in, rather than
416            # rendering it all at the end?
417            try:
418                play_html = to_unicode(self.render_string("game_links.html",
419                                                  games = config.games,
420                                                  save_info = self.save_info,
421                                                  disabled = disable_check))
422                self.send_message("set_game_links", content = play_html)
423            except:
424                self.logger.warning("Error on send_game_links callback",
425                                                                exc_info=True)
426        # if no game links at all have been sent, immediately render the
427        # empty version. This is so that if the server takes a while on
428        # initial connect, the player sees something immediately.
429        if len(self.save_info) == 0:
430            for g in config.games:
431                self.save_info[g] = None
432            send_game_links()
433        self.collect_save_info(send_game_links)
434
435    def reset_timeout(self):
436        if self.timeout:
437            IOLoop.current().remove_timeout(self.timeout)
438
439        self.received_pong = False
440        self.send_message("ping")
441        self.timeout = IOLoop.current().add_timeout(
442                                        time.time() + config.connection_timeout,
443                                        self.check_connection)
444
445    def check_connection(self):
446        self.timeout = None
447
448        if not self.received_pong:
449            self.logger.info("Connection timed out.")
450            self.close()
451        else:
452            if self.is_running() and self.process.idle_time() > config.max_idle_time:
453                self.logger.info("Stopping crawl after idle time limit.")
454                self.process.stop()
455
456        if not self.client_closed:
457            self.reset_timeout()
458
459    def start_crawl(self, game_id):
460        if config.dgl_mode and game_id not in config.games:
461            self.go_lobby()
462            return
463
464        if config.dgl_mode:
465            game_params = dict(config.games[game_id])
466            if self.username == None:
467                if self.watched_game:
468                    self.stop_watching()
469                self.send_message("login_required", game = game_params["name"])
470                return
471
472        if self.process:
473            # ignore multiple requests for the same game, can happen when
474            # logging in with cookies
475            if self.game_id != game_id:
476                self.go_lobby()
477            return
478
479        self.game_id = game_id
480
481        # invalidate cached save info for lobby
482        # TODO: invalidate for other sockets of the same player?
483        self.invalidate_saveslot_cache(game_id)
484
485        import process_handler
486
487        if config.dgl_mode:
488            game_params["id"] = game_id
489            args = (game_params, self.username, self.logger)
490            self.process = process_handler.CrawlProcessHandler(*args)
491        else:
492            self.process = process_handler.DGLLessCrawlProcessHandler(self.logger)
493
494        self.process.end_callback = self._on_crawl_end
495        self.process.add_watcher(self)
496        try:
497            self.process.start()
498        except Exception:
499            self.logger.warning("Exception starting process!", exc_info=True)
500            self.process = None
501            self.go_lobby()
502        else:
503            if self.process is None: # Can happen if the process creation fails
504                self.go_lobby()
505                return
506
507            self.send_message("game_started")
508            self.restore_mutelist()
509
510            if config.dgl_mode:
511                if self.process.where == {}:
512                    # If location info was found, the lobbys were already
513                    # updated by set_where_data
514                    update_all_lobbys(self.process)
515                update_global_status()
516
517    def _on_crawl_end(self):
518        if config.dgl_mode:
519            remove_in_lobbys(self.process)
520
521        reason = self.process.exit_reason
522        message = self.process.exit_message
523        dump_url = self.process.exit_dump_url
524        self.process = None
525
526        if self.client_closed:
527            sockets.remove(self)
528        else:
529            if shutting_down:
530                self.close()
531            else:
532                # Go back to lobby
533                self.send_message("game_ended", reason = reason,
534                                  message = message, dump = dump_url)
535
536                self.invalidate_saveslot_cache(self.game_id)
537
538                if config.dgl_mode:
539                    if not self.watched_game:
540                        self.send_message("go_lobby")
541                        self.send_lobby()
542                else:
543                    self.start_crawl(None)
544
545        if config.dgl_mode:
546            update_global_status()
547
548        if shutting_down and len(sockets) == 0:
549            # The last crawl process has ended, now we can go
550            IOLoop.current().stop()
551
552    def init_user(self, callback):
553        # this would be more cleanly implemented with wait_for_exit, but I
554        # can't get code for that to work in a way that supports all currently
555        # in-use versions. TODO: clean up once old Tornado versions are out of
556        # the picture.
557        with open("/dev/null", "w") as f:
558            if tornado.version_info[0] < 3:
559                # before tornado 3, an async approach would have to be done
560                # differently, and given that we're deprecating tornado 2.4
561                # it doesn't seem worth implementing right now. Just stick with
562                # the old synchronous approach for backwards compatibility.
563                p = subprocess.Popen([config.init_player_program, self.username],
564                                         stdout = f, stderr = subprocess.STDOUT)
565                callback(p.wait())
566            else:
567                # TODO: do we need to care about the streams at all here?
568                p = tornado.process.Subprocess(
569                        [config.init_player_program, self.username],
570                        stdout = f, stderr = subprocess.STDOUT)
571                p.set_exit_callback(callback)
572
573    def stop_watching(self):
574        if self.watched_game:
575            self.logger.info("%s stopped watching %s.",
576                                    self.username and self.username or "[Anon]",
577                                    self.watched_game.username)
578            self.watched_game.remove_watcher(self)
579            self.watched_game = None
580
581    def shutdown(self):
582        if not self.client_closed:
583            self.logger.info("Shutting down user %s id %d", self.username, self.id)
584            msg = to_unicode(self.render_string("shutdown.html", game=self))
585            self.send_message("close", reason = msg)
586            self.close()
587        if self.is_running():
588            self.process.stop()
589
590    def do_login(self, username):
591        self.username = username
592        self.user_id, self.user_email, self.user_flags = userdb.get_user_info(username)
593        self.logger.extra["username"] = username
594
595        def login_callback(result):
596            success = result == 0
597            if not success:
598                msg = ("Could not initialize your rc and morgue!<br>" +
599                       "This probably means there is something wrong " +
600                       "with the server configuration.")
601                self.send_message("close", reason = msg)
602                self.logger.warning("User initialization returned an error for user %s!",
603                                    self.username)
604                self.username = None
605                self.close()
606                return
607
608            self.queue_message("login_success", username=username,
609                               admin=self.is_admin())
610            if self.watched_game:
611                self.watched_game.update_watcher_description()
612            else:
613                self.send_lobby_html()
614
615        self.init_user(login_callback)
616
617    def login(self, username, password):
618        real_username = userdb.user_passwd_match(username, password)
619        if real_username:
620            self.logger.info("User %s logging in from %s.",
621                                        real_username, self.request.remote_ip)
622            self.do_login(real_username)
623        else:
624            self.logger.warning("Failed login for user %s.", username)
625            self.send_message("login_fail")
626
627    def token_login(self, cookie):
628        username, ok = auth.check_login_cookie(cookie)
629        if ok:
630            auth.forget_login_cookie(cookie)
631            self.logger.info("User %s logging in (via token).", username)
632            self.do_login(username)
633        else:
634            self.logger.warning("Wrong login token for user %s.", username)
635            self.send_message("login_fail")
636
637    def set_login_cookie(self):
638        if self.username is None:
639            return
640        cookie = auth.log_in_as_user(self, self.username)
641        self.send_message("login_cookie", cookie = cookie,
642                          expires = config.login_token_lifetime)
643
644    def forget_login_cookie(self, cookie):
645        auth.forget_login_cookie(cookie)
646
647    def restore_mutelist(self):
648        if not self.username:
649            return
650        receiver = None
651        if self.process:
652            receiver = self.process
653        elif self.watched_game:
654            receiver = self.watched_game
655
656        if not receiver:
657            return
658
659        db_string = userdb.get_mutelist(self.username)
660        if db_string is None:
661            db_string = ""
662        # list constructor here is for forward compatibility with python 3.
663        muted = list([_f for _f in db_string.strip().split(' ') if _f])
664        receiver.restore_mutelist(self.username, muted)
665
666    def save_mutelist(self, muted):
667        db_string = " ".join(muted).strip()
668        userdb.set_mutelist(self.username, db_string)
669
670    def is_admin(self):
671        return self.username is not None and userdb.dgl_is_admin(self.user_flags)
672
673    def pong(self):
674        self.received_pong = True
675
676    def rcfile_path(self, game_id):
677        if game_id not in config.games: return None
678        if not self.username: return None
679        path = dgl_format_str(config.games[game_id]["rcfile_path"],
680                                     self.username, config.games[game_id])
681        return os.path.join(path, self.username + ".rc")
682
683    def send_json_options(self, game_id, player_name):
684        def do_send(data, returncode):
685            if returncode != 0:
686                # fail silently for returncode 1 for now, probably just an old
687                # version missing the command line option
688                if returncode != 1:
689                    self.logger.warning("Error while getting JSON options!")
690                return
691            self.append_message('{"msg":"options","watcher":true,"options":'
692                                + data + '}')
693
694        if not self.username: return
695        if game_id not in config.games: return
696
697        game = config.games[game_id]
698        if not "send_json_options" in game or not game["send_json_options"]:
699            return
700
701        call = [game["crawl_binary"]]
702
703        if "pre_options" in game:
704            call += game["pre_options"]
705
706        call += ["-name", player_name,
707                 "-rc", self.rcfile_path(game_id)]
708        if "options" in game:
709            call += game["options"]
710        call.append("-print-webtiles-options")
711
712        checkoutput.check_output(call, do_send)
713
714    def watch(self, username):
715        if self.is_running():
716            self.process.stop()
717
718        from process_handler import processes
719        procs = [process for process in list(processes.values())
720                 if process.username.lower() == username.lower()]
721        if len(procs) >= 1:
722            process = procs[0]
723            if self.watched_game:
724                if self.watched_game == process:
725                    return
726                self.stop_watching()
727            self.logger.info("%s started watching %s (%s).",
728                                self.username and self.username or "[Anon]",
729                                process.username, process.id)
730
731            self.watched_game = process
732            process.add_watcher(self)
733            self.send_message("watching_started", username = process.username)
734        else:
735            if self.watched_game:
736                self.stop_watching()
737            self.go_lobby()
738
739    def post_chat_message(self, text):
740        receiver = None
741        if self.process:
742            receiver = self.process
743        elif self.watched_game:
744            receiver = self.watched_game
745
746        if receiver:
747            if self.username is None:
748                self.send_message("chat", content
749                                  = 'You need to log in to send messages!')
750                return
751
752            if not receiver.handle_chat_command(self, text):
753                receiver.handle_chat_message(self.username, text)
754
755    def register(self, username, password, email):
756        error = userdb.register_user(username, password, email)
757        if error is None:
758            self.logger.info("Registered user %s.", username)
759            self.do_login(username)
760        else:
761            self.logger.info("Registration attempt failed for username %s: %s",
762                             username, error)
763            self.send_message("register_fail", reason = error)
764
765    def start_change_password(self):
766        self.send_message("start_change_password")
767
768    def change_password(self, cur_password, new_password):
769        if self.username is None:
770            self.send_message("change_password_fail", reason = "You need to log in to change your password.")
771            return
772
773        if not userdb.user_passwd_match(self.username, cur_password):
774            self.send_message("change_password_fail", reason = "Your password didn't match.")
775            self.logger.info("Non-matching current password during password change for %s", self.username)
776            return
777
778        error = userdb.change_password(self.user_id, new_password)
779        if error is None:
780            self.user_id, self.user_email, self.user_flags = userdb.get_user_info(self.username)
781            self.logger.info("User %s changed password.", self.username)
782            self.send_message("change_password_done")
783        else:
784            self.logger.info("Failed to change username for %s: %s", self.username, error)
785            self.send_message("change_password_fail", reason = error)
786
787
788    def start_change_email(self):
789        self.send_message("start_change_email", email = self.user_email)
790
791    def change_email(self, email):
792        if self.username is None:
793            self.send_message("change_email_fail", reason = "You need to log in to change your email")
794            return
795        error = userdb.change_email(self.user_id, email)
796        if error is None:
797            self.user_id, self.user_email, self.user_flags = userdb.get_user_info(self.username)
798            self.logger.info("User %s changed email to %s.", self.username, email if email else "null")
799            self.send_message("change_email_done", email = email)
800        else:
801            self.logger.info("Failed to change username for %s: %s", self.username, error)
802            self.send_message("change_email_fail", reason = error)
803
804    def forgot_password(self, email):
805        if not getattr(config, "allow_password_reset", False):
806            return
807        sent, error = userdb.send_forgot_password(email)
808        if error is None:
809            if sent:
810                self.logger.info("Sent password reset email to %s.", email)
811            else:
812                self.logger.info("User requested a password reset, but email "
813                                 "is not registered (%s).", email)
814            self.send_message("forgot_password_done")
815        else:
816            self.logger.info("Failed to send password reset email for %s: %s",
817                             email, error)
818            self.send_message("forgot_password_fail", reason = error)
819
820    def reset_password(self, token, password):
821        username, error = userdb.update_user_password_from_token(token,
822                                                                 password)
823        if error is None:
824            self.logger.info("User %s has completed their password reset.",
825                             username)
826            self.send_message("reload_url")
827        else:
828            if username is None:
829                self.logger.info("Failed to update password for token %s: %s",
830                                 token, error)
831            else:
832                self.logger.info("Failed to update password for user %s: %s",
833                                 username, error)
834            self.send_message("reset_password_fail", reason = error)
835
836    def go_lobby(self):
837        if not config.dgl_mode: return
838        if self.is_running():
839            self.process.stop()
840        elif self.watched_game:
841            self.stop_watching()
842            self.send_message("go_lobby")
843            self.send_lobby()
844        else:
845            self.send_message("go_lobby")
846
847    def go_admin(self):
848        self.go_lobby()
849        self.send_message("go_admin")
850
851    def get_rc(self, game_id):
852        if game_id not in config.games: return
853        path = self.rcfile_path(game_id)
854        try:
855            with open(path, 'r') as f:
856                contents = f.read()
857        # Handle RC file not existing. IOError for py2, OSError for py3
858        except (OSError, IOError):
859            contents = ''
860        self.send_message("rcfile_contents", contents = contents)
861
862    def set_rc(self, game_id, contents):
863        rcfile_path = dgl_format_str(config.games[game_id]["rcfile_path"],
864                                     self.username, config.games[game_id])
865        rcfile_path = os.path.join(rcfile_path, self.username + ".rc")
866        with open(rcfile_path, 'wb') as f:
867            # TODO: is binary + encode necessary in py 3?
868            f.write(utf8(contents))
869
870    def on_message(self, message): # type: (Union[str, bytes]) -> None
871        try:
872            obj = json_decode(message) # type: Dict[str, Any]
873            if obj["msg"] in self.message_handlers:
874                handler = self.message_handlers[obj["msg"]]
875                del obj["msg"]
876                handler(**obj)
877            elif self.process:
878                self.process.handle_input(message)
879            elif not self.watched_game and obj["msg"] != 'ui_state_sync':
880                # ui_state_sync can get queued by the js client just before
881                # shutdown, and have its sending delayed by enough that the
882                # process has stopped. I do not currently think there's a
883                # principled way to suppress it with this timing on the js
884                # side (because it's basically just a consequence of general
885                # ui code), so have an ugly exception here. Otherwise, various
886                # game ending conditions log a warning. TODO: fixes?
887                self.logger.warning("Didn't know how to handle msg (user %s): %s",
888                                    self.username and self.username or "[Anon]",
889                                    obj["msg"])
890        except OSError as e:
891            # maybe should throw a custom exception from the socket call rather
892            # than rely on these cases?
893            excerpt = message[:50] if len(message) > 50 else message
894            trunc = "..." if len(message) > 50 else ""
895            if e.errno == errno.EAGAIN or e.errno == errno.ENOBUFS:
896                # errno is different on mac vs linux, maybe also depending on
897                # python version
898                self.logger.warning(
899                    "Socket buffer full; skipping JSON message ('%r%s')!",
900                                excerpt, trunc)
901            else:
902                self.logger.warning(
903                                "Error while handling JSON message ('%r%s')!",
904                                excerpt, trunc,
905                                exc_info=True)
906        except Exception:
907            excerpt = message[:50] if len(message) > 50 else message
908            trunc = "..." if len(message) > 50 else ""
909            self.logger.warning("Error while handling JSON message ('%r%s')!",
910                                excerpt, trunc,
911                                exc_info=True)
912
913    def flush_messages(self):
914        # type: () -> bool
915        if self.client_closed or len(self.message_queue) == 0:
916            return False
917        msg = ("{\"msgs\":["
918                + ",".join(self.message_queue)
919                + "]}")
920        self.message_queue = []
921
922        try:
923            binmsg = utf8(msg)
924            self.total_message_bytes += len(binmsg)
925            if self.deflate:
926                # Compress like in deflate-frame extension:
927                # Apply deflate, flush, then remove the 00 00 FF FF
928                # at the end
929                compressed = self._compressobj.compress(binmsg)
930                compressed += self._compressobj.flush(zlib.Z_SYNC_FLUSH)
931                compressed = compressed[:-4]
932                self.compressed_bytes_sent += len(compressed)
933                f = self.write_message(compressed, binary=True)
934            else:
935                self.uncompressed_bytes_sent += len(binmsg)
936                f = self.write_message(binmsg)
937
938            # handle any exceptions lingering in the Future
939            # TODO: this whole call chain should be converted to use coroutines
940            def after_write_callback(f):
941                try:
942                    f.result()
943                except tornado.websocket.WebSocketClosedError as e:
944                    self.logger.warning("Connection closed during async write_message")
945                    if self.ws_connection is not None:
946                        self.ws_connection._abort()
947                except Exception as e:
948                    self.logger.warning("Exception during async write_message")
949                    self.logger.warning(e, exc_info=True)
950                    if self.ws_connection is not None:
951                        self.ws_connection._abort()
952
953            # extreme back-compat try-except block, `f` should be None in
954            # ancient tornado versions
955            try:
956                f.add_done_callback(after_write_callback)
957            except:
958                pass
959            # true means that something was queued up to send, but it may be
960            # async
961            return True
962        except:
963            self.logger.warning("Exception trying to send message.", exc_info = True)
964            if self.ws_connection is not None:
965                self.ws_connection._abort()
966            return False
967
968    # n.b. this looks a lot like superclass write_message, but has a static
969    # type signature that is not compatible with it, so we do not override
970    # that function.
971    def append_message(self,
972                       msg,      # type: str
973                       send=True # type: bool
974                       ):
975        # type: (...) -> bool
976        if self.client_closed:
977            return False
978        self.message_queue.append(msg)
979        if send:
980            return self.flush_messages()
981        return False
982
983    def send_message(self, msg, **data):
984        # type: (str, Any) -> bool
985        """Sends a JSON message to the client."""
986        data["msg"] = msg
987        return self.append_message(json_encode(data), True)
988
989    def queue_message(self, msg, **data):
990        # type: (str, Any) -> bool
991        data["msg"] = msg
992        return self.append_message(json_encode(data), False)
993
994    def on_close(self):
995        if self.process is None and self in sockets:
996            sockets.remove(self)
997            if shutting_down and len(sockets) == 0:
998                # The last socket has been closed, now we can go
999                IOLoop.current().stop()
1000        elif self.is_running():
1001            self.process.stop()
1002
1003        if self.watched_game:
1004            self.watched_game.remove_watcher(self)
1005
1006        if self.timeout:
1007            IOLoop.current().remove_timeout(self.timeout)
1008
1009        if self.total_message_bytes == 0:
1010            comp_ratio = "N/A"
1011        else:
1012            comp_ratio = 100 - 100 * (self.compressed_bytes_sent + self.uncompressed_bytes_sent) / self.total_message_bytes
1013            comp_ratio = round(comp_ratio, 2)
1014
1015        self.logger.info("Socket closed. (%s sent, compression ratio %s%%)",
1016                         util.humanise_bytes(self.total_message_bytes), comp_ratio)
1017