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