Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cura/Backups/Backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
134 changes: 107 additions & 27 deletions plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?")
Expand All @@ -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)

Expand All @@ -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:
Expand Down
29 changes: 21 additions & 8 deletions plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading