diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index b3b1186281..2a179cfd92 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -5422,9 +5422,9 @@ std::string GUI_App::handle_web_request(std::string cmd) else if (command_str.compare("homepage_open_cloud") == 0) { // Deferred: set_cloud_mode() may create a second native webview, which must // not run inside this webview's script-message callback. - CallAfter([]() { + CallAfter([this]() { if (WebViewPanel* home = WebViewPanel::if_built()) - home->set_cloud_mode(true); + home->set_cloud_mode(true, cloud_webview_handoff()); }); } else if (command_str.compare("homepage_close_cloud") == 0) { @@ -5849,6 +5849,22 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt) }*/ } +wxString GUI_App::cloud_webview_handoff() +{ + auto orca = m_agent ? std::dynamic_pointer_cast(m_agent->get_cloud_agent()) : nullptr; + if (!orca || !orca->is_user_login()) + return {}; + // Never block the UI thread on a token refresh: that can take a cross-process lock plus a + // synchronous POST and retry. If the access token is missing or within a minute of expiry, + // skip the handoff for this open — set_cloud_mode then loads the plain cloud page and the next + // open retries. The pane is the app's guest: the app owns the session and re-injects a + // freshly-refreshed copy on each open (Supabase rotates refresh tokens, so a concurrent + // refresh by the SPA can invalidate the app's copy — a known, accepted trade-off). + if (orca->access_token_needs_refresh(std::chrono::seconds(60))) + return {}; + return from_u8(orca->build_webview_handoff_url()); +} + void GUI_App::on_user_login_handle(wxCommandEvent &evt) { if (!m_agent) { return; } @@ -5904,6 +5920,12 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt) if (app_config->get("sync_user_preset") == "true") { start_sync_user_preset(); } + + // Re-seed the embedded OrcaCloud pane if it is already open, so it picks up the new session + // without waiting for the next open. A closed pane is seeded when it is next opened. + if (WebViewPanel* home = WebViewPanel::if_built()) + if (home->cloud_mode_active()) + home->set_cloud_mode(true, cloud_webview_handoff()); } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 0a2ddc5f17..378feab82d 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -557,6 +557,9 @@ public: void on_update_machine_list(wxCommandEvent& evt); void on_user_login(wxCommandEvent &evt); void on_user_login_handle(wxCommandEvent& evt); + // Returns the fragment handoff URL for the signed-in Orca Cloud session, refreshing the + // token first if needed. Empty when not signed in. + wxString cloud_webview_handoff(); void enable_user_preset_folder(bool enable); // BBS diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp index 3e584cd716..7a62decde3 100644 --- a/src/slic3r/GUI/WebViewDialog.cpp +++ b/src/slic3r/GUI/WebViewDialog.cpp @@ -349,16 +349,28 @@ void WebViewPanel::apply_cloud_layout(bool cloud_on) Layout(); } -void WebViewPanel::set_cloud_mode(bool on) +void WebViewPanel::set_cloud_mode(bool on, const wxString& url) { if (on) { if (!m_cloud_browser) { create_cloud_browser(); - WebView::LoadUrl(m_cloud_browser, kCloudUrl); + if (url.empty()) { + WebView::LoadUrl(m_cloud_browser, wxString(kCloudUrl)); + } else { + // Load the handoff directly: WebView::LoadUrl re-serializes through wxURI, which + // could alter the fragment that carries the session. + m_cloud_browser->LoadURL(url); + } + } else if (!url.empty()) { + // Re-inject a freshly-refreshed session whenever the pane is (re)opened. + m_cloud_browser->LoadURL(url); } m_cloud_browser->Show(); } else if (m_cloud_browser) { m_cloud_browser->Hide(); + // Supabase scrubs with window.location.hash='', so the token fragment can remain in + // history; drop it when the pane is closed. + m_cloud_browser->ClearHistory(); } apply_cloud_layout(on && m_cloud_browser != nullptr); } @@ -730,7 +742,7 @@ void WebViewPanel::update_mode() */ void WebViewPanel::OnNavigationRequest(wxWebViewEvent& evt) { - BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data(); + BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data(); const wxString &url = evt.GetURL(); if (url.StartsWith("File://") || url.StartsWith("file://")) { if (!url.Contains("/web/homepage/index.html")) { @@ -776,7 +788,7 @@ void WebViewPanel::OnNavigationComplete(wxWebViewEvent& evt) { m_browser->Show(); Layout(); - BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data(); + BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data(); if (wxGetApp().get_mode() == comDevelop) wxLogMessage("%s", "Navigation complete; url='" + evt.GetURL() + "'"); UpdateState(); @@ -813,7 +825,7 @@ void WebViewPanel::OnTitleChanged(wxWebViewEvent &evt) */ void WebViewPanel::OnNewWindow(wxWebViewEvent& evt) { - BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data(); + BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data(); wxString flag = " (other)"; if (evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER) diff --git a/src/slic3r/GUI/WebViewDialog.hpp b/src/slic3r/GUI/WebViewDialog.hpp index a67311e0e9..e3d6fae107 100644 --- a/src/slic3r/GUI/WebViewDialog.hpp +++ b/src/slic3r/GUI/WebViewDialog.hpp @@ -115,8 +115,13 @@ public: void update_mode(); - // Shows/hides the native OrcaCloud pane beside this homepage nav column. - void set_cloud_mode(bool on); + // Shows/hides the native OrcaCloud pane beside this homepage nav column. When the pane is + // created, or when `on` is true with a non-empty `url`, that URL is loaded (the fragment + // carries a Supabase session handoff); `url` empty falls back to the cloud home page. + void set_cloud_mode(bool on, const wxString& url = wxString()); + + // True while the OrcaCloud pane exists and is visible. + bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); } bool Show(bool show = true) override; private: diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp index 498fa4ec43..7e022f82d7 100644 --- a/src/slic3r/GUI/Widgets/WebView.cpp +++ b/src/slic3r/GUI/Widgets/WebView.cpp @@ -418,6 +418,12 @@ bool WebView::DownloadAndInstallWebViewRuntime() return DownloadAndInstallWV2RT() == 0; } #endif +wxString WebView::RedactUrl(const wxString& url) +{ + const size_t cut = url.find_first_of(wxString("?#")); + return cut == wxString::npos ? url : url.Left(cut); +} + void WebView::LoadUrl(wxWebView * webView, wxString const &url) { auto url2 = url; @@ -425,7 +431,7 @@ void WebView::LoadUrl(wxWebView * webView, wxString const &url) url2.Replace("\\", "/"); #endif if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); } - BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << url2.ToUTF8(); + BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << RedactUrl(url2).ToUTF8(); webView->LoadURL(url2); } diff --git a/src/slic3r/GUI/Widgets/WebView.hpp b/src/slic3r/GUI/Widgets/WebView.hpp index cdd885f9bd..bef8a4a101 100644 --- a/src/slic3r/GUI/Widgets/WebView.hpp +++ b/src/slic3r/GUI/Widgets/WebView.hpp @@ -21,6 +21,10 @@ public: #endif static void LoadUrl(wxWebView * webView, wxString const &url); + // Returns the URL truncated at the first '?' or '#', so a query/fragment carrying secrets + // (e.g. a session-handoff token) is never written to a log. + static wxString RedactUrl(const wxString& url); + static bool RunScript(wxWebView * webView, wxString const & msg); // Marks "wx" as registered so CreateWebView's deferred add skips the duplicate. diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 5a5cedeb87..3d4aaefb31 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -85,6 +85,8 @@ namespace { constexpr const char* ORCA_DEFAULT_API_URL = "https://api.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_CLOUD_URL = "https://cloud.orcaslicer.com"; +// Host component of ORCA_DEFAULT_CLOUD_URL. The handoff pins to it (build_webview_handoff_url). +constexpr const char* ORCA_DEFAULT_CLOUD_HOST = "cloud.orcaslicer.com"; // Orca: This is a public key with no secret, used to identify the client application to the backend. constexpr const char* ORCA_DEFAULT_PUB_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"; @@ -3018,6 +3020,82 @@ std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& langua return url; } +// True when the two strings are equal ignoring ASCII case. +static bool ascii_iequals(const std::string& a, const std::string& b) +{ + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), + [](char x, char y) { return std::tolower(static_cast(x)) == + std::tolower(static_cast(y)); }); +} + +// Percent-encodes every byte outside the RFC 3986 unreserved set (A-Za-z0-9-._~), so token values +// survive fragment parsing intact. Deliberately tiny: no URL-encoder dependency. +static std::string percent_encode_unreserved(const std::string& s) +{ + static const char* const hex = "0123456789ABCDEF"; + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~') { + out.push_back(static_cast(c)); + } else { + out.push_back('%'); + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0x0F]); + } + } + return out; +} + +std::string OrcaCloudServiceAgent::build_webview_handoff_url() const +{ + if (cloud_base_url.empty()) + return ""; + + // Pin the handoff to the known cloud origin: https, no userinfo, no port (or :443), and the + // exact host. The OrcaCloud pane hardcodes the same host as kCloudUrl in WebViewDialog.cpp, so + // the handoff must target that host; if the pane ever becomes configurable, both must move + // together. Rejecting anything else avoids handing the refresh token to a plaintext or + // attacker-controlled base URL. + const std::string https_prefix = "https://"; + const bool https_scheme = cloud_base_url.size() >= https_prefix.size() && + ascii_iequals(https_prefix, cloud_base_url.substr(0, https_prefix.size())); + const std::string authority = https_scheme ? cloud_base_url.substr(https_prefix.size()) : std::string(); + // host_port = [userinfo@]host[:port], up to the first '/', '?' or '#'. + const std::string host_port = authority.substr(0, authority.find_first_of("/?#")); + const bool has_userinfo = host_port.find('@') != std::string::npos; + const size_t port_sep = host_port.find(':'); + const std::string host = host_port.substr(0, port_sep); + const std::string port = port_sep == std::string::npos ? "" : host_port.substr(port_sep + 1); + const bool host_ok = ascii_iequals(host, ORCA_DEFAULT_CLOUD_HOST); + const bool port_ok = port.empty() || port == "443"; + if (!https_scheme || has_userinfo || !host_ok || !port_ok) { + BOOST_LOG_TRIVIAL(warning) << "refusing OrcaCloud webview handoff: cloud URL is not the pinned https origin"; + return ""; + } + + std::lock_guard lock(session_mutex); + if (!session.logged_in || session.access_token.empty()) + return ""; + + // Supabase's implicit flow (detectSessionInUrl) requires all four fragment parameters. + const auto expires_in = std::max(0, + std::chrono::duration_cast(session.expires_at - std::chrono::system_clock::now()).count()); + + return cloud_base_url + "/#access_token=" + percent_encode_unreserved(session.access_token) + + "&refresh_token=" + percent_encode_unreserved(session.refresh_token) + + "&expires_in=" + std::to_string(expires_in) + + "&token_type=bearer"; +} + +bool OrcaCloudServiceAgent::access_token_needs_refresh(std::chrono::seconds skew) const +{ + std::lock_guard lock(session_mutex); + return should_refresh_locked(skew); +} + int OrcaCloudServiceAgent::get_mw_user_preference(std::function callback) { BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)"; diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index ae9e1f3213..5693d11750 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -203,6 +203,11 @@ public: // ======================================================================== std::string get_cloud_service_host() override; std::string get_cloud_login_url(const std::string& language = "") override; + // URL that hands the current session to the OrcaCloud SPA via the URL fragment + // (Supabase implicit flow / detectSessionInUrl). Empty when not signed in or no cloud URL. + std::string build_webview_handoff_url() const; + // True when the access token is missing or will expire within `skew` (no network). + bool access_token_needs_refresh(std::chrono::seconds skew) const; int connect_server() override; bool is_server_connected() override; int refresh_connection() override;