diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index db3f0c92abd..a422b470258 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -36,7 +36,7 @@ class Backup: IGNORED_FOLDERS = [] # type: List[str] """These folders should be ignored when making a backup.""" - SECRETS_SETTINGS = ["general/ultimaker_auth_data", "cluster_api/auth_ids", "cluster_api/auth_keys", "cluster_api/nonce_counts", "cluster_api/nonces"] + SECRETS_SETTINGS = ["general/ultimaker_auth_data", "cluster_api/auth_ids", "cluster_api/auth_keys"] """Secret preferences that need to obfuscated when making a backup of Cura""" catalog = i18nCatalog("cura") diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index d483e3d8625..3eabf116349 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -2,16 +2,16 @@ # Cura is released under the terms of the LGPLv3 or higher. import hashlib import json -import platform import re import secrets from enum import StrEnum from json import JSONDecodeError from typing import Callable, List, Optional, Dict, Union, Any, Type, cast, TypeVar, Tuple -from PyQt6.QtCore import QUrl +from PyQt6.QtCore import QUrl, QTimer from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from UM import i18nCatalog from UM.Logger import Logger from cura.CuraApplication import CuraApplication @@ -23,6 +23,7 @@ from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus from ..Models.Http.ClusterMaterial import ClusterMaterial +catalog = i18nCatalog("cura") ClusterApiClientModel = TypeVar("ClusterApiClientModel", bound=BaseModel) """The generic type variable used to document the methods below.""" @@ -53,9 +54,10 @@ class ClusterApiClient: AUTH_CNONCE_LEN = 8 AUTH_MAX_TRIES = 5 + REQUEST_MAX_TRIES = 5 # In order to avoid garbage collection we keep the callbacks in this list. - _anti_gc_callbacks = [] # type: List[Callable[[], None]] + _anti_gc_callbacks: List[Callable[[], None]] = [] def __init__(self, address: str, on_error: Callable, on_auth_required: Callable) -> None: """Initializes a new cluster API client. @@ -69,24 +71,25 @@ def __init__(self, address: str, on_error: Callable, on_auth_required: Callable) self._on_error = on_error self._auth_tries = 0 + self._request_tries = 0 self._on_auth_required = on_auth_required + self._pending_auth = False + self._preliminary_auth_id: Optional[str] = None + self._preliminary_auth_key: Optional[str] = None prefs = CuraApplication.getInstance().getPreferences() prefs.addPreference("cluster_api/auth_ids", "{}") prefs.addPreference("cluster_api/auth_keys", "{}") - prefs.addPreference("cluster_api/nonce_counts", "{}") - prefs.addPreference("cluster_api/nonces", "{}") try: self._auth_id = json.loads(prefs.getValue("cluster_api/auth_ids")).get(self._address, None) self._auth_key = json.loads(prefs.getValue("cluster_api/auth_keys")).get(self._address, None) - self._nonce_count = int(json.loads(prefs.getValue("cluster_api/nonce_counts")).get(self._address, 1)) - self._nonce = json.loads(prefs.getValue("cluster_api/nonces")).get(self._address, None) except (JSONDecodeError, TypeError, KeyError) as ex: Logger.info(f"Get new cluster-API auth info ('{str(ex)}').") self._auth_id = None self._auth_key = None - self._nonce_count = 1 - self._nonce = None + + self._nonce_count = 1 + self._nonce = None def _setLocalValueToPrefDict(self, name: str, value: Any) -> None: prefs = CuraApplication.getInstance().getPreferences() @@ -179,8 +182,6 @@ def createEmptyRequest(self, path: str, content_type: Optional[str] = "applicati digest_str = self._makeAuthDigestHeaderPart(path, method=method) request.setRawHeader(b"Authorization", f"Digest {digest_str}".encode("utf-8")) self._nonce_count += 1 - self._setLocalValueToPrefDict("cluster_api/nonce_counts", self._nonce_count) - CuraApplication.getInstance().savePreferences() elif not skip_auth: self._setupAuth() return request @@ -251,9 +252,90 @@ def sha256_utf8(x: str) -> str: f'algorithm="SHA-256"' ]) + def _checkAuth(self) -> None: + """ + Polling function to check whether the user has authorized the application yet. + """ + Logger.info("Checking Cluster API authorization status...") + + def on_finished(resp) -> None: + try: + auth_info = json.loads(resp.data().decode()) + + match (auth_info["message"]): + case "authorized": + Logger.info("Cluster API authorization successful.") + AuthorizationRequiredMessage.hide() + self._auth_id = self._preliminary_auth_id + self._auth_key = self._preliminary_auth_key + + self._setLocalValueToPrefDict("cluster_api/auth_ids", self._auth_id) + self._setLocalValueToPrefDict("cluster_api/auth_keys", self._auth_key) + CuraApplication.getInstance().savePreferences() + + self._preliminary_auth_id = None + self._preliminary_auth_key = None + self._pending_auth = False + return + case "unauthorized": + Logger.warning("Cluster API was denied.") + self._pending_auth = False + return + + except Exception as ex: + Logger.warning(f"Couldn't check authorization status: {str(ex)}") + return + + self._auth_polling_timer = QTimer() + self._auth_polling_timer.setInterval(1000) + self._auth_polling_timer.setSingleShot(True) + self._auth_polling_timer.timeout.connect(self._checkAuth) + self._auth_polling_timer.start() + + url = f"{self.PRINTER_API_PREFIX}/auth/check/{self._preliminary_auth_id}" + reply = self._manager.get(self.createEmptyRequest(url, method=HttpRequestMethod.GET, skip_auth=True)) + + self._addCallback(reply, on_finished) + + def _validateAuth(self) -> None: + """ + Validates whether the current authentication credentials are still valid. + """ + Logger.info("Validating Cluster API authorization status...") + + def on_finished(resp) -> None: + try: + auth_info = json.loads(resp.data().decode()) + + match (auth_info["message"]): + case "authorized": + pass + case _: + # Invalid credentials, clear them + self._auth_id = None + self._auth_key = None + self._setLocalValueToPrefDict("cluster_api/auth_ids", None) + self._setLocalValueToPrefDict("cluster_api/auth_keys", None) + + except Exception as ex: + Logger.warning(f"Couldn't check authorization status: {str(ex)}") + return + + url = f"{self.PRINTER_API_PREFIX}/auth/check/{self._auth_id}" + reply = self._manager.get(self.createEmptyRequest(url, method=HttpRequestMethod.GET, skip_auth=True)) + + self._addCallback(reply, on_finished) + def _setupAuth(self) -> None: """ Handles the setup process for authentication by making a temporary digest-token request to the printer API. """ + if self._pending_auth: + return + Logger.info("Requesting Cluster API authorization...") + + self._pending_auth = True + self._on_auth_required( + catalog.i18nc("@info:status", "Please authorize Cura by approving the request on the printer's display.")) if self._auth_tries >= ClusterApiClient.AUTH_MAX_TRIES: Logger.warning("Maximum authorization temporary digest-token request tries exceeded. Is printer-firmware up to date?") @@ -263,20 +345,19 @@ def on_finished(resp) -> None: self._auth_tries += 1 try: auth_info = json.loads(resp.data().decode()) - self._auth_id = auth_info["id"] - self._auth_key = auth_info["key"] - self._setLocalValueToPrefDict("cluster_api/auth_ids", self._auth_id) - self._setLocalValueToPrefDict("cluster_api/auth_keys", self._auth_key) - CuraApplication.getInstance().savePreferences() + self._preliminary_auth_id = auth_info["id"] + self._preliminary_auth_key = auth_info["key"] + self._checkAuth() except Exception as ex: Logger.warning(f"Couldn't get temporary digest token: {str(ex)}") return self._auth_tries = 0 url = "{}/auth/request".format(self.PRINTER_API_PREFIX) + application_name = CuraApplication.getInstance().getApplicationDisplayName() request_body = json.dumps({ - "application": CuraApplication.getInstance().getApplicationDisplayName(), - "user": f"user@{platform.node()}", + "application": application_name, + "user": f"{application_name} user", }).encode("utf-8") reply = self._manager.post(self.createEmptyRequest(url, method=HttpRequestMethod.POST, skip_auth=True), request_body) @@ -303,24 +384,23 @@ def parse() -> None: return if reply.error() != QNetworkReply.NetworkError.NoError: + Logger.warning("Cluster API request error: %s", reply.errorString()) if reply.error() == QNetworkReply.NetworkError.AuthenticationRequiredError: - self._auth_id = None - self._auth_key = None - - self._on_auth_required(reply.errorString()) nonce_match = re.search(r'nonce="([^"]+)', str(reply.rawHeader(b"WWW-Authenticate"))) if nonce_match: self._nonce = nonce_match.group(1) self._nonce_count = 1 - self._setLocalValueToPrefDict("cluster_api/nonce_counts", self._nonce_count) - self._setLocalValueToPrefDict("cluster_api/nonces", self._nonce) CuraApplication.getInstance().savePreferences() + + self._request_tries += 1 + if self._request_tries >= ClusterApiClient.REQUEST_MAX_TRIES: + self._validateAuth() + return else: self._on_error(reply.errorString()) return - - if self._auth_id and self._auth_key and self._nonce_count > 1: - AuthorizationRequiredMessage.hide() + else: + self._request_tries = 0 # If no parse model is given, simply return the raw data in the callback. if not model: diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 8ce237b22be..320fd475fe9 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -18,7 +18,7 @@ from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType -from .ClusterApiClient import ClusterApiClient +from .ClusterApiClient import ClusterApiClient, HttpRequestMethod from .SendMaterialJob import SendMaterialJob from ..ExportFileJob import ExportFileJob from ..Messages.AuthorizationRequiredMessage import AuthorizationRequiredMessage @@ -118,6 +118,7 @@ def _update(self) -> None: super()._update() if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL: return # avoid calling the cluster too often + self._time_of_last_request = time() self.getApiClient().getPrinters(self._updatePrinters) self.getApiClient().getPrintJobs(self._updatePrintJobs) self._updatePrintJobPreviewImages() @@ -196,17 +197,26 @@ def _startPrintJobUpload(self, unique_name: str = None) -> None: self._progress.show() parts = [ self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"), - self._createFormPart("name=\"file\"; filename=\"%s\"" % self._active_exported_job.getFileName(), - self._active_exported_job.getOutput()) + self._createFormPart( + "name=\"file\"; filename=\"%s\"" % self._active_exported_job.getFileName(), + self._active_exported_job.getOutput() + ) ] # If a specific printer was selected we include the name in the request. # FIXME: Connect should allow the printer UUID here instead of the 'unique_name'. if unique_name is not None: parts.append(self._createFormPart("name=require_printer_name", bytes(unique_name, "utf-8"), "text/plain")) # FIXME: move form posting to API client - self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, - on_progress=self._onPrintJobUploadProgress, - request=self.getApiClient().createEmptyRequest("/cluster-api/v1/print_jobs/", content_type=None, method="POST")) + self.postFormWithParts( + "/cluster-api/v1/print_jobs/", + parts, + on_finished=self._onPrintUploadCompleted, + on_progress=self._onPrintJobUploadProgress, + request=self.getApiClient().createEmptyRequest("/cluster-api/v1/print_jobs/", + content_type=None, + method=HttpRequestMethod.POST, + ) + ) self._active_exported_job = None def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: @@ -216,11 +226,14 @@ def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: self._progress.setProgress(percentage * 100) self.writeProgress.emit() - def _onPrintUploadCompleted(self, _: QNetworkReply) -> None: + def _onPrintUploadCompleted(self, reply: QNetworkReply) -> None: """Handler for when the print job was fully uploaded to the cluster.""" self._progress.hide() - PrintJobUploadSuccessMessage().show() + if reply.error() == QNetworkReply.NetworkError.NoError: + PrintJobUploadSuccessMessage().show() + else: + PrintJobUploadErrorMessage().show() self.writeFinished.emit() def _onUploadError(self, message: str = None) -> None: