diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 2a179cfd92..0274d7ba80 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -325,6 +325,13 @@ namespace GUI { class MainFrame; +// The OrcaCloud agent behind the shared NetworkAgent, or null when networking/OrcaCloud is not +// available (e.g. stealth mode or the network plugin still loading). +static std::shared_ptr get_orca_cloud_agent(NetworkAgent* agent) +{ + return agent ? std::dynamic_pointer_cast(agent->get_cloud_agent()) : nullptr; +} + void start_ping_test() { return; @@ -2272,7 +2279,9 @@ void GUI_App::init_networking_callbacks() if (return_code == 5) { GUI::wxGetApp().CallAfter([this, provider = event.provider] { BOOST_LOG_TRIVIAL(info) << "logout: login expired"; - this->request_user_logout(provider); + // Involuntary: the desktop lost its session on its own. Leave the webview's + // own session alone (guide §8). + this->request_user_logout(provider, /*user_initiated=*/false); MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK); if (msg_dlg.ShowModal() == wxOK) { return; @@ -5249,7 +5258,7 @@ void GUI_App::post_logout_to_webview(const std::string& provider) } } -void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/) +void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/, bool user_initiated) { if (m_agent && m_agent->is_user_login(provider)) { m_agent->user_logout(true, provider); @@ -5262,6 +5271,21 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO } if (provider == ORCA_CLOUD_PROVIDER) { + // Invalidate any handoff still in flight, so a token minted for the signed-out + // account is never loaded into the pane. + ++m_cloud_handoff_gen; + m_cloud_handoff_state.clear(); + + // An explicit user sign-out also ends the webview's own cloud session (guide §8). + // A session the desktop lost on its own must NOT do this: a 2FA user would otherwise + // loop through the code prompt on every desktop re-login. + if (user_initiated) { + if (auto orca = get_orca_cloud_agent(m_agent)) { + if (WebViewPanel* home = WebViewPanel::if_built()) + home->cloud_signed_out(from_u8(orca->build_web_session_signout_url())); + } + } + /* delete old user settings */ bool transfer_preset_changes = false; wxString header = _L("Some presets are modified.") + "\n" + @@ -5383,7 +5407,7 @@ std::string GUI_App::handle_web_request(std::string cmd) else if (command_str.compare("homepage_logout") == 0) { CallAfter([this] { BOOST_LOG_TRIVIAL(info) << "logout: homepage_logout"; - request_user_logout(); + request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true); }); } else if (command_str.compare("get_orca_login_info") == 0) { @@ -5398,7 +5422,7 @@ std::string GUI_App::handle_web_request(std::string cmd) else if (command_str.compare("homepage_bambu_logout") == 0) { CallAfter([this] { BOOST_LOG_TRIVIAL(info) << "logout: homepage_bambu_logout"; - request_user_logout(BBL_CLOUD_PROVIDER); + request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/true); }); } else if (command_str.compare("homepage_orca_login_or_register") == 0) { @@ -5407,7 +5431,7 @@ std::string GUI_App::handle_web_request(std::string cmd) else if (command_str.compare("homepage_orca_logout") == 0) { CallAfter([this] { BOOST_LOG_TRIVIAL(info) << "logout: homepage_orca_logout"; - request_user_logout(ORCA_CLOUD_PROVIDER); + request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true); }); } else if (command_str.compare("homepage_modeldepot") == 0) { @@ -5705,7 +5729,9 @@ void GUI_App::on_http_error(wxCommandEvent &evt) if (!provider.empty() && m_agent->is_user_login(provider)) { if (std::chrono::steady_clock::now() - m_last_401_error_time > 30s) { BOOST_LOG_TRIVIAL(warning) << "logout: http error 401."; - this->request_user_logout(provider); + // Involuntary: a 401 means the desktop session was rejected. Leave the + // webview's own session alone (guide §8). + this->request_user_logout(provider, /*user_initiated=*/false); if (!m_show_http_error_msgdlg) { MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK); @@ -5851,18 +5877,89 @@ 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()) + // The cloud pane loads the Start URL, not a token: the page checks the webview's own session + // and asks for a handoff itself (guide §4). Signed out, user_id is omitted and the page will + // post request_web_session, which is what opens the desktop login dialog. + auto orca = get_orca_cloud_agent(m_agent); + if (!orca) 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()); + return from_u8(orca->build_web_session_start_url()); +} + +void GUI_App::request_cloud_web_session(const std::string& state) +{ + if (state.empty()) + return; + + // Only the newest request counts: a newer state supersedes any mint or login still in flight. + m_cloud_handoff_state = state; + const uint64_t gen = ++m_cloud_handoff_gen; + + auto orca = get_orca_cloud_agent(m_agent); + if (!orca || !orca->is_user_login()) { + // Desktop signed out: open the desktop login dialog and stop here. On success + // on_user_login_handle reloads the Start URL, which makes the page request again; on + // cancel nothing happens (the guide's "on cancel, do nothing"). The newest state is + // already stored, so a repeat request while the dialog is open is dropped rather than + // stacking another dialog. + if (!login_dlg || !login_dlg->IsShown()) + request_login(true, ORCA_CLOUD_PROVIDER); + return; + } + + mint_cloud_web_session(state, gen); +} + +void GUI_App::mint_cloud_web_session(const std::string& state, uint64_t gen) +{ + auto orca = get_orca_cloud_agent(m_agent); + if (!orca) + return; + + // The endpoint call blocks on network I/O (and possibly a cross-process token refresh), so + // it must not run on the UI thread. Hand off to a worker and marshal the token back. + boost::thread([this, orca, state, gen]() { + std::string token_hash; + std::string error_code; + unsigned int http_code = 0; + orca->request_web_session_handoff(token_hash, error_code, http_code); + + if (is_closing()) + return; + + if (token_hash.empty()) { + // No token: 401 after retry, 409/429/503 or a transport error. The page owns its own + // "Sign-in link expired"/"Try again" UI; the desktop stays silent. Never log the body + // (it may echo account data), only the status/error code. + BOOST_LOG_TRIVIAL(warning) << "cloud web session handoff not minted - http_code=" << http_code + << " error_code=" << error_code; + return; + } + + GUI::wxGetApp().CallAfter([this, state, gen, token_hash]() { + on_cloud_web_session_minted(state, gen, token_hash); + }); + }).detach(); +} + +void GUI_App::on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash) +{ + if (is_closing()) + return; + // Drop a result for a superseded request (a newer request_web_session, login or sign-out). + if (gen != m_cloud_handoff_gen || state != m_cloud_handoff_state) + return; + if (token_hash.empty()) + return; + + auto orca = get_orca_cloud_agent(m_agent); + if (!orca) + return; + + // Load the Complete URL in the SAME webview that posted request_web_session, because the + // state lives in that webview's session storage (guide §3/§7). + if (WebViewPanel* home = WebViewPanel::if_built()) + home->load_cloud_url(from_u8(orca->build_web_session_complete_url(token_hash, state))); } void GUI_App::on_user_login_handle(wxCommandEvent &evt) @@ -5922,7 +6019,11 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt) } // 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. + // without waiting for the next open. On a fresh login or account switch this invalidates any + // in-flight handoff for the previous account and loads the Start URL with the new user_id, so + // the page drops the old account and re-requests (guide §8). + ++m_cloud_handoff_gen; + m_cloud_handoff_state.clear(); if (WebViewPanel* home = WebViewPanel::if_built()) if (home->cloud_mode_active()) home->set_cloud_mode(true, cloud_webview_handoff()); @@ -7994,8 +8095,11 @@ void GUI_App::on_stealth_mode_enter() { stop_sync_user_preset(); BOOST_LOG_TRIVIAL(info) << "logout: on_stealth_mode_enter"; - request_user_logout(ORCA_CLOUD_PROVIDER); - request_user_logout(BBL_CLOUD_PROVIDER); + // Stealth mode forces a desktop logout, but the user did not choose "Sign out": guide §8 ties + // ?signout=1 to the explicit desktop sign-out only. Leave the cloud webview's own session + // alone, so it is re-checked (and re-seeded with the right account) on the next open. + request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/false); + request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/false); if (WebViewPanel* home = WebViewPanel::if_built()) { home->SendCloudProvidersInfo(); } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 378feab82d..d6b7258b76 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -361,6 +361,11 @@ private: bool m_side_popup_status{false}; bool m_show_http_error_msgdlg{false}; std::chrono::steady_clock::time_point m_last_401_error_time; + // OrcaCloud webview session handoff: the newest request_web_session state from the cloud pane + // and a generation counter so a superseded mint/login result is dropped (guide §5, "only the + // newest request counts"). + std::string m_cloud_handoff_state; + uint64_t m_cloud_handoff_gen { 0 }; bool m_show_error_msgdlg{false}; wxString m_info_dialog_content; HttpServer m_http_server; @@ -541,7 +546,11 @@ public: void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); - void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER); + // `user_initiated` distinguishes a user sign-out from the desktop losing its session on its + // own (refresh failed, 401). Only an explicit sign-out also ends the cloud webview's own + // session; an involuntary one leaves the webview alone (guide §8). Defaults to the safe + // direction: a new caller that forgets the flag will not silently sign the webview out. + void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER, bool user_initiated = false); void post_logout_to_webview(const std::string& provider); int request_user_unbind(std::string dev_id, const std::string& provider = ORCA_CLOUD_PROVIDER); std::string handle_web_request(std::string cmd); @@ -557,9 +566,15 @@ 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. + // OrcaCloud webview session handoff (guide §5/§7). cloud_webview_handoff builds the cloud + // pane's Start URL (with the desktop user_id while signed in). request_cloud_web_session is + // called from the pane when the page asks for a session; it opens the desktop login dialog if + // signed out, otherwise mints a one-time token on a worker thread and loads the Complete URL + // in the same webview. wxString cloud_webview_handoff(); + void request_cloud_web_session(const std::string& state); + void mint_cloud_web_session(const std::string& state, uint64_t gen); + void on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash); void enable_user_preset_folder(bool enable); // BBS diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 27e101745e..6172095bf5 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -645,7 +645,7 @@ wxBoxSizer *PreferencesDialog::create_item_region_combobox(wxString title, wxStr combobox->SetSelection(current_region); return; } else { - wxGetApp().request_user_logout(); + wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true); config->set("region", region.ToStdString()); auto area = config->get_country_code(); if (agent) { @@ -2479,7 +2479,7 @@ wxBoxSizer* PreferencesDialog::create_debug_page() AppConfig* config = GUI::wxGetApp().app_config; std::string country_code = config->get_country_code(); if (agent) { - wxGetApp().request_user_logout(); + wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true); agent->set_country_code(country_code); } ConfirmBeforeSendDialog confirm_dlg(this, wxID_ANY, _L("Warning"), ConfirmBeforeSendDialog::VisibleButtons::ONLY_CONFIRM); // ORCA VisibleButtons instead ButtonStyle diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp index 930ac3c59a..6ad577c60a 100644 --- a/src/slic3r/GUI/WebGuideDialog.cpp +++ b/src/slic3r/GUI/WebGuideDialog.cpp @@ -753,7 +753,7 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt) if (wxGetApp().is_user_login()) { BOOST_LOG_TRIVIAL(info) << "logout: user_logout on user_guide_finish"; // agent->user_logout(); - wxGetApp().request_user_logout(); + wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true); } } } diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp index 7a62decde3..b4fb4b0566 100644 --- a/src/slic3r/GUI/WebViewDialog.cpp +++ b/src/slic3r/GUI/WebViewDialog.cpp @@ -36,12 +36,17 @@ #include #include #include +#include #include #include #include #include #include + +#ifdef __linux__ +#include +#endif #include namespace pt = boost::property_tree; @@ -307,6 +312,19 @@ void WebViewPanel::create_cloud_browser() { m_cloud_browser = WebView::CreateWebView(this, wxEmptyString); +#ifdef __linux__ + // The pane's web session is a cookie (guide §3), and WebKitGTK keeps cookies only in memory + // unless a persistent store is set up, so without this the sign-in is lost on every restart + // (and on every reset_browser). Each wxWebView has its own context, so give the cloud pane its + // own store instead of sharing PrinterWebView's. Mirrors PrinterWebView::create_browser. + if (auto* wv = static_cast(m_cloud_browser->GetNativeBackend())) { + const auto cookies_path = boost::filesystem::path(data_dir() + "/cache/cloud_cookies.db"); + auto* cookie_manager = webkit_web_context_get_cookie_manager(webkit_web_view_get_context(wv)); + webkit_cookie_manager_set_persistent_storage(cookie_manager, cookies_path.c_str(), + WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE); + } +#endif + // Webview events are command events, so they would otherwise propagate up to the // panel's handlers, which drive the homepage view. Keep the cloud pane's events here. m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATING, &WebViewPanel::OnCloudWebViewEvent, this); @@ -322,18 +340,99 @@ void WebViewPanel::create_cloud_browser() m_content_sizer->Add(m_cloud_browser, wxSizerFlags().Expand().Proportion(1)); } -// Consumed on the cloud view so it never reaches the homepage handlers; new windows open in -// the system browser, the same way PrinterWebView treats them. +// The scheme://host[:port] origin of an http(s) URL, lower-cased and with the default port +// dropped, or empty for any other scheme or a malformed URL. Comparing origins (not a string +// prefix) is what keeps a lookalike host such as "https://cloud.orcaslicer.com.evil.com" out. +static wxString http_origin_of(const wxString& url) +{ + const wxURI uri(url); + if (!uri.HasScheme() || !uri.HasServer()) + return {}; + const wxString scheme = uri.GetScheme().Lower(); + if (scheme != "http" && scheme != "https") + return {}; + wxString origin = scheme + "://" + uri.GetServer().Lower(); + const wxString port = uri.GetPort(); + if (!port.IsEmpty() && port != (scheme == "https" ? "443" : "80")) + origin += ":" + port; + return origin; +} + +// Whether a NAVIGATING event will replace the pane's top-level document rather than load a +// subframe. Edge and macOS report the target frame; WebKitGTK does not (IsTargetMainFrame() is +// always false there), so a user-gesture navigation stands in: a page's own embedded frames load +// without a gesture and stay put, while a user following a link to another site does not. +static bool cloud_navigation_is_top_level(const wxWebViewEvent& evt) +{ +#ifdef __WXGTK__ + return evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER; +#else + return evt.IsTargetMainFrame(); +#endif +} + +// Sends an off-site navigation to the system browser instead of embedding it. Deferred: this runs +// inside a native webview navigation callback, so launching must not re-enter it. +static void open_cloud_url_externally(const wxString& url) +{ + if (url.empty()) + return; + BOOST_LOG_TRIVIAL(info) << "cloud pane: opening externally " << WebView::RedactUrl(url).ToUTF8().data(); + wxGetApp().CallAfter([url] { wxLaunchDefaultBrowser(url); }); +} + +// Consumed on the cloud view so it never reaches the homepage handlers. {web} pages stay in the +// pane; every other site (OAuth providers included) opens in the system browser (guide §3). void WebViewPanel::OnCloudWebViewEvent(wxWebViewEvent& evt) { - if (evt.GetEventType() == wxEVT_WEBVIEW_NEWWINDOW) { + const wxEventType event_type = evt.GetEventType(); + if (event_type == wxEVT_WEBVIEW_NAVIGATING) { const wxString url = evt.GetURL(); - if (!url.empty()) - wxLaunchDefaultBrowser(url); + // Guide §3: keep every {web} page in the pane and open other sites (OAuth providers + // included) in the system browser. Only top-level navigations are diverted, and + // should_divert_cloud_url() excludes same-origin URLs and internal schemes. + if (should_divert_cloud_url(url) && cloud_navigation_is_top_level(evt)) { + evt.Veto(); + open_cloud_url_externally(url); + } + } else if (event_type == wxEVT_WEBVIEW_NEWWINDOW) { + const wxString url = evt.GetURL(); + if (!url.empty()) { + if (is_cloud_origin(url)) + load_cloud_url(url); + else + open_cloud_url_externally(url); + } evt.Veto(); + } else if (event_type == wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED) { + on_cloud_script_message(evt.GetString().utf8_string()); + } else if (event_type == wxEVT_WEBVIEW_LOADED || event_type == wxEVT_WEBVIEW_ERROR) { + // A Start URL deferred behind a sign-out (see load_cloud_after_signout). + if (!m_cloud_after_signout_url.IsEmpty()) { + const wxString next = std::exchange(m_cloud_after_signout_url, wxString()); + load_cloud_url(next); + } } } +// The page asks for a web session by posting JSON through window.wx; only the newest request +// counts (tracked in GUI_App). Nothing here touches the UI directly: the desktop login dialog and +// the network mint must not run on the webview's message-dispatch stack. +void WebViewPanel::on_cloud_script_message(const std::string& body) +{ + const json msg = json::parse(body, nullptr, false); + if (msg.is_discarded() || !msg.is_object() || msg.value("command", std::string()) != "request_web_session") + return; + + std::string state; + if (msg.contains("data") && msg["data"].is_object()) + state = msg["data"].value("state", std::string()); + if (state.empty()) + return; + + wxGetApp().CallAfter([state = std::move(state)] { wxGetApp().request_cloud_web_session(state); }); +} + void WebViewPanel::apply_cloud_layout(bool cloud_on) { if (!m_content_sizer) @@ -349,28 +448,116 @@ void WebViewPanel::apply_cloud_layout(bool cloud_on) Layout(); } +void WebViewPanel::load_cloud_url(const wxString& url) +{ + if (url.empty()) + return; + // Track the origin of whatever the pane is told to load (Start URL, Complete URL, sign-out or + // the plain cloud fallback), so navigation policy has the right origin from the first load on. + const wxString origin = http_origin_of(url); + if (!origin.IsEmpty()) + m_cloud_base_url = origin; + if (!m_cloud_browser) + return; + // Raw LoadURL: WebView::LoadUrl re-serialises through wxURI, which must not alter the + // fragment that carries the one-time handoff token. Log only the redacted URL. + BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(url).ToUTF8().data(); + m_cloud_browser->LoadURL(url); +} + +bool WebViewPanel::is_cloud_origin(const wxString& url) const +{ + const wxString origin = http_origin_of(url); + return !origin.IsEmpty() && !m_cloud_base_url.IsEmpty() && origin == m_cloud_base_url; +} + +bool WebViewPanel::should_divert_cloud_url(const wxString& url) const +{ + const wxString origin = http_origin_of(url); + // Unknown cloud origin or a non-web scheme: never divert (the pane must keep working). + if (origin.IsEmpty() || m_cloud_base_url.IsEmpty()) + return false; + return origin != m_cloud_base_url; +} + +// Explicit sign-out ends the pane's own web session. That session is a cookie JS cannot clear +// (HttpOnly, guide §3/§8) and ClearHistory() is a no-op on WebKitGTK, so drop the pane's stored +// data natively. wx 3.3.2 ignores the type mask on every backend (any non-zero mask clears +// everything) and needs an invalid `since` to delete cookies, so the default "all" call is used. +void WebViewPanel::clear_cloud_storage() +{ + if (m_cloud_browser) + m_cloud_browser->ClearBrowsingData(wxWEBVIEW_BROWSING_DATA_ALL); +} + +void WebViewPanel::load_cloud_after_signout(const wxString& url) +{ + if (m_cloud_pending_signout && !m_cloud_signout_url.IsEmpty()) { + // The pane did not exist when the user signed out, so its persisted session must be + // cleared before the sign-out page runs and `url` loads once that navigation finishes + // (see OnCloudWebViewEvent). + m_cloud_pending_signout = false; + m_cloud_after_signout_url = url; + clear_cloud_storage(); + load_cloud_url(m_cloud_signout_url); + return; + } + load_cloud_url(url); +} + +void WebViewPanel::schedule_cloud_load(const wxString& url) +{ + if (!m_cloud_browser) { + load_cloud_after_signout(url); + return; + } + // CreateWebView queues the "wx" handler add on the view (macOS/Linux); queue the load behind + // it so the page's first scripts already see the bridge (guide §3). + m_cloud_browser->CallAfter([this, url]() { load_cloud_after_signout(url); }); +} + +void WebViewPanel::cloud_signed_out(const wxString& signout_url) +{ + if (signout_url.empty()) + return; + m_cloud_signout_url = signout_url; + // The next Start URL must re-seed the pane (it now reflects the signed-out state), so forget + // the previous seed regardless of which branch below runs. + m_cloud_seed_url.clear(); + if (m_cloud_browser) { + m_cloud_pending_signout = false; + // Run the sign-out page (ends the server-side session), then clear the pane's stored data. + load_cloud_url(signout_url); + clear_cloud_storage(); + } else { + // Pane never opened this run: clear the persisted session on its first open. + m_cloud_pending_signout = true; + } +} + void WebViewPanel::set_cloud_mode(bool on, const wxString& url) { if (on) { + // load_cloud_url records the origin from whatever it loads, including the plain cloud + // fallback used when `url` is empty. if (!m_cloud_browser) { create_cloud_browser(); - 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_seed_url = url; + schedule_cloud_load(url.empty() ? wxString(kCloudUrl) : url); + } else if (!url.empty() && url != m_cloud_seed_url) { + // Re-seed only when the Start URL actually changed — a login, an account switch or a + // sign-out changes it; an ordinary re-open does not (guide §8). Reloading an unchanged + // Start URL re-runs the page's auth check on every open, which flashes "Signing you + // in…" and re-requests a session (guide §10: mint when the page asks, not per open). + // The pane is kept alive, so an unchanged open just shows the page it already holds. + m_cloud_seed_url = url; + load_cloud_after_signout(url); } m_cloud_browser->Show(); } else if (m_cloud_browser) { + // Closing keeps the pane alive with its session, so re-opening does not have to sign in + // again (guide §10). Nothing is cleared here. 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); } @@ -388,8 +575,10 @@ void WebViewPanel::reset_browser() m_cloud_browser->Destroy(); m_cloud_browser = nullptr; create_cloud_browser(); - WebView::LoadUrl(m_cloud_browser, kCloudUrl); + schedule_cloud_load(wxString(kCloudUrl)); m_cloud_browser->Show(cloud_on); + // The rebuilt pane holds no seed, so the next open loads the Start URL again. + m_cloud_seed_url.clear(); } apply_cloud_layout(cloud_on); diff --git a/src/slic3r/GUI/WebViewDialog.hpp b/src/slic3r/GUI/WebViewDialog.hpp index e3d6fae107..07f719331a 100644 --- a/src/slic3r/GUI/WebViewDialog.hpp +++ b/src/slic3r/GUI/WebViewDialog.hpp @@ -115,14 +115,22 @@ public: void update_mode(); - // 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. + // Shows/hides the native OrcaCloud pane beside this homepage nav column. `url` is the + // cloud Start URL ({web}/auth/handoff?next=…&user_id=…), which the page uses to decide + // whether it already has a session; empty falls back to the plain cloud 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(); } + // Loads `url` in the cloud pane — the webview that owns the session handoff. Raw LoadURL (no + // wxURI re-serialisation) so the Complete URL's #fragment stays intact; never logged. + void load_cloud_url(const wxString& url); + + // Clears the cloud pane's own session on an explicit desktop sign-out. If the pane has not + // been created yet, the sign-out is remembered and applied when it is first opened. + void cloud_signed_out(const wxString& signout_url); + bool Show(bool show = true) override; private: void create_browser(); @@ -130,6 +138,20 @@ private: void reset_browser(); // Consumes the cloud pane's webview events so they stay off the homepage handlers. void OnCloudWebViewEvent(wxWebViewEvent& evt); + // True when `url` has the same scheme/host/port as the cloud origin ({web}). + bool is_cloud_origin(const wxString& url) const; + // True when a navigation to `url` must leave the pane: an http(s) URL on another origin than + // the (known) cloud origin. False for non-web schemes and while the origin is unknown. + bool should_divert_cloud_url(const wxString& url) const; + // Parses a request_web_session message and defers it to GUI_App. + void on_cloud_script_message(const std::string& body); + // Loads `url`, first clearing a lazily-required sign-out (see cloud_signed_out). + void load_cloud_after_signout(const wxString& url); + // First load of a freshly created pane: deferred so CreateWebView's queued "wx" handler add + // runs first and the page sees window.wx at load time (guide §3). + void schedule_cloud_load(const wxString& url); + // Clears the pane's stored web data (its cookie-backed session) on an explicit sign-out. + void clear_cloud_storage(); // Re-applies the content sizer's proportions and min sizes for the cloud state. void apply_cloud_layout(bool cloud_on); @@ -138,6 +160,17 @@ private: wxBoxSizer* m_content_sizer{nullptr}; wxString m_home_url; bool m_reset_on_show{false}; + // {web} origin (scheme://host[:port]) of the loaded pane. Set from every load, including the + // plain cloud fallback, so navigation policy can keep {web} pages in the pane and send other + // sites to the system browser. + wxString m_cloud_base_url; + // Start URL the pane was last seeded with. An open whose URL matches this is an ordinary + // re-open (nothing changed), so it only shows the pane instead of reloading it (guide §10). + wxString m_cloud_seed_url; + // Sign-out clear the pane must apply once it exists / next loads. + wxString m_cloud_signout_url; + wxString m_cloud_after_signout_url; + bool m_cloud_pending_signout{false}; wxButton * m_button_stop; wxTextCtrl *m_url; #if !BBL_RELEASE_TO_PUBLIC diff --git a/src/slic3r/Utils/Http.cpp b/src/slic3r/Utils/Http.cpp index c268ef251c..9adbfc8ea1 100644 --- a/src/slic3r/Utils/Http.cpp +++ b/src/slic3r/Utils/Http.cpp @@ -482,6 +482,14 @@ void Http::priv::http_perform() if (!postfields.empty()) { ::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str()); ::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, postfields.size()); + } else if (method == "POST" && form == nullptr && mime == nullptr) { + // A POST with no body. Without CURLOPT_POSTFIELDS, curl takes the request body from + // the read callback, but form_file_read_cb only has a usable user pointer for a PUT + // (set_put_body) or a streamed multipart part (form_add_file); for an empty POST it + // dereferences libcurl's default READDATA as a form_file and crashes. Point curl at a + // zero-length body so the read callback is never used. + ::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str()); + ::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(0)); } CURLcode res = ::curl_easy_perform(curl); diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 3d4aaefb31..d926e8a690 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -85,8 +85,6 @@ 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_"; @@ -110,6 +108,10 @@ constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers"; constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login"; +// Gateway endpoint that mints a one-time web-session handoff token for the signed-in desktop +// user. The cloud pane reloads the returned token into the webview (see build_web_session_*). +constexpr const char* ORCA_WEB_SESSION_HANDOFF_PATH = "/api/v1/account/web-session-handoff"; + constexpr const char* CONFIG_ORCA_API_URL = "orca_api_url"; constexpr const char* CONFIG_ORCA_AUTH_URL = "orca_auth_url"; constexpr const char* CONFIG_ORCA_CLOUD_URL = "orca_cloud_url"; @@ -3020,15 +3022,6 @@ 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) @@ -3049,51 +3042,126 @@ static std::string percent_encode_unreserved(const std::string& s) return out; } -std::string OrcaCloudServiceAgent::build_webview_handoff_url() const +std::string OrcaCloudServiceAgent::build_web_session_start_url(const std::string& next) 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 ""; - } + // The page replaces a `next` that is not a same-site path with /app/profiles, so pass a + // same-site path (the guide's default) and let it validate. + std::string url = cloud_base_url + "/auth/handoff?next=" + + percent_encode_unreserved(next.empty() ? "/app/profiles" : next); 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"; + if (session.logged_in && !session.user_id.empty()) + url += "&user_id=" + percent_encode_unreserved(session.user_id); + return url; } -bool OrcaCloudServiceAgent::access_token_needs_refresh(std::chrono::seconds skew) const +std::string OrcaCloudServiceAgent::build_web_session_complete_url(const std::string& token_hash, + const std::string& state) const { - std::lock_guard lock(session_mutex); - return should_refresh_locked(skew); + if (cloud_base_url.empty() || token_hash.empty() || state.empty()) + return ""; + + // Fragment (not query) so the one-time token never reaches a server or a log. + return cloud_base_url + "/auth/handoff#token_hash=" + percent_encode_unreserved(token_hash) + + "&state=" + percent_encode_unreserved(state); +} + +std::string OrcaCloudServiceAgent::build_web_session_signout_url() const +{ + if (cloud_base_url.empty()) + return ""; + return cloud_base_url + "/auth/handoff?signout=1"; +} + +// Extracts the machine-readable code from a handoff error body. 401 uses {"error":""}; +// the other statuses use {"error":{"code":"","message":"..."}}. +static std::string handoff_error_code(const std::string& body) +{ + const json j = json::parse(body, nullptr, false); + if (j.is_discarded() || !j.is_object() || !j.contains("error")) + return ""; + const auto& err = j["error"]; + if (err.is_string()) + return err.get(); + if (err.is_object() && err.contains("code") && err["code"].is_string()) + return err["code"].get(); + return ""; +} + +int OrcaCloudServiceAgent::request_web_session_handoff(std::string& token_hash, std::string& error_code, + unsigned int& http_code) +{ + token_hash.clear(); + error_code.clear(); + http_code = 0; + + const std::string url = api_base_url + ORCA_WEB_SESSION_HANDOFF_PATH; + BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << ORCA_WEB_SESSION_HANDOFF_PATH; + + // Refresh the desktop token the usual way before minting (newest access token). + ensure_token_fresh("web_session_handoff"); + + auto perform = [&]() { + HttpResult result; + try { + auto http = Http::post(url); + http.tls_verify(true); + + const std::string token = get_access_token(); + + auto headers = data_headers(); + for (const auto& pair : headers) { + http.header(pair.first, pair.second); + } + if (!token.empty()) { + http.header("Authorization", "Bearer " + token); + } + + // No request body: the endpoint identifies the user by the bearer token alone. + http.on_complete([&](std::string resp_body, unsigned resp_status) { + result.success = true; + result.status = resp_status; + result.body = resp_body; + }) + .on_error([&](std::string resp_body, std::string error, unsigned resp_status) { + result.success = false; + result.status = resp_status == 0 ? 404 : resp_status; + result.body = resp_body; + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff HTTP error - " << error; + }) + .timeout_max(30) + .perform_sync(); + + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff exception - " << e.what(); + } + return result; + }; + + HttpResult res = perform(); + // Reuse the shared 401 policy (refresh once, then retry), exactly like http_get/http_post: + // `suppress` is true for a transient refresh failure (keep the session and token, no logout) + // and false when the refresh token was rejected — so a 401 that remains after the refresh + // surfaces through the generic HTTP-error callback and drives the normal desktop logout. + // Every other handoff status (409/429/503/transport) stays silent here, so the page can + // offer "Try again" instead of the desktop showing a generic cloud error. + const bool suppress = resolve_unauthorized(res, perform, "web_session_handoff"); + if (!suppress && res.status == 401) + invoke_http_error_callback(res.status, res.body); + + http_code = res.status; + if (res.status == 200) { + const json j = json::parse(res.body, nullptr, false); + if (!j.is_discarded() && j.is_object() && j.contains("token_hash") && j["token_hash"].is_string()) + token_hash = j["token_hash"].get(); + } else { + error_code = handoff_error_code(res.body); + } + + return (res.success && res.status == 200) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } int OrcaCloudServiceAgent::get_mw_user_preference(std::function callback) diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 5693d11750..524f24ced7 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -203,11 +203,28 @@ 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; + + // ======================================================================== + // OrcaCloud webview session handoff (OrcaCloud "Webview Session Handoff" guide) + // ======================================================================== + // The cloud pane never receives the desktop's access/refresh tokens. Instead the page posts + // request_web_session, the desktop mints a single-use token through the gateway and loads the + // Complete URL; the site then signs itself in. These builders return "" when no cloud URL is + // configured. + // + // Start URL: {web}/auth/handoff?next=[&user_id=]. `next` defaults to + // /app/profiles; user_id is included only while signed in. + std::string build_web_session_start_url(const std::string& next = std::string()) const; + // Complete URL: {web}/auth/handoff#token_hash=&state=. Both values URL-encoded. + std::string build_web_session_complete_url(const std::string& token_hash, const std::string& state) const; + // Clears only the webview's own session; loaded on an explicit desktop sign-out. + std::string build_web_session_signout_url() const; + // POST {api}/api/v1/account/web-session-handoff with the desktop access token (no body), + // refreshing once on 401 through the shared policy. On 200 writes the single-use `token_hash`; + // otherwise writes the server's error code (e.g. WEB_SESSION_HANDOFF_UNAVAILABLE) into + // `error_code`. Blocking: call from a worker thread. Never logs the token. + int request_web_session_handoff(std::string& token_hash, std::string& error_code, unsigned int& http_code); + int connect_server() override; bool is_server_connected() override; int refresh_connection() override;