DriveApiService.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import base64
  4. import hashlib
  5. from datetime import datetime
  6. from tempfile import NamedTemporaryFile
  7. from typing import Any, Optional, List, Dict
  8. import requests
  9. from UM.Logger import Logger
  10. from UM.Message import Message
  11. from UM.Signal import Signal, signalemitter
  12. from cura.CuraApplication import CuraApplication
  13. from .UploadBackupJob import UploadBackupJob
  14. from .Settings import Settings
  15. from UM.i18n import i18nCatalog
  16. catalog = i18nCatalog("cura")
  17. ## The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
  18. @signalemitter
  19. class DriveApiService:
  20. BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
  21. # Emit signal when restoring backup started or finished.
  22. restoringStateChanged = Signal()
  23. # Emit signal when creating backup started or finished.
  24. creatingStateChanged = Signal()
  25. def __init__(self) -> None:
  26. self._cura_api = CuraApplication.getInstance().getCuraAPI()
  27. def getBackups(self) -> List[Dict[str, Any]]:
  28. access_token = self._cura_api.account.accessToken
  29. if not access_token:
  30. Logger.log("w", "Could not get access token.")
  31. return []
  32. try:
  33. backup_list_request = requests.get(self.BACKUP_URL, headers = {
  34. "Authorization": "Bearer {}".format(access_token)
  35. })
  36. except requests.exceptions.ConnectionError:
  37. Logger.logException("w", "Unable to connect with the server.")
  38. return []
  39. # HTTP status 300s mean redirection. 400s and 500s are errors.
  40. # Technically 300s are not errors, but the use case here relies on "requests" to handle redirects automatically.
  41. if backup_list_request.status_code >= 300:
  42. Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text)
  43. Message(catalog.i18nc("@info:backup_status", "There was an error listing your backups."), title = catalog.i18nc("@info:title", "Backup")).show()
  44. return []
  45. backup_list_response = backup_list_request.json()
  46. if "data" not in backup_list_response:
  47. Logger.log("w", "Could not get backups from remote, actual response body was: %s", str(backup_list_response))
  48. return []
  49. return backup_list_response["data"]
  50. def createBackup(self) -> None:
  51. self.creatingStateChanged.emit(is_creating = True)
  52. # Create the backup.
  53. backup_zip_file, backup_meta_data = self._cura_api.backups.createBackup()
  54. if not backup_zip_file or not backup_meta_data:
  55. self.creatingStateChanged.emit(is_creating = False, error_message ="Could not create backup.")
  56. return
  57. # Create an upload entry for the backup.
  58. timestamp = datetime.now().isoformat()
  59. backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
  60. backup_upload_url = self._requestBackupUpload(backup_meta_data, len(backup_zip_file))
  61. if not backup_upload_url:
  62. self.creatingStateChanged.emit(is_creating = False, error_message ="Could not upload backup.")
  63. return
  64. # Upload the backup to storage.
  65. upload_backup_job = UploadBackupJob(backup_upload_url, backup_zip_file)
  66. upload_backup_job.finished.connect(self._onUploadFinished)
  67. upload_backup_job.start()
  68. def _onUploadFinished(self, job: "UploadBackupJob") -> None:
  69. if job.backup_upload_error_message != "":
  70. # If the job contains an error message we pass it along so the UI can display it.
  71. self.creatingStateChanged.emit(is_creating = False, error_message = job.backup_upload_error_message)
  72. else:
  73. self.creatingStateChanged.emit(is_creating = False)
  74. def restoreBackup(self, backup: Dict[str, Any]) -> None:
  75. self.restoringStateChanged.emit(is_restoring = True)
  76. download_url = backup.get("download_url")
  77. if not download_url:
  78. # If there is no download URL, we can't restore the backup.
  79. return self._emitRestoreError()
  80. try:
  81. download_package = requests.get(download_url, stream = True)
  82. except requests.exceptions.ConnectionError:
  83. Logger.logException("e", "Unable to connect with the server")
  84. return self._emitRestoreError()
  85. if download_package.status_code >= 300:
  86. # Something went wrong when attempting to download the backup.
  87. Logger.log("w", "Could not download backup from url %s: %s", download_url, download_package.text)
  88. return self._emitRestoreError()
  89. # We store the file in a temporary path fist to ensure integrity.
  90. temporary_backup_file = NamedTemporaryFile(delete = False)
  91. with open(temporary_backup_file.name, "wb") as write_backup:
  92. for chunk in download_package:
  93. write_backup.write(chunk)
  94. if not self._verifyMd5Hash(temporary_backup_file.name, backup.get("md5_hash", "")):
  95. # Don't restore the backup if the MD5 hashes do not match.
  96. # This can happen if the download was interrupted.
  97. Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.")
  98. return self._emitRestoreError()
  99. # Tell Cura to place the backup back in the user data folder.
  100. with open(temporary_backup_file.name, "rb") as read_backup:
  101. self._cura_api.backups.restoreBackup(read_backup.read(), backup.get("metadata", {}))
  102. self.restoringStateChanged.emit(is_restoring = False)
  103. def _emitRestoreError(self) -> None:
  104. self.restoringStateChanged.emit(is_restoring = False,
  105. error_message = catalog.i18nc("@info:backup_status",
  106. "There was an error trying to restore your backup."))
  107. # Verify the MD5 hash of a file.
  108. # \param file_path Full path to the file.
  109. # \param known_hash The known MD5 hash of the file.
  110. # \return: Success or not.
  111. @staticmethod
  112. def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:
  113. with open(file_path, "rb") as read_backup:
  114. local_md5_hash = base64.b64encode(hashlib.md5(read_backup.read()).digest(), altchars = b"_-").decode("utf-8")
  115. return known_hash == local_md5_hash
  116. def deleteBackup(self, backup_id: str) -> bool:
  117. access_token = self._cura_api.account.accessToken
  118. if not access_token:
  119. Logger.log("w", "Could not get access token.")
  120. return False
  121. try:
  122. delete_backup = requests.delete("{}/{}".format(self.BACKUP_URL, backup_id), headers = {
  123. "Authorization": "Bearer {}".format(access_token)
  124. })
  125. except requests.exceptions.ConnectionError:
  126. Logger.logException("e", "Unable to connect with the server")
  127. return False
  128. if delete_backup.status_code >= 300:
  129. Logger.log("w", "Could not delete backup: %s", delete_backup.text)
  130. return False
  131. return True
  132. # Request a backup upload slot from the API.
  133. # \param backup_metadata: A dict containing some meta data about the backup.
  134. # \param backup_size The size of the backup file in bytes.
  135. # \return: The upload URL for the actual backup file if successful, otherwise None.
  136. def _requestBackupUpload(self, backup_metadata: Dict[str, Any], backup_size: int) -> Optional[str]:
  137. access_token = self._cura_api.account.accessToken
  138. if not access_token:
  139. Logger.log("w", "Could not get access token.")
  140. return None
  141. try:
  142. backup_upload_request = requests.put(
  143. self.BACKUP_URL,
  144. json = {"data": {"backup_size": backup_size,
  145. "metadata": backup_metadata
  146. }
  147. },
  148. headers = {
  149. "Authorization": "Bearer {}".format(access_token)
  150. })
  151. except requests.exceptions.ConnectionError:
  152. Logger.logException("e", "Unable to connect with the server")
  153. return None
  154. # Any status code of 300 or above indicates an error.
  155. if backup_upload_request.status_code >= 300:
  156. Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text)
  157. return None
  158. return backup_upload_request.json()["data"]["upload_url"]