Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """
- Uncategorized methods of the v3 API
- """
- from base64 import urlsafe_b64decode
- from collections import namedtuple
- from datetime import timedelta
- import hashlib
- import json
- import zlib
- from botocore.exceptions import ClientError
- from django.conf import settings
- from django.db import transaction
- from django.views.decorators.http import require_safe, require_POST
- from matchmaker import cloud_save, save_migrations, stats
- from matchmaker.controllers import save as save_controller
- from matchmaker.filters import json_view, require_player
- from matchmaker.log import logger, player_log
- from matchmaker.models import SaveGame, AuthMethod
- from matchmaker.model_helpers import get_db_now, LockTimeoutException
- from matchmaker.utils import error, unquote
- class UnknownSessionComparisonException(Exception):
- """
- Exception thrown if we are comparing sessions to try
- sending a savegame down and come across a very unexpected situation
- """
- pass
- # Ignoring too-many-return-values
- # pylint: disable-msg=R0911
- # Ignoring too-many-statements
- # TODO (tday): Remove once base64 branch is removed
- # pylint: disable-msg=R0915
- @require_POST
- @require_player(require_registered=False)
- @json_view
- def save(request, identifier):
- """
- Save the player's game and return OK. If this save is bound to another
- device, sends a remote_login flag.
- """
- content_type = request.headers.get('Content-Type', None)
- try:
- if content_type and content_type == 'application/zlib':
- # Grab "external" save counter from header
- request_save_counter = request.headers.get('X-Demiurge-Save-Counter', None)
- if request_save_counter is None:
- player_log("save error", category="save", problem="missing request save counter")
- return error('Bad data', 400)
- try:
- request_save_counter = int(request_save_counter)
- except ValueError:
- player_log("save error", category="save", problem="bad request save counter")
- return error('Bad data', 400)
- # Verify with MD5 from header
- header_md5 = request.headers.get('X-Demiurge-Save-MD5', None)
- computed_md5 = hashlib.md5(request.body)
- if header_md5 is None or header_md5 != computed_md5.hexdigest().upper():
- player_log("save error", category="save", problem="bad md5")
- stats.log_save_bad_md5()
- return error('Bad data', 400)
- compressed_save_data = request.body
- else:
- encoded_save_data = request.POST.get('data_zipped')
- if not encoded_save_data:
- player_log("save error", problem="missing data_zipped")
- return error('Bad data', 400)
- encoded_save_data = encoded_save_data.encode("utf8")
- # Verify with MD5 from POST data
- data_md5 = request.POST.get('md5')
- if data_md5:
- computed_md5 = hashlib.md5(encoded_save_data)
- if data_md5 != computed_md5.hexdigest().upper():
- player_log("save error", category="save", problem="bad md5")
- stats.log_save_bad_md5()
- return error('Bad data', 400)
- # Base64 decode
- try:
- compressed_save_data = urlsafe_b64decode(encoded_save_data)
- except TypeError:
- player_log("save error", category="save", problem="base64 decode error")
- stats.log_save_bad_b64()
- return error('Bad data', 400)
- # Pull off the save counter from POST data
- request_save_counter = unquote(request.POST.get('save_counter'))
- if not request_save_counter:
- request_save_counter = 0
- else:
- request_save_counter = int(request_save_counter)
- try:
- decompressed = zlib.decompress(compressed_save_data)
- except zlib.error:
- player_log("save error", category="save", problem="zlib decode error")
- stats.log_save_bad_zlib()
- return error('Bad data', 400)
- data_save_counter = None
- try:
- # Make sure save data has valid bytes
- _ = decompressed.decode("utf8")
- save_data = json.loads(decompressed)
- data_save_counter = save_data.get('SaveCounter', None)
- except UnicodeDecodeError:
- player_log("save error", category="save", problem="unicode decode error")
- stats.log_save_bad_json()
- return error('Bad data', 400)
- except ValueError:
- player_log("save error", category="save", problem="json decode error")
- stats.log_save_bad_json()
- return error('Bad data', 400)
- except UnicodeEncodeError:
- player_log("save error", category="save", problem="unicode encode error")
- stats.log_save_bad_json()
- return error('Bad data', 400)
- if data_save_counter is None:
- logger.error("Save Error: missing data save counter "
- "(Player GUID: %s, Request Save Counter: %i)",
- request.player.guid,
- request_save_counter)
- stats.log_save_bad_json()
- # TODO (tday): Once the change requiring the data save counter to be present
- # has sat on production for a while, remove this line and make it return a
- # 400 instead. We do not want to rely on the request save counter.
- data_save_counter = request_save_counter
- # NOTE: The request save counter should NOT be trusted over the data save counter.
- # The data save counter is directly associated with the save data that will be
- # placed on S3, and should be considered the source of truth. The request save
- # counter is here for statistics gathering, to identify requests where the client's
- # external and internal counters desynchronized.
- if request_save_counter != data_save_counter:
- stats.log_save_counter_upload_mismatch()
- try:
- result = save_controller.upload_save(
- request.player,
- identifier,
- compressed_save_data,
- data_save_counter,
- request.device_id,
- request.session_id,
- request.release,
- request.changelist)
- stats.log_save()
- except LockTimeoutException:
- stats.log_save_read_lock_timeout()
- player_log("save error", category="save", save_counter=data_save_counter,
- device_id=request.device_id, session_id=request.session_id,
- problem="write lock timeout")
- return error('Timed out', 500)
- except (IOError, ClientError):
- stats.log_s3_timeout()
- player_log("save error", category="save", save_counter=data_save_counter,
- device_id=request.device_id, session_id=request.session_id,
- problem="s3 upload timeout")
- return error('IO error', 500)
- if result is None:
- return error("IO error", 500)
- if result == save_controller.SaveResult.REMOTE_LOGIN:
- return {
- 'remote_login': True,
- }
- try:
- request.player.update_last_played()
- except Exception: # pylint: disable-msg=W0703
- # Log an error, but don't call the request failed
- logger.exception("Failed to update last played")
- return {
- "saved_save_counter": data_save_counter,
- }
- CloudSaveStatus = namedtuple(
- "CloudSaveStatus",
- """
- saved_game
- json_data
- failed
- required_release
- """)
- def _is_session_match(session_id, last_session_id, save_session_id, save_data_session_id):
- """
- Returns whether the request's session info matches the save's session info
- """
- if last_session_id is None:
- # The client doesn't know about sessions (pre R58) or
- # isn't in a state to have a session id (initial start).
- # Either way, we should use the cloud save.
- return settings.NO_CLIENT_SESSION_TRUST_CLIENT_SAVE
- elif save_session_id is None:
- # The cloud save was from a version before session id existed (pre R58)
- # This means we should trust the client save.
- return True
- elif last_session_id == save_session_id or not settings.CHECK_SAVE_DATA_SESSION:
- return save_session_id == last_session_id
- elif session_id == save_session_id:
- # They already bound to this session -- assume this is a retry, and
- # reapply the rules using the prior session id (which is save_data_session_id)
- if save_session_id == save_data_session_id:
- # don't recurse infinitely, session_id == save_data_session_id
- # which should be impossible (from comment in else branch)
- raise UnknownSessionComparisonException()
- return _is_session_match(
- session_id, last_session_id,
- # Note: using save_data_session_id (which is equal to the old value
- # of save_session_id until new save data is POSTed) for both save
- # session_id values.
- save_data_session_id, save_data_session_id)
- elif last_session_id == save_data_session_id:
- # They bound to save_session_id, but the client doesn't know;
- # then, they restarted from a new session. Treat as a "match."
- return True
- else:
- # Either session_id == save_data_session_id (which should be impossible),
- # or neither client session matches any server save session. Either way,
- # force resync.
- return False
- def _load_cloud_save(player, device_id, session_id, last_session_id,
- identifier, counter, request_release):
- """
- Load a player's cloud save, based on the given player, save identifier and
- counter
- """
- logger.debug("loading cloud save for player %s, device_id %s", player.id, device_id)
- saved_game = SaveGame.get_by_player_and_id(player, identifier)
- if not saved_game:
- return CloudSaveStatus(
- saved_game=None,
- json_data=None,
- failed=False,
- required_release=None)
- if settings.REQUIRE_SAVED_GAME_RELEASE:
- if saved_game.release is not None and saved_game.release > request_release:
- return CloudSaveStatus(
- required_release=saved_game.release,
- saved_game=None,
- failed=False,
- json_data=None)
- if not SaveGame.bind_to_session(saved_game, device_id, session_id):
- # If we can't ensure the save is associated with device_id, don't
- # continue
- return CloudSaveStatus(
- saved_game=None,
- json_data=None,
- failed=True,
- required_release=None)
- session_match = _is_session_match(
- session_id, last_session_id,
- saved_game.session_id, saved_game.data_session_id)
- should_send_save = saved_game.device_id != device_id or not session_match
- if counter <= saved_game.save_counter or should_send_save:
- changed = save_migrations.migrate_in_place(player, saved_game)
- if changed:
- should_send_save = True
- if counter < saved_game.save_counter:
- should_send_save = True
- if not should_send_save:
- return CloudSaveStatus(
- saved_game=saved_game,
- json_data=None,
- failed=False,
- required_release=None)
- json_data = None
- # TODO: rename this boolean to clarify what kind of failure it is meant to handle
- # also consider setting failed to true in the exception cases below, if it makes sense
- failed = False
- try:
- json_data = cloud_save.load_to_str(player, identifier=identifier)
- except ClientError:
- # File not found
- logger.info("S3 save not found for player %s",
- player.guid)
- except zlib.error:
- logger.exception("Problem decompressing S3 save for player %s",
- player.guid)
- except UnicodeDecodeError:
- logger.exception("Problem decoding S3 save for player %s",
- player.guid)
- return CloudSaveStatus(
- saved_game=saved_game,
- json_data=json_data,
- failed=failed,
- required_release=None)
- @require_safe
- @require_player(require_registered=False)
- @json_view
- def load(request, identifier, counter):
- """
- Load a save game for the player and return it. This binds the save to the
- request's device id.
- """
- now = get_db_now()
- player_guid = request.player.guid
- counter = int(counter) if counter else None
- last_session_id = request.META.get('HTTP_X_DEMIURGE_LAST_SESSION_ID')
- try:
- with transaction.atomic():
- SaveGame.read_lock(request.player)
- save_status = _load_cloud_save(
- request.player, request.device_id, request.session_id,
- last_session_id, identifier, counter, request.release)
- except LockTimeoutException:
- stats.log_save_read_lock_timeout()
- logger.info("Timed out trying to get read lock for save",
- exc_info=True)
- return error('Timed out', 500, player_guid=player_guid)
- except cloud_save.S3_READ_EXCEPTIONS:
- logger.info("Timed out talking to S3 for save", exc_info=True)
- stats.log_s3_timeout()
- return error("IO Error/Timeout", 500, player_guid=player_guid)
- except UnknownSessionComparisonException:
- stats.log_save_unknown_session_comparison()
- logger.info("Unknown Session Comparison to judge resending save",
- exc_info=True)
- return error('Unknown Session Pattern', 500, player_guid=player_guid)
- # Now that we have the cloud save data from the db and s3, we don't need
- # the read lock. It goes away as soon as the transaction is committed.
- if save_status.required_release is not None:
- return {
- "data": "{}",
- "player_guid": player_guid,
- "required_version": {
- "required": {
- "major": save_status.required_release,
- "minor": 0,
- "changelist": 0,
- },
- "recommended": {
- "major": 0,
- "minor": 0,
- "changelist": 0,
- },
- },
- }
- if save_status.failed:
- return error("Load failed", 500, player_guid=player_guid)
- # Retrieve the user's authentication methods, if any.
- if request.player.user_id:
- methods = AuthMethod.list_for_user(request.player.user_id)
- else:
- methods = []
- saved_game = save_status.saved_game
- if not saved_game:
- return error("Not found", 404, player_guid=player_guid)
- if not save_status.json_data:
- return {
- "data": "{}",
- "player_guid": player_guid,
- "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
- "time": now.replace(microsecond=0).isoformat(),
- "auth": methods,
- }
- save_data_obj = json.loads(save_status.json_data)
- save_counter = save_data_obj.get("SaveCounter", 0)
- if save_counter != saved_game.save_counter:
- logger.info("Save counter %s does not equal server save counter %s",
- save_counter, saved_game.save_counter)
- stats.log_save_counter_mismatch()
- save_age = now - saved_game.saved_on
- if save_age > timedelta(minutes=3):
- # If their cloud save is out of sync for several minutes, assume S3
- # will never catch up, and let them back in to the game
- return {
- "data": "{}",
- "player_guid": player_guid,
- "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
- "time": now.replace(microsecond=0).isoformat(),
- "auth": methods,
- }
- return error("Save counters out of sync", 500, player_guid=player_guid)
- return {
- "data": save_status.json_data,
- "player_guid": player_guid,
- "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
- "save_counter": saved_game.save_counter,
- "time": now.replace(microsecond=0).isoformat(),
- "auth": methods,
- }
Add Comment
Please, Sign In to add comment