Guest User

saveLoad

a guest
Feb 20th, 2023
58
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 16.41 KB | None | 0 0
  1. """
  2. Uncategorized methods of the v3 API
  3. """
  4. from base64 import urlsafe_b64decode
  5. from collections import namedtuple
  6. from datetime import timedelta
  7. import hashlib
  8. import json
  9. import zlib
  10.  
  11. from botocore.exceptions import ClientError
  12.  
  13. from django.conf import settings
  14. from django.db import transaction
  15. from django.views.decorators.http import require_safe, require_POST
  16.  
  17. from matchmaker import cloud_save, save_migrations, stats
  18. from matchmaker.controllers import save as save_controller
  19. from matchmaker.filters import json_view, require_player
  20. from matchmaker.log import logger, player_log
  21. from matchmaker.models import SaveGame, AuthMethod
  22. from matchmaker.model_helpers import get_db_now, LockTimeoutException
  23. from matchmaker.utils import error, unquote
  24.  
  25.  
  26. class UnknownSessionComparisonException(Exception):
  27. """
  28. Exception thrown if we are comparing sessions to try
  29. sending a savegame down and come across a very unexpected situation
  30. """
  31. pass
  32.  
  33.  
  34. # Ignoring too-many-return-values
  35. # pylint: disable-msg=R0911
  36. # Ignoring too-many-statements
  37. # TODO (tday): Remove once base64 branch is removed
  38. # pylint: disable-msg=R0915
  39. @require_POST
  40. @require_player(require_registered=False)
  41. @json_view
  42. def save(request, identifier):
  43. """
  44. Save the player's game and return OK. If this save is bound to another
  45. device, sends a remote_login flag.
  46. """
  47. content_type = request.headers.get('Content-Type', None)
  48.  
  49. try:
  50. if content_type and content_type == 'application/zlib':
  51. # Grab "external" save counter from header
  52. request_save_counter = request.headers.get('X-Demiurge-Save-Counter', None)
  53. if request_save_counter is None:
  54. player_log("save error", category="save", problem="missing request save counter")
  55. return error('Bad data', 400)
  56. try:
  57. request_save_counter = int(request_save_counter)
  58. except ValueError:
  59. player_log("save error", category="save", problem="bad request save counter")
  60. return error('Bad data', 400)
  61.  
  62. # Verify with MD5 from header
  63. header_md5 = request.headers.get('X-Demiurge-Save-MD5', None)
  64. computed_md5 = hashlib.md5(request.body)
  65. if header_md5 is None or header_md5 != computed_md5.hexdigest().upper():
  66. player_log("save error", category="save", problem="bad md5")
  67. stats.log_save_bad_md5()
  68. return error('Bad data', 400)
  69.  
  70. compressed_save_data = request.body
  71. else:
  72. encoded_save_data = request.POST.get('data_zipped')
  73. if not encoded_save_data:
  74. player_log("save error", problem="missing data_zipped")
  75. return error('Bad data', 400)
  76. encoded_save_data = encoded_save_data.encode("utf8")
  77. # Verify with MD5 from POST data
  78. data_md5 = request.POST.get('md5')
  79. if data_md5:
  80. computed_md5 = hashlib.md5(encoded_save_data)
  81. if data_md5 != computed_md5.hexdigest().upper():
  82. player_log("save error", category="save", problem="bad md5")
  83. stats.log_save_bad_md5()
  84. return error('Bad data', 400)
  85. # Base64 decode
  86. try:
  87. compressed_save_data = urlsafe_b64decode(encoded_save_data)
  88. except TypeError:
  89. player_log("save error", category="save", problem="base64 decode error")
  90. stats.log_save_bad_b64()
  91. return error('Bad data', 400)
  92. # Pull off the save counter from POST data
  93. request_save_counter = unquote(request.POST.get('save_counter'))
  94. if not request_save_counter:
  95. request_save_counter = 0
  96. else:
  97. request_save_counter = int(request_save_counter)
  98.  
  99. try:
  100. decompressed = zlib.decompress(compressed_save_data)
  101. except zlib.error:
  102. player_log("save error", category="save", problem="zlib decode error")
  103. stats.log_save_bad_zlib()
  104. return error('Bad data', 400)
  105.  
  106. data_save_counter = None
  107. try:
  108. # Make sure save data has valid bytes
  109. _ = decompressed.decode("utf8")
  110. save_data = json.loads(decompressed)
  111. data_save_counter = save_data.get('SaveCounter', None)
  112. except UnicodeDecodeError:
  113. player_log("save error", category="save", problem="unicode decode error")
  114. stats.log_save_bad_json()
  115. return error('Bad data', 400)
  116. except ValueError:
  117. player_log("save error", category="save", problem="json decode error")
  118. stats.log_save_bad_json()
  119. return error('Bad data', 400)
  120. except UnicodeEncodeError:
  121. player_log("save error", category="save", problem="unicode encode error")
  122. stats.log_save_bad_json()
  123. return error('Bad data', 400)
  124.  
  125. if data_save_counter is None:
  126. logger.error("Save Error: missing data save counter "
  127. "(Player GUID: %s, Request Save Counter: %i)",
  128. request.player.guid,
  129. request_save_counter)
  130. stats.log_save_bad_json()
  131. # TODO (tday): Once the change requiring the data save counter to be present
  132. # has sat on production for a while, remove this line and make it return a
  133. # 400 instead. We do not want to rely on the request save counter.
  134. data_save_counter = request_save_counter
  135.  
  136. # NOTE: The request save counter should NOT be trusted over the data save counter.
  137. # The data save counter is directly associated with the save data that will be
  138. # placed on S3, and should be considered the source of truth. The request save
  139. # counter is here for statistics gathering, to identify requests where the client's
  140. # external and internal counters desynchronized.
  141. if request_save_counter != data_save_counter:
  142. stats.log_save_counter_upload_mismatch()
  143.  
  144. try:
  145. result = save_controller.upload_save(
  146. request.player,
  147. identifier,
  148. compressed_save_data,
  149. data_save_counter,
  150. request.device_id,
  151. request.session_id,
  152. request.release,
  153. request.changelist)
  154. stats.log_save()
  155. except LockTimeoutException:
  156. stats.log_save_read_lock_timeout()
  157. player_log("save error", category="save", save_counter=data_save_counter,
  158. device_id=request.device_id, session_id=request.session_id,
  159. problem="write lock timeout")
  160. return error('Timed out', 500)
  161. except (IOError, ClientError):
  162. stats.log_s3_timeout()
  163. player_log("save error", category="save", save_counter=data_save_counter,
  164. device_id=request.device_id, session_id=request.session_id,
  165. problem="s3 upload timeout")
  166. return error('IO error', 500)
  167.  
  168. if result is None:
  169. return error("IO error", 500)
  170.  
  171. if result == save_controller.SaveResult.REMOTE_LOGIN:
  172. return {
  173. 'remote_login': True,
  174. }
  175.  
  176. try:
  177. request.player.update_last_played()
  178. except Exception: # pylint: disable-msg=W0703
  179. # Log an error, but don't call the request failed
  180. logger.exception("Failed to update last played")
  181.  
  182. return {
  183. "saved_save_counter": data_save_counter,
  184. }
  185.  
  186.  
  187. CloudSaveStatus = namedtuple(
  188. "CloudSaveStatus",
  189. """
  190. saved_game
  191. json_data
  192. failed
  193. required_release
  194. """)
  195.  
  196.  
  197. def _is_session_match(session_id, last_session_id, save_session_id, save_data_session_id):
  198. """
  199. Returns whether the request's session info matches the save's session info
  200. """
  201. if last_session_id is None:
  202. # The client doesn't know about sessions (pre R58) or
  203. # isn't in a state to have a session id (initial start).
  204. # Either way, we should use the cloud save.
  205. return settings.NO_CLIENT_SESSION_TRUST_CLIENT_SAVE
  206. elif save_session_id is None:
  207. # The cloud save was from a version before session id existed (pre R58)
  208. # This means we should trust the client save.
  209. return True
  210. elif last_session_id == save_session_id or not settings.CHECK_SAVE_DATA_SESSION:
  211. return save_session_id == last_session_id
  212. elif session_id == save_session_id:
  213. # They already bound to this session -- assume this is a retry, and
  214. # reapply the rules using the prior session id (which is save_data_session_id)
  215. if save_session_id == save_data_session_id:
  216. # don't recurse infinitely, session_id == save_data_session_id
  217. # which should be impossible (from comment in else branch)
  218. raise UnknownSessionComparisonException()
  219. return _is_session_match(
  220. session_id, last_session_id,
  221. # Note: using save_data_session_id (which is equal to the old value
  222. # of save_session_id until new save data is POSTed) for both save
  223. # session_id values.
  224. save_data_session_id, save_data_session_id)
  225. elif last_session_id == save_data_session_id:
  226. # They bound to save_session_id, but the client doesn't know;
  227. # then, they restarted from a new session. Treat as a "match."
  228. return True
  229. else:
  230. # Either session_id == save_data_session_id (which should be impossible),
  231. # or neither client session matches any server save session. Either way,
  232. # force resync.
  233. return False
  234.  
  235.  
  236. def _load_cloud_save(player, device_id, session_id, last_session_id,
  237. identifier, counter, request_release):
  238. """
  239. Load a player's cloud save, based on the given player, save identifier and
  240. counter
  241. """
  242. logger.debug("loading cloud save for player %s, device_id %s", player.id, device_id)
  243.  
  244. saved_game = SaveGame.get_by_player_and_id(player, identifier)
  245. if not saved_game:
  246. return CloudSaveStatus(
  247. saved_game=None,
  248. json_data=None,
  249. failed=False,
  250. required_release=None)
  251.  
  252. if settings.REQUIRE_SAVED_GAME_RELEASE:
  253. if saved_game.release is not None and saved_game.release > request_release:
  254. return CloudSaveStatus(
  255. required_release=saved_game.release,
  256. saved_game=None,
  257. failed=False,
  258. json_data=None)
  259.  
  260. if not SaveGame.bind_to_session(saved_game, device_id, session_id):
  261. # If we can't ensure the save is associated with device_id, don't
  262. # continue
  263. return CloudSaveStatus(
  264. saved_game=None,
  265. json_data=None,
  266. failed=True,
  267. required_release=None)
  268.  
  269. session_match = _is_session_match(
  270. session_id, last_session_id,
  271. saved_game.session_id, saved_game.data_session_id)
  272. should_send_save = saved_game.device_id != device_id or not session_match
  273.  
  274. if counter <= saved_game.save_counter or should_send_save:
  275. changed = save_migrations.migrate_in_place(player, saved_game)
  276. if changed:
  277. should_send_save = True
  278.  
  279. if counter < saved_game.save_counter:
  280. should_send_save = True
  281.  
  282. if not should_send_save:
  283. return CloudSaveStatus(
  284. saved_game=saved_game,
  285. json_data=None,
  286. failed=False,
  287. required_release=None)
  288.  
  289. json_data = None
  290. # TODO: rename this boolean to clarify what kind of failure it is meant to handle
  291. # also consider setting failed to true in the exception cases below, if it makes sense
  292. failed = False
  293. try:
  294. json_data = cloud_save.load_to_str(player, identifier=identifier)
  295. except ClientError:
  296. # File not found
  297. logger.info("S3 save not found for player %s",
  298. player.guid)
  299. except zlib.error:
  300. logger.exception("Problem decompressing S3 save for player %s",
  301. player.guid)
  302. except UnicodeDecodeError:
  303. logger.exception("Problem decoding S3 save for player %s",
  304. player.guid)
  305.  
  306. return CloudSaveStatus(
  307. saved_game=saved_game,
  308. json_data=json_data,
  309. failed=failed,
  310. required_release=None)
  311.  
  312.  
  313. @require_safe
  314. @require_player(require_registered=False)
  315. @json_view
  316. def load(request, identifier, counter):
  317. """
  318. Load a save game for the player and return it. This binds the save to the
  319. request's device id.
  320. """
  321. now = get_db_now()
  322.  
  323. player_guid = request.player.guid
  324.  
  325. counter = int(counter) if counter else None
  326. last_session_id = request.META.get('HTTP_X_DEMIURGE_LAST_SESSION_ID')
  327.  
  328. try:
  329. with transaction.atomic():
  330. SaveGame.read_lock(request.player)
  331. save_status = _load_cloud_save(
  332. request.player, request.device_id, request.session_id,
  333. last_session_id, identifier, counter, request.release)
  334. except LockTimeoutException:
  335. stats.log_save_read_lock_timeout()
  336. logger.info("Timed out trying to get read lock for save",
  337. exc_info=True)
  338. return error('Timed out', 500, player_guid=player_guid)
  339. except cloud_save.S3_READ_EXCEPTIONS:
  340. logger.info("Timed out talking to S3 for save", exc_info=True)
  341. stats.log_s3_timeout()
  342. return error("IO Error/Timeout", 500, player_guid=player_guid)
  343. except UnknownSessionComparisonException:
  344. stats.log_save_unknown_session_comparison()
  345. logger.info("Unknown Session Comparison to judge resending save",
  346. exc_info=True)
  347. return error('Unknown Session Pattern', 500, player_guid=player_guid)
  348. # Now that we have the cloud save data from the db and s3, we don't need
  349. # the read lock. It goes away as soon as the transaction is committed.
  350.  
  351. if save_status.required_release is not None:
  352. return {
  353. "data": "{}",
  354. "player_guid": player_guid,
  355. "required_version": {
  356. "required": {
  357. "major": save_status.required_release,
  358. "minor": 0,
  359. "changelist": 0,
  360. },
  361. "recommended": {
  362. "major": 0,
  363. "minor": 0,
  364. "changelist": 0,
  365. },
  366. },
  367. }
  368.  
  369. if save_status.failed:
  370. return error("Load failed", 500, player_guid=player_guid)
  371.  
  372. # Retrieve the user's authentication methods, if any.
  373. if request.player.user_id:
  374. methods = AuthMethod.list_for_user(request.player.user_id)
  375. else:
  376. methods = []
  377.  
  378. saved_game = save_status.saved_game
  379. if not saved_game:
  380. return error("Not found", 404, player_guid=player_guid)
  381. if not save_status.json_data:
  382. return {
  383. "data": "{}",
  384. "player_guid": player_guid,
  385. "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
  386. "time": now.replace(microsecond=0).isoformat(),
  387. "auth": methods,
  388. }
  389.  
  390. save_data_obj = json.loads(save_status.json_data)
  391. save_counter = save_data_obj.get("SaveCounter", 0)
  392. if save_counter != saved_game.save_counter:
  393. logger.info("Save counter %s does not equal server save counter %s",
  394. save_counter, saved_game.save_counter)
  395. stats.log_save_counter_mismatch()
  396.  
  397. save_age = now - saved_game.saved_on
  398. if save_age > timedelta(minutes=3):
  399. # If their cloud save is out of sync for several minutes, assume S3
  400. # will never catch up, and let them back in to the game
  401. return {
  402. "data": "{}",
  403. "player_guid": player_guid,
  404. "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
  405. "time": now.replace(microsecond=0).isoformat(),
  406. "auth": methods,
  407. }
  408.  
  409. return error("Save counters out of sync", 500, player_guid=player_guid)
  410.  
  411. return {
  412. "data": save_status.json_data,
  413. "player_guid": player_guid,
  414. "saved_on": saved_game.saved_on.replace(microsecond=0).isoformat(),
  415. "save_counter": saved_game.save_counter,
  416. "time": now.replace(microsecond=0).isoformat(),
  417. "auth": methods,
  418. }
  419.  
Add Comment
Please, Sign In to add comment