From 13c8302252563a99fb12c522367dd3e726d608ec Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 2 Jul 2026 21:10:45 +0200 Subject: [PATCH 1/7] feat: add NC URI account setup Add NC URI handling for account setup and provider signup flow support. Signed-off-by: Rello --- .gitignore | 1 + src/gui/CMakeLists.txt | 4 + src/gui/application.cpp | 46 +++--- src/gui/application.h | 4 +- src/gui/cocoainitializer_mac.mm | 20 ++- src/gui/creds/flow2auth.cpp | 2 + src/gui/owncloudsetupwizard.cpp | 100 +++++++++++++ src/gui/owncloudsetupwizard.h | 4 + src/gui/urischemehandler.cpp | 157 +++++++++++++++++++++ src/gui/urischemehandler.h | 33 +++++ src/gui/wizard/accountwizardcontroller.cpp | 2 +- src/gui/wizard/flow2authwidget.cpp | 51 ++++++- src/gui/wizard/flow2authwidget.h | 4 +- src/gui/wizard/flow2authwidget.ui | 3 + src/gui/wizard/providersignuppage.cpp | 44 ++++++ src/gui/wizard/providersignuppage.h | 38 +++++ test/CMakeLists.txt | 1 + test/testurischemehandler.cpp | 111 +++++++++++++++ 18 files changed, 594 insertions(+), 31 deletions(-) create mode 100644 src/gui/urischemehandler.cpp create mode 100644 src/gui/urischemehandler.h create mode 100644 src/gui/wizard/providersignuppage.cpp create mode 100644 src/gui/wizard/providersignuppage.h create mode 100644 test/testurischemehandler.cpp diff --git a/.gitignore b/.gitignore index 921157a060d18..528e79c66f5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ build* cscope.* tags t1.cfg +.idea ## Ignore Visual Studio Code config & environment files diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index b9d83431c7a08..1e570318da00b 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -178,6 +178,8 @@ set(client_SRCS proxyauthdialog.cpp tooltipupdater.h tooltipupdater.cpp + urischemehandler.h + urischemehandler.cpp notificationconfirmjob.h notificationconfirmjob.cpp guiutility.h @@ -241,6 +243,8 @@ set(client_SRCS wizard/accountwizardcontroller.cpp wizard/flow2authwidget.h wizard/flow2authwidget.cpp + wizard/providersignuppage.h + wizard/providersignuppage.cpp wizard/linklabel.h wizard/linklabel.cpp integration/fileactionsmodel.h diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 81eaaabdb9b6d..0158487d0ee8f 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -15,13 +15,13 @@ #include "configfile.h" #include "connectionvalidator.h" #include "creds/abstractcredentials.h" -#include "editlocallymanager.h" #include "folder.h" #include "folderman.h" #include "logger.h" #include "pushnotifications.h" #include "socketapi/socketapi.h" #include "theme.h" +#include "urischemehandler.h" #if defined(BUILD_UPDATER) #include "updater/ocupdater.h" @@ -50,7 +50,6 @@ #include #include #include -#include #include #include #include @@ -488,7 +487,7 @@ Application::Application(int &argc, char **argv) _gui->createTray(); - handleEditLocallyFromOptions(); + handleUriFromOptions(); #ifdef Q_OS_MACOS // If any sync folder needs sandbox reapproval after upgrading to v33+, @@ -666,7 +665,7 @@ void Application::parseOptionsRemoteCommand(const QStringList &options) qApp->quit(); } - handleEditLocallyFromOptions(); + handleUriFromOptions(); if (AccountSetupCommandLineManager::instance()->isCommandLineParsed()) { AccountSetupCommandLineManager::instance()->setupAccountFromCommandLine(); @@ -972,14 +971,17 @@ void Application::parseOptions(const QStringList &options) } else if (option.endsWith(QStringLiteral(APPLICATION_DOTVIRTUALFILE_SUFFIX))) { // virtual file, open it after the Folder were created (if the app is not terminated) QTimer::singleShot(0, this, [this, option] { openVirtualFile(option); }); - } else if (option.startsWith(QStringLiteral(APPLICATION_URI_HANDLER_SCHEME "://open"))) { - // see the section Local file editing of the Architecture page of the user documentation - _editFileLocallyUrl = QUrl::fromUserInput(option); - if (!_editFileLocallyUrl.isValid()) { - _editFileLocallyUrl.clear(); - const auto errorParsingLocalFileEditingUrl = QStringLiteral("The supplied url for local file editing '%1' is invalid!").arg(option); - qCInfo(lcApplication) << errorParsingLocalFileEditingUrl; - showHint(errorParsingLocalFileEditingUrl.toStdString()); + } else if (option.startsWith(QStringLiteral(APPLICATION_URI_HANDLER_SCHEME "://"))) { + _uriSchemeUrl = QUrl{option}; + qCInfo(lcApplication) << "Command line contains custom URI scheme request:" + << "scheme=" << _uriSchemeUrl.scheme() + << "host=" << _uriSchemeUrl.host() + << "path=" << _uriSchemeUrl.path(); + if (!_uriSchemeUrl.isValid()) { + _uriSchemeUrl.clear(); + const auto errorParsingUri = QStringLiteral("The supplied url '%1' is invalid!").arg(option); + qCInfo(lcApplication) << errorParsingUri; + showHint(errorParsingUri.toStdString()); } } else if (option == QStringLiteral("--overrideserverurl")) { if (it.hasNext() && !it.peekNext().startsWith(QLatin1String("--"))) { @@ -1122,14 +1124,19 @@ void Application::setHelp() _helpOnly = true; } -void Application::handleEditLocallyFromOptions() +void Application::handleUriFromOptions() { - if (!_editFileLocallyUrl.isValid()) { + if (!_uriSchemeUrl.isValid()) { + qCDebug(lcApplication) << "No pending custom URI scheme request from command line options."; return; } - EditLocallyManager::instance()->handleRequest(_editFileLocallyUrl); - _editFileLocallyUrl.clear(); + qCInfo(lcApplication) << "Dispatching pending custom URI scheme request from command line options:" + << "scheme=" << _uriSchemeUrl.scheme() + << "host=" << _uriSchemeUrl.host() + << "path=" << _uriSchemeUrl.path(); + UriSchemeHandler::handleUri(_uriSchemeUrl); + _uriSchemeUrl.clear(); } QString enforcedLanguage() @@ -1290,8 +1297,11 @@ bool Application::event(QEvent *event) } else if (!openEvent->url().isEmpty() && openEvent->url().isValid()) { // On macOS, Qt does not handle receiving a custom URI as it does on other systems (as an application argument). // Instead, it sends out a QFileOpenEvent. We therefore need custom handling for our URI handling on macOS. - qCInfo(lcApplication) << "macOS: Opening local file for editing: " << openEvent->url(); - EditLocallyManager::instance()->handleRequest(openEvent->url()); + qCInfo(lcApplication) << "macOS QFileOpenEvent contains custom URI scheme request:" + << "scheme=" << openEvent->url().scheme() + << "host=" << openEvent->url().host() + << "path=" << openEvent->url().path(); + UriSchemeHandler::handleUri(openEvent->url()); } else { const auto errorParsingLocalFileEditingUrl = QStringLiteral("The supplied url for local file editing '%1' is invalid!").arg(openEvent->url().toString()); qCInfo(lcApplication) << errorParsingLocalFileEditingUrl; diff --git a/src/gui/application.h b/src/gui/application.h index c43e4143a90db..f0ee5dd06c623 100644 --- a/src/gui/application.h +++ b/src/gui/application.h @@ -120,7 +120,7 @@ protected slots: private: void setHelp(); - void handleEditLocallyFromOptions(); + void handleUriFromOptions(); AccountManager::AccountsRestoreResult restoreLegacyAccount(); void setupConfigFile(); @@ -163,7 +163,7 @@ protected slots: bool _userTriggeredConnect = false; bool _debugMode = false; bool _backgroundMode = false; - QUrl _editFileLocallyUrl; + QUrl _uriSchemeUrl; ClientProxy _proxy; diff --git a/src/gui/cocoainitializer_mac.mm b/src/gui/cocoainitializer_mac.mm index 38693aa3c9a3c..7f707ae5bc783 100644 --- a/src/gui/cocoainitializer_mac.mm +++ b/src/gui/cocoainitializer_mac.mm @@ -11,7 +11,9 @@ #import #include "application.h" -#include "editlocallymanager.h" +#include "urischemehandler.h" + +#include /* In theory, we should be able to just capture QFileOpenEvents * when we open our custom URLs in our Application class and be @@ -52,7 +54,21 @@ - (void)handleURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEv { NSURL* url = [NSURL URLWithString:[[event paramDescriptorForKeyword:keyDirectObject] stringValue]]; const auto qtUrl = QUrl::fromNSURL(url); - OCC::EditLocallyManager::instance()->handleRequest(qtUrl); + qCInfo(OCC::lcApplication) << "macOS AppleEvent contains custom URI scheme request:" + << "scheme=" << qtUrl.scheme() + << "host=" << qtUrl.host() + << "path=" << qtUrl.path(); + if (qApp) { + QTimer::singleShot(0, qApp, [qtUrl] { + qCInfo(OCC::lcApplication) << "Dispatching macOS AppleEvent custom URI scheme request:" + << "scheme=" << qtUrl.scheme() + << "host=" << qtUrl.host() + << "path=" << qtUrl.path(); + OCC::UriSchemeHandler::handleUri(qtUrl); + }); + } else { + qCWarning(OCC::lcApplication) << "Could not dispatch macOS AppleEvent custom URI scheme request because qApp is not available yet."; + } } @end diff --git a/src/gui/creds/flow2auth.cpp b/src/gui/creds/flow2auth.cpp index a6056d0238e11..845092e727fc3 100644 --- a/src/gui/creds/flow2auth.cpp +++ b/src/gui/creds/flow2auth.cpp @@ -20,6 +20,8 @@ #include #include +#include + namespace OCC { Q_LOGGING_CATEGORY(lcFlow2auth, "nextcloud.sync.credentials.flow2auth", QtInfoMsg) diff --git a/src/gui/owncloudsetupwizard.cpp b/src/gui/owncloudsetupwizard.cpp index db1c0b6bde594..59529a5651885 100644 --- a/src/gui/owncloudsetupwizard.cpp +++ b/src/gui/owncloudsetupwizard.cpp @@ -6,6 +6,7 @@ #include "owncloudsetupwizard.h" +#include "accountmanager.h" #include "folderman.h" #include "systray.h" #include "theme.h" @@ -70,6 +71,35 @@ void OwncloudSetupWizard::runWizard(QObject *obj, const char *amember, QWidget * owncloudSetupWizard.clear(); } +void OwncloudSetupWizard::runWizardForLoginFlow(QObject *obj, const char *amember, const QUrl &serverUrl, QWidget *parent) +{ +#if defined ENFORCE_SINGLE_ACCOUNT + if (!AccountManager::instance()->accounts().isEmpty()) { + return; + } +#endif + + if (!owncloudSetupWizard.isNull()) { + qCInfo(lcWizard) << "Restarting existing setup wizard for URI login flow."; + FolderMan::instance()->setSyncEnabled(false); + owncloudSetupWizard->startWizardForLoginFlow(serverUrl); + bringWizardToFrontIfVisible(); + return; + } + + owncloudSetupWizard = new OwncloudSetupWizard(parent); + connect(owncloudSetupWizard, SIGNAL(ownCloudWizardDone(int)), obj, amember); + + FolderMan::instance()->setSyncEnabled(false); + if (owncloudSetupWizard->startQmlWizardForLoginFlow(serverUrl)) { + return; + } + + emit owncloudSetupWizard->ownCloudWizardDone(QDialog::Rejected); + owncloudSetupWizard->deleteLater(); + owncloudSetupWizard.clear(); +} + bool OwncloudSetupWizard::bringWizardToFrontIfVisible() { if (owncloudSetupWizard.isNull() @@ -151,6 +181,76 @@ bool OwncloudSetupWizard::startQmlWizard() return true; } +bool OwncloudSetupWizard::startQmlWizardForLoginFlow(const QUrl &serverUrl) +{ + auto *engine = Systray::instance()->trayEngine(); + if (!engine) { + qCWarning(lcWizard) << "Cannot start QML account wizard without a QML engine."; + return false; + } + + _qmlController = new AccountWizardController(this); + _qmlController->setServerUrl(serverUrl.toString()); + _qmlController->submitServerUrl(); + + QQmlComponent component(engine, QStringLiteral("qrc:/qml/src/gui/wizard/qml/AccountWizardWindow.qml")); + QVariantMap initialProperties; + initialProperties.insert(QStringLiteral("controller"), QVariant::fromValue(_qmlController)); + auto *createdObject = component.createWithInitialProperties(initialProperties); + + if (component.isError()) { + qCWarning(lcWizard) << "Failed to load QML account wizard:" << component.errors(); + } + + _qmlWizardWindow = qobject_cast(createdObject); + if (!_qmlWizardWindow) { + if (createdObject) { + createdObject->deleteLater(); + } + _qmlController->deleteLater(); + _qmlController = nullptr; + return false; + } + + _qmlWizardWindow->setIcon(Theme::instance()->applicationIcon()); + +#ifdef Q_OS_MACOS + auto *fgbg = new ForegroundBackground(this); + _qmlWizardWindow->installEventFilter(fgbg); +#endif + + connect(_qmlController, &AccountWizardController::finished, this, &OwncloudSetupWizard::finish); + connect(_qmlWizardWindow, &QQuickWindow::visibleChanged, this, [this](bool visible) { + if (!visible) { + finish(QDialog::Rejected); + } + }); + connect(_qmlWizardWindow, &QObject::destroyed, this, [this] { + finish(QDialog::Rejected); + }); + + _qmlWizardWindow->show(); + _qmlWizardWindow->raise(); + _qmlWizardWindow->requestActivate(); + +#ifdef Q_OS_MACOS + styleNativeTitleBar(_qmlWizardWindow, /*hideTitleText=*/true); + connect(_qmlWizardWindow, &QQuickWindow::colorChanged, this, [this] { + styleNativeTitleBar(_qmlWizardWindow, /*hideTitleText=*/true); + }); +#endif + + return true; +} + +void OwncloudSetupWizard::startWizardForLoginFlow(const QUrl &serverUrl) +{ + if (_qmlController) { + _qmlController->setServerUrl(serverUrl.toString()); + _qmlController->submitServerUrl(); + } +} + void OwncloudSetupWizard::finish(int result) { if (_finished) { diff --git a/src/gui/owncloudsetupwizard.h b/src/gui/owncloudsetupwizard.h index 4c2558a577b0b..cbb1ff83fd170 100644 --- a/src/gui/owncloudsetupwizard.h +++ b/src/gui/owncloudsetupwizard.h @@ -10,6 +10,7 @@ #include #include #include +#include class QQuickWindow; @@ -27,6 +28,7 @@ class OwncloudSetupWizard : public QObject public: /** Run the wizard */ static void runWizard(QObject *obj, const char *amember, QWidget *parent = nullptr, bool forceRestart = false); + static void runWizardForLoginFlow(QObject *obj, const char *amember, const QUrl &serverUrl, QWidget *parent = nullptr); static bool bringWizardToFrontIfVisible(); signals: @@ -37,6 +39,8 @@ class OwncloudSetupWizard : public QObject explicit OwncloudSetupWizard(QObject *parent = nullptr); ~OwncloudSetupWizard() override; bool startQmlWizard(); + bool startQmlWizardForLoginFlow(const QUrl &serverUrl); + void startWizardForLoginFlow(const QUrl &serverUrl); void finish(int result); AccountWizardController *_qmlController = nullptr; diff --git a/src/gui/urischemehandler.cpp b/src/gui/urischemehandler.cpp new file mode 100644 index 0000000000000..047bc1d40a775 --- /dev/null +++ b/src/gui/urischemehandler.cpp @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "urischemehandler.h" + +#include "accountmanager.h" +#include "config.h" +#include "editlocallymanager.h" +#include "owncloudsetupwizard.h" +#include "systray.h" + +#include +#include +#include +#include +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcUriSchemeHandler, "nextcloud.gui.urischemehandler", QtInfoMsg) + +namespace { + +constexpr auto openAction = "open"; +constexpr auto loginAction = "login"; +constexpr auto loginServerPathPrefix = "/server:"; + +[[nodiscard]] QString describeUriForLog(const QUrl &url) +{ + const auto query = QUrlQuery{url}; + QStringList queryItemNames; + const auto queryItems = query.queryItems(); + for (const auto &queryItem : queryItems) { + queryItemNames.append(queryItem.first); + } + + return QStringLiteral("scheme=%1 host=%2 path=%3 queryItems=%4") + .arg(url.scheme(), + url.host(), + url.path(), + queryItemNames.join(QLatin1Char(','))); +} + +[[nodiscard]] bool isValidServerUrl(const QUrl &serverUrl) +{ + return serverUrl.isValid() + && !serverUrl.isRelative() + && !serverUrl.host().isEmpty() + && (serverUrl.scheme() == QStringLiteral("http") || serverUrl.scheme() == QStringLiteral("https")); +} + +void showWarning(const QString &message) +{ + qCWarning(lcUriSchemeHandler) << message; + + if (auto systray = Systray::instance()) { + systray->showMessage(QApplication::translate("UriSchemeHandler", "Could not handle link"), + message, + QSystemTrayIcon::Warning); + } +} + +} + +UriSchemeHandler::ParsedUri UriSchemeHandler::parseUri(const QUrl &url) +{ + qCInfo(lcUriSchemeHandler) << "Parsing URI scheme request:" << describeUriForLog(url); + + ParsedUri result; + result.originalUrl = url; + + if (!url.isValid()) { + result.error = QStringLiteral("The supplied URL is invalid."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + + if (url.scheme() != QStringLiteral(APPLICATION_URI_HANDLER_SCHEME)) { + result.error = QStringLiteral("The supplied URL does not use the supported scheme."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + + const auto action = url.host().toCaseFolded(); + if (action == QLatin1String(openAction)) { + result.action = Action::OpenLocalEdit; + qCInfo(lcUriSchemeHandler) << "Accepted URI scheme request for local edit:" << describeUriForLog(url); + return result; + } + + if (action != QLatin1String(loginAction)) { + result.error = QStringLiteral("The supplied URL action is not supported."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + + const auto path = url.path(QUrl::FullyDecoded); + if (!path.startsWith(QLatin1String(loginServerPathPrefix))) { + result.error = QStringLiteral("The login URL must use the nc://login/server:{server} path."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + + const auto serverUrlValue = path.mid(QString::fromLatin1(loginServerPathPrefix).size()); + const auto serverUrl = QUrl{serverUrlValue}; + qCInfo(lcUriSchemeHandler) << "Decoded login server URL:" + << "isValid=" << serverUrl.isValid() + << "isRelative=" << serverUrl.isRelative() + << "scheme=" << serverUrl.scheme() + << "host=" << serverUrl.host() + << "path=" << serverUrl.path(); + if (!isValidServerUrl(serverUrl)) { + result.error = QStringLiteral("The login URL contains an invalid server URL."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + + result.action = Action::Login; + result.serverUrl = serverUrl; + qCInfo(lcUriSchemeHandler) << "Accepted URI scheme request to log in to server:" + << serverUrl.toString(QUrl::RemoveUserInfo | QUrl::RemoveQuery | QUrl::RemoveFragment); + return result; +} + +bool UriSchemeHandler::handleUri(const QUrl &url) +{ + qCInfo(lcUriSchemeHandler) << "Handling URI scheme request:" << describeUriForLog(url); + + const auto parsedUri = parseUri(url); + switch (parsedUri.action) { + case Action::OpenLocalEdit: + qCInfo(lcUriSchemeHandler) << "Dispatching URI scheme request to local edit manager."; + EditLocallyManager::instance()->handleRequest(parsedUri.originalUrl); + return true; + case Action::Login: +#if defined ENFORCE_SINGLE_ACCOUNT + if (!AccountManager::instance()->accounts().isEmpty()) { + qCWarning(lcUriSchemeHandler) << "Rejected login URI scheme request because this client enforces a single account."; + showWarning(QApplication::translate("UriSchemeHandler", "Adding another account is not allowed in this client.")); + return false; + } +#endif + qCInfo(lcUriSchemeHandler) << "Dispatching URI scheme request to quick account setup."; + OwncloudSetupWizard::runWizardForLoginFlow(qApp, SLOT(slotownCloudWizardDone(int)), parsedUri.serverUrl); + return true; + case Action::Invalid: + qCWarning(lcUriSchemeHandler) << "URI scheme request was not dispatched:" << parsedUri.error; + showWarning(parsedUri.error); + return false; + } + + Q_UNREACHABLE(); +} + +} diff --git a/src/gui/urischemehandler.h b/src/gui/urischemehandler.h new file mode 100644 index 0000000000000..f7b16a07b383a --- /dev/null +++ b/src/gui/urischemehandler.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +namespace OCC { + +class UriSchemeHandler +{ +public: + enum class Action { + Invalid, + OpenLocalEdit, + Login, + }; + + struct ParsedUri { + Action action = Action::Invalid; + QUrl originalUrl; + QUrl serverUrl; + QString error; + }; + + [[nodiscard]] static ParsedUri parseUri(const QUrl &url); + static bool handleUri(const QUrl &url); +}; + +} diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 69bb618529453..43c8ba6eda8a2 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -903,7 +903,7 @@ void AccountWizardController::copyLoginLink() void AccountWizardController::openSignup() { - Utility::openBrowser(QUrl(QStringLiteral("https://nextcloud.com/register"))); + Utility::openBrowser(QUrl(QStringLiteral("https://nextcloud.com/sign-up/?flow=V3"))); } void AccountWizardController::openSelfHostedServerGuide() diff --git a/src/gui/wizard/flow2authwidget.cpp b/src/gui/wizard/flow2authwidget.cpp index 28803697f9d83..3b6b0c2030958 100644 --- a/src/gui/wizard/flow2authwidget.cpp +++ b/src/gui/wizard/flow2authwidget.cpp @@ -5,7 +5,7 @@ #include "flow2authwidget.h" -#include "common/utility.h" +#include "guiutility.h" #include "account.h" #include "creds/webflowcredentials.h" #include "guiutility.h" @@ -18,6 +18,8 @@ #include #include #include +#include +#include namespace OCC { @@ -56,6 +58,8 @@ void Flow2AuthWidget::setLogo() void Flow2AuthWidget::startAuth(Account *account) { + _externalUrl.clear(); + const auto oldAuth = _asyncAuth.release(); if (oldAuth) { oldAuth->deleteLater(); @@ -76,6 +80,29 @@ void Flow2AuthWidget::startAuth(Account *account) } } +void Flow2AuthWidget::startProviderSignup(const QUrl &signupUrl) +{ + const auto oldAuth = _asyncAuth.release(); + if (oldAuth) { + oldAuth->deleteLater(); + } + + _account = nullptr; + _externalUrl = signupUrl; + _statusUpdateSkipCount = 0; + _ui.errorLabel->hide(); + _ui.errorLabel->clear(); + _ui.label->setText(tr("Complete your provider signup in your browser")); + _ui.statusLabel->setText(tr("Please complete the provider signup in your browser. " + "After the completion, confirm to open the Nextcloud application popup in your browser.")); + stopSpinner(true); + + if (!Utility::openBrowser(_externalUrl)) { + _ui.errorLabel->setText(tr("Unable to open the Browser, please copy the link to your Browser.")); + _ui.errorLabel->show(); + } +} + void Flow2AuthWidget::resetAuth(Account *account) { startAuth(account); @@ -124,8 +151,14 @@ void Flow2AuthWidget::slotOpenBrowser() if (_ui.errorLabel) _ui.errorLabel->hide(); - if (_asyncAuth) + if (!_externalUrl.isEmpty()) { + if (!Utility::openBrowser(_externalUrl)) { + _ui.errorLabel->setText(tr("Unable to open the Browser, please copy the link to your Browser.")); + _ui.errorLabel->show(); + } + } else if (_asyncAuth) { _asyncAuth->openBrowser(); + } } void Flow2AuthWidget::slotCopyLinkToClipboard() @@ -133,8 +166,12 @@ void Flow2AuthWidget::slotCopyLinkToClipboard() if (_ui.errorLabel) _ui.errorLabel->hide(); - if (_asyncAuth) + if (!_externalUrl.isEmpty()) { + QApplication::clipboard()->setText(_externalUrl.toString(QUrl::FullyEncoded)); + _ui.statusLabel->setText(tr("Link copied to clipboard.")); + } else if (_asyncAuth) { _asyncAuth->copyLinkToClipboard(); + } } void Flow2AuthWidget::slotPollNow() @@ -157,7 +194,7 @@ void Flow2AuthWidget::slotStatusChanged(Flow2Auth::PollStatus status, int second case Flow2Auth::statusPollNow: _statusUpdateSkipCount = 0; _ui.statusLabel->setText(tr("Polling for authorization") + "…"); - startSpinner(); + startSpinner(true); break; case Flow2Auth::statusFetchToken: _statusUpdateSkipCount = 0; @@ -172,15 +209,15 @@ void Flow2AuthWidget::slotStatusChanged(Flow2Auth::PollStatus status, int second } } -void Flow2AuthWidget::startSpinner() +void Flow2AuthWidget::startSpinner(bool keepLinkButtonsEnabled) { _ui.progressLayout->setEnabled(true); _ui.statusLabel->setVisible(true); _progressIndi->setVisible(true); _progressIndi->startAnimation(); - _ui.openLinkButton->setEnabled(false); - _ui.copyLinkButton->setEnabled(false); + _ui.openLinkButton->setEnabled(keepLinkButtonsEnabled); + _ui.copyLinkButton->setEnabled(keepLinkButtonsEnabled); } void Flow2AuthWidget::stopSpinner(bool showStatusLabel) diff --git a/src/gui/wizard/flow2authwidget.h b/src/gui/wizard/flow2authwidget.h index 565b04ed69e24..a4c38dee95c0d 100644 --- a/src/gui/wizard/flow2authwidget.h +++ b/src/gui/wizard/flow2authwidget.h @@ -25,6 +25,7 @@ class Flow2AuthWidget : public QWidget ~Flow2AuthWidget() override; void startAuth(Account *account); + void startProviderSignup(const QUrl &signupUrl); void resetAuth(Account *account = nullptr); void setError(const QString &error); @@ -41,6 +42,7 @@ public Q_SLOTS: private: Account *_account = nullptr; std::unique_ptr _asyncAuth; + QUrl _externalUrl; Ui_Flow2AuthWidget _ui{}; protected Q_SLOTS: @@ -48,7 +50,7 @@ protected Q_SLOTS: void slotCopyLinkToClipboard(); private: - void startSpinner(); + void startSpinner(bool keepLinkButtonsEnabled = false); void stopSpinner(bool showStatusLabel); void customizeStyle(); void setLogo(); diff --git a/src/gui/wizard/flow2authwidget.ui b/src/gui/wizard/flow2authwidget.ui index e98f3c6d5e515..756fe65dbf554 100644 --- a/src/gui/wizard/flow2authwidget.ui +++ b/src/gui/wizard/flow2authwidget.ui @@ -107,6 +107,9 @@ Qt::AlignCenter + + true + 0 diff --git a/src/gui/wizard/providersignuppage.cpp b/src/gui/wizard/providersignuppage.cpp new file mode 100644 index 0000000000000..2687141217b01 --- /dev/null +++ b/src/gui/wizard/providersignuppage.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "providersignuppage.h" + +#include "wizard/flow2authwidget.h" + +#include +#include + +namespace OCC { + +ProviderSignupPage::ProviderSignupPage(QWidget *parent) + : QWizardPage(parent) + , _layout(new QVBoxLayout(this)) + , _flow2AuthWidget(new Flow2AuthWidget) +{ + _layout->addWidget(_flow2AuthWidget); +} + +void ProviderSignupPage::initializePage() +{ + _flow2AuthWidget->startProviderSignup(QUrl(QStringLiteral("https://nextcloud.com/sign-up/?flow=V3"))); + _flow2AuthWidget->slotStyleChanged(); +} + +void ProviderSignupPage::cleanupPage() +{ + _flow2AuthWidget->resetAuth(); +} + +bool ProviderSignupPage::isComplete() const +{ + return false; +} + +void ProviderSignupPage::slotStyleChanged() +{ + _flow2AuthWidget->slotStyleChanged(); +} + +} // namespace OCC diff --git a/src/gui/wizard/providersignuppage.h b/src/gui/wizard/providersignuppage.h new file mode 100644 index 0000000000000..fe612dff3084b --- /dev/null +++ b/src/gui/wizard/providersignuppage.h @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef PROVIDERSIGNUPPAGE_H +#define PROVIDERSIGNUPPAGE_H + +#include + +class QVBoxLayout; + +namespace OCC { + +class Flow2AuthWidget; + +class ProviderSignupPage : public QWizardPage +{ + Q_OBJECT + +public: + explicit ProviderSignupPage(QWidget *parent = nullptr); + + void initializePage() override; + void cleanupPage() override; + [[nodiscard]] bool isComplete() const override; + +public slots: + void slotStyleChanged(); + +private: + QVBoxLayout *_layout = nullptr; + Flow2AuthWidget *_flow2AuthWidget = nullptr; +}; + +} // namespace OCC + +#endif // PROVIDERSIGNUPPAGE_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index cc5f9a3b72a7a..75c2820bec183 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -167,6 +167,7 @@ endif() nextcloud_add_test(NetworkSettings) nextcloud_add_test(UpdateChannel) nextcloud_add_test(FileActionsModel) +nextcloud_add_test(UriSchemeHandler) configure_file(test_journal.db "${PROJECT_BINARY_DIR}/bin/test_journal.db" COPYONLY) diff --git a/test/testurischemehandler.cpp b/test/testurischemehandler.cpp new file mode 100644 index 0000000000000..9a6794e748083 --- /dev/null +++ b/test/testurischemehandler.cpp @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: CC0-1.0 + */ + +#include + +#include + +#include "urischemehandler.h" + +using namespace OCC; + +Q_DECLARE_METATYPE(OCC::UriSchemeHandler::Action) + +class TestUriSchemeHandler : public QObject +{ + Q_OBJECT + +private slots: + void parseUri_data() + { + QTest::addColumn("url"); + QTest::addColumn("expectedAction"); + QTest::addColumn("expectedServerUrl"); + QTest::addColumn("expectError"); + + QTest::newRow("login") + << QUrl(QStringLiteral("nc://login/server:https://cloud.example.com")) + << UriSchemeHandler::Action::Login + << QUrl(QStringLiteral("https://cloud.example.com")) + << false; + + QTest::newRow("login encoded server") + << QUrl(QStringLiteral("nc://login/server:https%3A%2F%2Fcloud.example.com")) + << UriSchemeHandler::Action::Login + << QUrl(QStringLiteral("https://cloud.example.com")) + << false; + + QTest::newRow("login uppercase host") + << QUrl(QStringLiteral("nc://LOGIN/server:https://cloud.example.com")) + << UriSchemeHandler::Action::Login + << QUrl(QStringLiteral("https://cloud.example.com")) + << false; + + QTest::newRow("login missing server prefix") + << QUrl(QStringLiteral("nc://login/https://cloud.example.com")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("login missing server url") + << QUrl(QStringLiteral("nc://login/server:")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("login relative server url") + << QUrl(QStringLiteral("nc://login/server:cloud.example.com")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("login hostless server url") + << QUrl(QStringLiteral("nc://login/server:https://")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("login unsupported server url scheme") + << QUrl(QStringLiteral("nc://login/server:ftp://cloud.example.com")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("old add account") + << QUrl(QStringLiteral("nc://addAccount/?serverUrl=https%3A%2F%2Fcloud.example.com")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + + QTest::newRow("local edit") + << QUrl(QStringLiteral("nc://open/admin@cloud.example.com/Documents/file.txt?token=secret")) + << UriSchemeHandler::Action::OpenLocalEdit + << QUrl{} + << false; + + QTest::newRow("unknown action") + << QUrl(QStringLiteral("nc://unknown/")) + << UriSchemeHandler::Action::Invalid + << QUrl{} + << true; + } + + void parseUri() + { + QFETCH(QUrl, url); + QFETCH(UriSchemeHandler::Action, expectedAction); + QFETCH(QUrl, expectedServerUrl); + QFETCH(bool, expectError); + + const auto result = UriSchemeHandler::parseUri(url); + + QCOMPARE(result.action, expectedAction); + QCOMPARE(result.serverUrl, expectedServerUrl); + QCOMPARE(result.error.isEmpty(), !expectError); + } +}; + +QTEST_APPLESS_MAIN(TestUriSchemeHandler) +#include "testurischemehandler.moc" From 841519692f5b45ed737bc8e30c11ccf973a93fbc Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 3 Jul 2026 09:45:04 +0200 Subject: [PATCH 2/7] fix: only allow valid URLs Signed-off-by: Rello --- src/gui/urischemehandler.cpp | 75 +++++++++++++++++++++++++++++++++++ test/testurischemehandler.cpp | 56 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/gui/urischemehandler.cpp b/src/gui/urischemehandler.cpp index 047bc1d40a775..fb606f594a511 100644 --- a/src/gui/urischemehandler.cpp +++ b/src/gui/urischemehandler.cpp @@ -10,8 +10,13 @@ #include "editlocallymanager.h" #include "owncloudsetupwizard.h" #include "systray.h" +#include "theme.h" #include +#include +#include +#include +#include #include #include #include @@ -51,6 +56,70 @@ constexpr auto loginServerPathPrefix = "/server:"; && (serverUrl.scheme() == QStringLiteral("http") || serverUrl.scheme() == QStringLiteral("https")); } +[[nodiscard]] QUrl normalizedServerUrlForComparison(const QUrl &serverUrl) +{ + auto normalizedUrl = serverUrl.adjusted(QUrl::NormalizePathSegments + | QUrl::StripTrailingSlash + | QUrl::RemoveUserInfo + | QUrl::RemoveQuery + | QUrl::RemoveFragment); + normalizedUrl.setScheme(normalizedUrl.scheme().toCaseFolded()); + normalizedUrl.setHost(normalizedUrl.host().toCaseFolded()); + + if ((normalizedUrl.scheme() == QStringLiteral("http") && normalizedUrl.port() == 80) + || (normalizedUrl.scheme() == QStringLiteral("https") && normalizedUrl.port() == 443)) { + normalizedUrl.setPort(-1); + } + if (normalizedUrl.path() == QStringLiteral("/")) { + normalizedUrl.setPath(QString{}); + } + + return normalizedUrl; +} + +[[nodiscard]] QList configuredOverrideServerUrls() +{ + const auto theme = Theme::instance(); + auto serverUrls = QList{}; + + if (theme->multipleOverrideServers()) { + const auto serversJsonArray = QJsonDocument::fromJson(theme->overrideServerUrl().toUtf8()).array(); + for (const auto &serverJson : serversJsonArray) { + const auto serverUrl = QUrl{serverJson.toObject().value(QStringLiteral("url")).toString()}; + if (isValidServerUrl(serverUrl)) { + serverUrls.append(serverUrl); + } + } + return serverUrls; + } + + const auto serverUrl = QUrl{theme->overrideServerUrl()}; + if (isValidServerUrl(serverUrl)) { + serverUrls.append(serverUrl); + } + + return serverUrls; +} + +[[nodiscard]] bool isAllowedByConfiguredServerOverride(const QUrl &serverUrl) +{ + const auto theme = Theme::instance(); + if (theme->overrideServerUrl().isEmpty() + || (!theme->forceOverrideServerUrl() && !theme->multipleOverrideServers())) { + return true; + } + + const auto normalizedServerUrl = normalizedServerUrlForComparison(serverUrl); + const auto allowedServerUrls = configuredOverrideServerUrls(); + for (const auto &allowedServerUrl : allowedServerUrls) { + if (normalizedServerUrlForComparison(allowedServerUrl) == normalizedServerUrl) { + return true; + } + } + + return false; +} + void showWarning(const QString &message) { qCWarning(lcUriSchemeHandler) << message; @@ -117,6 +186,12 @@ UriSchemeHandler::ParsedUri UriSchemeHandler::parseUri(const QUrl &url) return result; } + if (!isAllowedByConfiguredServerOverride(serverUrl)) { + result.error = QStringLiteral("The login URL is not allowed by the configured server restriction."); + qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); + return result; + } + result.action = Action::Login; result.serverUrl = serverUrl; qCInfo(lcUriSchemeHandler) << "Accepted URI scheme request to log in to server:" diff --git a/test/testurischemehandler.cpp b/test/testurischemehandler.cpp index 9a6794e748083..26a7c14d7a385 100644 --- a/test/testurischemehandler.cpp +++ b/test/testurischemehandler.cpp @@ -5,8 +5,10 @@ #include +#include #include +#include "theme.h" #include "urischemehandler.h" using namespace OCC; @@ -99,12 +101,66 @@ private slots: QFETCH(QUrl, expectedServerUrl); QFETCH(bool, expectError); + auto theme = Theme::instance(); + theme->setOverrideServerUrl(QString{}); + theme->setForceOverrideServerUrl(false); + const auto result = UriSchemeHandler::parseUri(url); QCOMPARE(result.action, expectedAction); QCOMPARE(result.serverUrl, expectedServerUrl); QCOMPARE(result.error.isEmpty(), !expectError); } + + void parseUriHonoursForcedSingleServerOverride() + { + auto theme = Theme::instance(); + const auto previousOverrideServerUrl = theme->overrideServerUrl(); + const auto previousForceOverrideServerUrl = theme->forceOverrideServerUrl(); + const auto restoreTheme = qScopeGuard([theme, previousOverrideServerUrl, previousForceOverrideServerUrl] { + theme->setOverrideServerUrl(previousOverrideServerUrl); + theme->setForceOverrideServerUrl(previousForceOverrideServerUrl); + }); + + theme->setOverrideServerUrl(QStringLiteral("https://cloud.example.com")); + theme->setForceOverrideServerUrl(true); + + const auto acceptedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://cloud.example.com/"))); + QCOMPARE(acceptedResult.action, UriSchemeHandler::Action::Login); + QCOMPARE(acceptedResult.serverUrl, QUrl(QStringLiteral("https://cloud.example.com/"))); + QVERIFY(acceptedResult.error.isEmpty()); + + const auto rejectedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://unconfigured.example.com"))); + QCOMPARE(rejectedResult.action, UriSchemeHandler::Action::Invalid); + QVERIFY(!rejectedResult.error.isEmpty()); + } + + void parseUriHonoursMultipleServerOverrides() + { + auto theme = Theme::instance(); + const auto previousOverrideServerUrl = theme->overrideServerUrl(); + const auto previousForceOverrideServerUrl = theme->forceOverrideServerUrl(); + const auto restoreTheme = qScopeGuard([theme, previousOverrideServerUrl, previousForceOverrideServerUrl] { + theme->setOverrideServerUrl(previousOverrideServerUrl); + theme->setForceOverrideServerUrl(previousForceOverrideServerUrl); + }); + + theme->setOverrideServerUrl(QStringLiteral( + "[" + R"({"name": "Primary", "url": "https://primary.example.com"},)" + R"({"name": "Secondary", "url": "https://secondary.example.com"})" + "]")); + theme->setForceOverrideServerUrl(true); + + const auto acceptedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://secondary.example.com"))); + QCOMPARE(acceptedResult.action, UriSchemeHandler::Action::Login); + QCOMPARE(acceptedResult.serverUrl, QUrl(QStringLiteral("https://secondary.example.com"))); + QVERIFY(acceptedResult.error.isEmpty()); + + const auto rejectedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://unconfigured.example.com"))); + QCOMPARE(rejectedResult.action, UriSchemeHandler::Action::Invalid); + QVERIFY(!rejectedResult.error.isEmpty()); + } }; QTEST_APPLESS_MAIN(TestUriSchemeHandler) From 33a23270541f38cecff584a77eb2db7aeaf23734 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 3 Jul 2026 11:37:22 +0200 Subject: [PATCH 3/7] fix: only allow valid URLs and reduce logging Signed-off-by: Rello --- src/gui/application.cpp | 4 -- src/gui/cocoainitializer_mac.mm | 6 +-- src/gui/urischemehandler.cpp | 72 +++++++++++---------------------- test/testurischemehandler.cpp | 35 ++++++++++++++++ 4 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 0158487d0ee8f..2a537ce8b1a98 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -1131,10 +1131,6 @@ void Application::handleUriFromOptions() return; } - qCInfo(lcApplication) << "Dispatching pending custom URI scheme request from command line options:" - << "scheme=" << _uriSchemeUrl.scheme() - << "host=" << _uriSchemeUrl.host() - << "path=" << _uriSchemeUrl.path(); UriSchemeHandler::handleUri(_uriSchemeUrl); _uriSchemeUrl.clear(); } diff --git a/src/gui/cocoainitializer_mac.mm b/src/gui/cocoainitializer_mac.mm index 7f707ae5bc783..845c07f9e7d94 100644 --- a/src/gui/cocoainitializer_mac.mm +++ b/src/gui/cocoainitializer_mac.mm @@ -54,13 +54,9 @@ - (void)handleURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEv { NSURL* url = [NSURL URLWithString:[[event paramDescriptorForKeyword:keyDirectObject] stringValue]]; const auto qtUrl = QUrl::fromNSURL(url); - qCInfo(OCC::lcApplication) << "macOS AppleEvent contains custom URI scheme request:" - << "scheme=" << qtUrl.scheme() - << "host=" << qtUrl.host() - << "path=" << qtUrl.path(); if (qApp) { QTimer::singleShot(0, qApp, [qtUrl] { - qCInfo(OCC::lcApplication) << "Dispatching macOS AppleEvent custom URI scheme request:" + qCInfo(OCC::lcApplication) << "macOS AppleEvent custom URI scheme request:" << "scheme=" << qtUrl.scheme() << "host=" << qtUrl.host() << "path=" << qtUrl.path(); diff --git a/src/gui/urischemehandler.cpp b/src/gui/urischemehandler.cpp index fb606f594a511..03e966066e61e 100644 --- a/src/gui/urischemehandler.cpp +++ b/src/gui/urischemehandler.cpp @@ -7,6 +7,7 @@ #include "accountmanager.h" #include "config.h" +#include "configfile.h" #include "editlocallymanager.h" #include "owncloudsetupwizard.h" #include "systray.h" @@ -18,9 +19,7 @@ #include #include #include -#include #include -#include namespace OCC { @@ -32,22 +31,6 @@ constexpr auto openAction = "open"; constexpr auto loginAction = "login"; constexpr auto loginServerPathPrefix = "/server:"; -[[nodiscard]] QString describeUriForLog(const QUrl &url) -{ - const auto query = QUrlQuery{url}; - QStringList queryItemNames; - const auto queryItems = query.queryItems(); - for (const auto &queryItem : queryItems) { - queryItemNames.append(queryItem.first); - } - - return QStringLiteral("scheme=%1 host=%2 path=%3 queryItems=%4") - .arg(url.scheme(), - url.host(), - url.path(), - queryItemNames.join(QLatin1Char(','))); -} - [[nodiscard]] bool isValidServerUrl(const QUrl &serverUrl) { return serverUrl.isValid() @@ -77,13 +60,14 @@ constexpr auto loginServerPathPrefix = "/server:"; return normalizedUrl; } -[[nodiscard]] QList configuredOverrideServerUrls() +[[nodiscard]] QList configuredOverrideServerUrls(const QString &overrideServerUrl) { - const auto theme = Theme::instance(); auto serverUrls = QList{}; - if (theme->multipleOverrideServers()) { - const auto serversJsonArray = QJsonDocument::fromJson(theme->overrideServerUrl().toUtf8()).array(); + QJsonParseError jsonParseError; + const auto serversJsonDocument = QJsonDocument::fromJson(overrideServerUrl.toUtf8(), &jsonParseError); + if (jsonParseError.error == QJsonParseError::NoError && serversJsonDocument.isArray()) { + const auto serversJsonArray = serversJsonDocument.array(); for (const auto &serverJson : serversJsonArray) { const auto serverUrl = QUrl{serverJson.toObject().value(QStringLiteral("url")).toString()}; if (isValidServerUrl(serverUrl)) { @@ -93,7 +77,7 @@ constexpr auto loginServerPathPrefix = "/server:"; return serverUrls; } - const auto serverUrl = QUrl{theme->overrideServerUrl()}; + const auto serverUrl = QUrl{overrideServerUrl}; if (isValidServerUrl(serverUrl)) { serverUrls.append(serverUrl); } @@ -103,14 +87,27 @@ constexpr auto loginServerPathPrefix = "/server:"; [[nodiscard]] bool isAllowedByConfiguredServerOverride(const QUrl &serverUrl) { + const auto configuredOverrideServerUrl = ConfigFile{}.overrideServerUrl(); + if (!configuredOverrideServerUrl.isEmpty()) { + const auto normalizedServerUrl = normalizedServerUrlForComparison(serverUrl); + const auto allowedServerUrls = configuredOverrideServerUrls(configuredOverrideServerUrl); + for (const auto &allowedServerUrl : allowedServerUrls) { + if (normalizedServerUrlForComparison(allowedServerUrl) == normalizedServerUrl) { + return true; + } + } + + return false; + } + const auto theme = Theme::instance(); if (theme->overrideServerUrl().isEmpty() - || (!theme->forceOverrideServerUrl() && !theme->multipleOverrideServers())) { + || !theme->forceOverrideServerUrl()) { return true; } const auto normalizedServerUrl = normalizedServerUrlForComparison(serverUrl); - const auto allowedServerUrls = configuredOverrideServerUrls(); + const auto allowedServerUrls = configuredOverrideServerUrls(theme->overrideServerUrl()); for (const auto &allowedServerUrl : allowedServerUrls) { if (normalizedServerUrlForComparison(allowedServerUrl) == normalizedServerUrl) { return true; @@ -122,8 +119,6 @@ constexpr auto loginServerPathPrefix = "/server:"; void showWarning(const QString &message) { - qCWarning(lcUriSchemeHandler) << message; - if (auto systray = Systray::instance()) { systray->showMessage(QApplication::translate("UriSchemeHandler", "Could not handle link"), message, @@ -135,78 +130,58 @@ void showWarning(const QString &message) UriSchemeHandler::ParsedUri UriSchemeHandler::parseUri(const QUrl &url) { - qCInfo(lcUriSchemeHandler) << "Parsing URI scheme request:" << describeUriForLog(url); - ParsedUri result; result.originalUrl = url; if (!url.isValid()) { result.error = QStringLiteral("The supplied URL is invalid."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } if (url.scheme() != QStringLiteral(APPLICATION_URI_HANDLER_SCHEME)) { result.error = QStringLiteral("The supplied URL does not use the supported scheme."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } const auto action = url.host().toCaseFolded(); if (action == QLatin1String(openAction)) { result.action = Action::OpenLocalEdit; - qCInfo(lcUriSchemeHandler) << "Accepted URI scheme request for local edit:" << describeUriForLog(url); return result; } if (action != QLatin1String(loginAction)) { result.error = QStringLiteral("The supplied URL action is not supported."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } const auto path = url.path(QUrl::FullyDecoded); if (!path.startsWith(QLatin1String(loginServerPathPrefix))) { result.error = QStringLiteral("The login URL must use the nc://login/server:{server} path."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } const auto serverUrlValue = path.mid(QString::fromLatin1(loginServerPathPrefix).size()); const auto serverUrl = QUrl{serverUrlValue}; - qCInfo(lcUriSchemeHandler) << "Decoded login server URL:" - << "isValid=" << serverUrl.isValid() - << "isRelative=" << serverUrl.isRelative() - << "scheme=" << serverUrl.scheme() - << "host=" << serverUrl.host() - << "path=" << serverUrl.path(); if (!isValidServerUrl(serverUrl)) { result.error = QStringLiteral("The login URL contains an invalid server URL."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } if (!isAllowedByConfiguredServerOverride(serverUrl)) { result.error = QStringLiteral("The login URL is not allowed by the configured server restriction."); - qCWarning(lcUriSchemeHandler) << "Rejected URI scheme request:" << result.error << describeUriForLog(url); return result; } result.action = Action::Login; result.serverUrl = serverUrl; - qCInfo(lcUriSchemeHandler) << "Accepted URI scheme request to log in to server:" - << serverUrl.toString(QUrl::RemoveUserInfo | QUrl::RemoveQuery | QUrl::RemoveFragment); return result; } bool UriSchemeHandler::handleUri(const QUrl &url) { - qCInfo(lcUriSchemeHandler) << "Handling URI scheme request:" << describeUriForLog(url); - const auto parsedUri = parseUri(url); switch (parsedUri.action) { case Action::OpenLocalEdit: - qCInfo(lcUriSchemeHandler) << "Dispatching URI scheme request to local edit manager."; EditLocallyManager::instance()->handleRequest(parsedUri.originalUrl); return true; case Action::Login: @@ -217,11 +192,10 @@ bool UriSchemeHandler::handleUri(const QUrl &url) return false; } #endif - qCInfo(lcUriSchemeHandler) << "Dispatching URI scheme request to quick account setup."; OwncloudSetupWizard::runWizardForLoginFlow(qApp, SLOT(slotownCloudWizardDone(int)), parsedUri.serverUrl); return true; case Action::Invalid: - qCWarning(lcUriSchemeHandler) << "URI scheme request was not dispatched:" << parsedUri.error; + qCWarning(lcUriSchemeHandler) << "Could not handle URI scheme request:" << parsedUri.error; showWarning(parsedUri.error); return false; } diff --git a/test/testurischemehandler.cpp b/test/testurischemehandler.cpp index 26a7c14d7a385..892466b5ef5e3 100644 --- a/test/testurischemehandler.cpp +++ b/test/testurischemehandler.cpp @@ -6,8 +6,10 @@ #include #include +#include #include +#include "configfile.h" #include "theme.h" #include "urischemehandler.h" @@ -20,6 +22,11 @@ class TestUriSchemeHandler : public QObject Q_OBJECT private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + } + void parseUri_data() { QTest::addColumn("url"); @@ -104,6 +111,7 @@ private slots: auto theme = Theme::instance(); theme->setOverrideServerUrl(QString{}); theme->setForceOverrideServerUrl(false); + ConfigFile{}.setOverrideServerUrl(QString{}); const auto result = UriSchemeHandler::parseUri(url); @@ -135,6 +143,33 @@ private slots: QVERIFY(!rejectedResult.error.isEmpty()); } + void parseUriHonoursConfiguredServerOverride() + { + ConfigFile cfg; + const auto previousConfiguredOverrideServerUrl = cfg.overrideServerUrl(); + auto theme = Theme::instance(); + const auto previousOverrideServerUrl = theme->overrideServerUrl(); + const auto previousForceOverrideServerUrl = theme->forceOverrideServerUrl(); + const auto restoreState = qScopeGuard([&cfg, theme, previousConfiguredOverrideServerUrl, previousOverrideServerUrl, previousForceOverrideServerUrl] { + cfg.setOverrideServerUrl(previousConfiguredOverrideServerUrl); + theme->setOverrideServerUrl(previousOverrideServerUrl); + theme->setForceOverrideServerUrl(previousForceOverrideServerUrl); + }); + + cfg.setOverrideServerUrl(QStringLiteral("https://configured.example.com")); + theme->setOverrideServerUrl(QString{}); + theme->setForceOverrideServerUrl(false); + + const auto acceptedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://configured.example.com"))); + QCOMPARE(acceptedResult.action, UriSchemeHandler::Action::Login); + QCOMPARE(acceptedResult.serverUrl, QUrl(QStringLiteral("https://configured.example.com"))); + QVERIFY(acceptedResult.error.isEmpty()); + + const auto rejectedResult = UriSchemeHandler::parseUri(QUrl(QStringLiteral("nc://login/server:https://unconfigured.example.com"))); + QCOMPARE(rejectedResult.action, UriSchemeHandler::Action::Invalid); + QVERIFY(!rejectedResult.error.isEmpty()); + } + void parseUriHonoursMultipleServerOverrides() { auto theme = Theme::instance(); From 1ce817cebb3958e489d3ae314dba8a9236f73d33 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 3 Jul 2026 14:49:23 +0200 Subject: [PATCH 4/7] fix: cancel a running login flow if a new one arrives Signed-off-by: Rello --- src/gui/owncloudsetupwizard.cpp | 14 ++++---------- src/gui/owncloudsetupwizard.h | 1 - 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/gui/owncloudsetupwizard.cpp b/src/gui/owncloudsetupwizard.cpp index 59529a5651885..098257f0ead2d 100644 --- a/src/gui/owncloudsetupwizard.cpp +++ b/src/gui/owncloudsetupwizard.cpp @@ -81,8 +81,10 @@ void OwncloudSetupWizard::runWizardForLoginFlow(QObject *obj, const char *amembe if (!owncloudSetupWizard.isNull()) { qCInfo(lcWizard) << "Restarting existing setup wizard for URI login flow."; - FolderMan::instance()->setSyncEnabled(false); - owncloudSetupWizard->startWizardForLoginFlow(serverUrl); + owncloudSetupWizard->finish(QDialog::Rejected); + } + + if (!owncloudSetupWizard.isNull()) { bringWizardToFrontIfVisible(); return; } @@ -243,14 +245,6 @@ bool OwncloudSetupWizard::startQmlWizardForLoginFlow(const QUrl &serverUrl) return true; } -void OwncloudSetupWizard::startWizardForLoginFlow(const QUrl &serverUrl) -{ - if (_qmlController) { - _qmlController->setServerUrl(serverUrl.toString()); - _qmlController->submitServerUrl(); - } -} - void OwncloudSetupWizard::finish(int result) { if (_finished) { diff --git a/src/gui/owncloudsetupwizard.h b/src/gui/owncloudsetupwizard.h index cbb1ff83fd170..ab4d691895af7 100644 --- a/src/gui/owncloudsetupwizard.h +++ b/src/gui/owncloudsetupwizard.h @@ -40,7 +40,6 @@ class OwncloudSetupWizard : public QObject ~OwncloudSetupWizard() override; bool startQmlWizard(); bool startQmlWizardForLoginFlow(const QUrl &serverUrl); - void startWizardForLoginFlow(const QUrl &serverUrl); void finish(int result); AccountWizardController *_qmlController = nullptr; From 866749ab2862942a4d087b8ca349d42881e6224e Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 6 Jul 2026 11:04:30 +0200 Subject: [PATCH 5/7] fix: remove unused and stabilize the code Signed-off-by: Rello --- src/gui/application.cpp | 14 +++++++++++++- src/gui/application.h | 1 + src/gui/creds/flow2auth.cpp | 2 -- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 2a537ce8b1a98..9452e2b4e7c3b 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -818,6 +818,12 @@ void Application::slotSystemOnlineConfigurationChanged() void Application::slotCheckConnection() { if (AccountManager::instance()->accounts().isEmpty()) { + if (_suppressNextEmptyAccountCheck) { + qCInfo(lcApplication) << "Suppressing empty-account check after handling login URI."; + _suppressNextEmptyAccountCheck = false; + return; + } + // let gui open the setup wizard _gui->slotOpenSettingsDialog(); @@ -1131,7 +1137,13 @@ void Application::handleUriFromOptions() return; } - UriSchemeHandler::handleUri(_uriSchemeUrl); + const auto parsedUri = UriSchemeHandler::parseUri(_uriSchemeUrl); + if (UriSchemeHandler::handleUri(_uriSchemeUrl) + && parsedUri.action == UriSchemeHandler::Action::Login + && AccountManager::instance()->accounts().isEmpty()) { + _suppressNextEmptyAccountCheck = true; + _checkConnectionTimer.stop(); + } _uriSchemeUrl.clear(); } diff --git a/src/gui/application.h b/src/gui/application.h index f0ee5dd06c623..f98995c15055b 100644 --- a/src/gui/application.h +++ b/src/gui/application.h @@ -163,6 +163,7 @@ protected slots: bool _userTriggeredConnect = false; bool _debugMode = false; bool _backgroundMode = false; + bool _suppressNextEmptyAccountCheck = false; QUrl _uriSchemeUrl; ClientProxy _proxy; diff --git a/src/gui/creds/flow2auth.cpp b/src/gui/creds/flow2auth.cpp index 845092e727fc3..a6056d0238e11 100644 --- a/src/gui/creds/flow2auth.cpp +++ b/src/gui/creds/flow2auth.cpp @@ -20,8 +20,6 @@ #include #include -#include - namespace OCC { Q_LOGGING_CATEGORY(lcFlow2auth, "nextcloud.sync.credentials.flow2auth", QtInfoMsg) From 58406923b3e64de2598001d9a0de990d01e335f1 Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 6 Jul 2026 13:19:28 +0200 Subject: [PATCH 6/7] fix: prevent double account wizard Signed-off-by: Rello --- src/gui/application.cpp | 28 +++++++++++---- src/gui/application.h | 1 + src/gui/cocoainitializer_mac.mm | 7 ++-- src/gui/owncloudsetupwizard.cpp | 7 +++- src/gui/wizard/accountwizardcontroller.cpp | 42 ++++++++++++++++++++++ src/gui/wizard/accountwizardcontroller.h | 1 + 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 9452e2b4e7c3b..40cba58df8b56 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -1137,14 +1137,30 @@ void Application::handleUriFromOptions() return; } - const auto parsedUri = UriSchemeHandler::parseUri(_uriSchemeUrl); - if (UriSchemeHandler::handleUri(_uriSchemeUrl) - && parsedUri.action == UriSchemeHandler::Action::Login - && AccountManager::instance()->accounts().isEmpty()) { + handleUriSchemeRequest(_uriSchemeUrl); + _uriSchemeUrl.clear(); +} + +bool Application::handleUriSchemeRequest(const QUrl &url) +{ + const auto parsedUri = UriSchemeHandler::parseUri(url); + const auto suppressEmptyAccountCheck = parsedUri.action == UriSchemeHandler::Action::Login + && AccountManager::instance()->accounts().isEmpty(); + const auto wasCheckConnectionTimerActive = _checkConnectionTimer.isActive(); + if (suppressEmptyAccountCheck) { _suppressNextEmptyAccountCheck = true; _checkConnectionTimer.stop(); } - _uriSchemeUrl.clear(); + + const auto handled = UriSchemeHandler::handleUri(url); + if (!handled && suppressEmptyAccountCheck) { + _suppressNextEmptyAccountCheck = false; + if (wasCheckConnectionTimerActive) { + _checkConnectionTimer.start(); + } + } + + return handled; } QString enforcedLanguage() @@ -1309,7 +1325,7 @@ bool Application::event(QEvent *event) << "scheme=" << openEvent->url().scheme() << "host=" << openEvent->url().host() << "path=" << openEvent->url().path(); - UriSchemeHandler::handleUri(openEvent->url()); + handleUriSchemeRequest(openEvent->url()); } else { const auto errorParsingLocalFileEditingUrl = QStringLiteral("The supplied url for local file editing '%1' is invalid!").arg(openEvent->url().toString()); qCInfo(lcApplication) << errorParsingLocalFileEditingUrl; diff --git a/src/gui/application.h b/src/gui/application.h index f98995c15055b..a59e35cca23dd 100644 --- a/src/gui/application.h +++ b/src/gui/application.h @@ -74,6 +74,7 @@ class Application : public QApplication bool sendMessage(const QString &message); void showMainDialog(); + bool handleUriSchemeRequest(const QUrl &url); [[nodiscard]] ownCloudGui *gui() const; diff --git a/src/gui/cocoainitializer_mac.mm b/src/gui/cocoainitializer_mac.mm index 845c07f9e7d94..91404b154fa57 100644 --- a/src/gui/cocoainitializer_mac.mm +++ b/src/gui/cocoainitializer_mac.mm @@ -11,7 +11,6 @@ #import #include "application.h" -#include "urischemehandler.h" #include @@ -60,7 +59,11 @@ - (void)handleURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEv << "scheme=" << qtUrl.scheme() << "host=" << qtUrl.host() << "path=" << qtUrl.path(); - OCC::UriSchemeHandler::handleUri(qtUrl); + if (auto application = qobject_cast(qApp)) { + application->handleUriSchemeRequest(qtUrl); + } else { + qCWarning(OCC::lcApplication) << "Could not dispatch macOS AppleEvent custom URI scheme request because qApp is not an Application."; + } }); } else { qCWarning(OCC::lcApplication) << "Could not dispatch macOS AppleEvent custom URI scheme request because qApp is not available yet."; diff --git a/src/gui/owncloudsetupwizard.cpp b/src/gui/owncloudsetupwizard.cpp index 098257f0ead2d..02b302c194bcd 100644 --- a/src/gui/owncloudsetupwizard.cpp +++ b/src/gui/owncloudsetupwizard.cpp @@ -192,7 +192,12 @@ bool OwncloudSetupWizard::startQmlWizardForLoginFlow(const QUrl &serverUrl) } _qmlController = new AccountWizardController(this); - _qmlController->setServerUrl(serverUrl.toString()); + if (!_qmlController->setServerUrlForLoginFlow(serverUrl)) { + qCWarning(lcWizard) << "Cannot start QML account wizard for URI login flow with an unconfigured server URL."; + _qmlController->deleteLater(); + _qmlController = nullptr; + return false; + } _qmlController->submitServerUrl(); QQmlComponent component(engine, QStringLiteral("qrc:/qml/src/gui/wizard/qml/AccountWizardWindow.qml")); diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 43c8ba6eda8a2..d99f6d6437046 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -68,6 +68,27 @@ bool localFolderContainsData(const QString &localSyncFolder) && !localFolder.entryList(QDir::AllEntries | QDir::NoDotAndDotDot).isEmpty(); } +[[nodiscard]] QUrl normalizedServerUrlForComparison(const QUrl &serverUrl) +{ + auto normalizedUrl = serverUrl.adjusted(QUrl::NormalizePathSegments + | QUrl::StripTrailingSlash + | QUrl::RemoveUserInfo + | QUrl::RemoveQuery + | QUrl::RemoveFragment); + normalizedUrl.setScheme(normalizedUrl.scheme().toCaseFolded()); + normalizedUrl.setHost(normalizedUrl.host().toCaseFolded()); + + if ((normalizedUrl.scheme() == "http"_L1 && normalizedUrl.port() == 80) + || (normalizedUrl.scheme() == "https"_L1 && normalizedUrl.port() == 443)) { + normalizedUrl.setPort(-1); + } + if (normalizedUrl.path() == "/"_L1) { + normalizedUrl.setPath({}); + } + + return normalizedUrl; +} + } AccountWizardController::AccountWizardController(QObject *parent) @@ -205,6 +226,27 @@ void AccountWizardController::setOverrideServerIndex(int index) emit overrideServerSelectionChanged(); } +bool AccountWizardController::setServerUrlForLoginFlow(const QUrl &serverUrl) +{ + if (!overrideServerSelectionRequired()) { + setServerUrl(serverUrl.toString()); + return true; + } + + const auto normalizedServerUrl = normalizedServerUrlForComparison(serverUrl); + for (auto index = 0; index < _overrideServerUrls.size(); ++index) { + if (normalizedServerUrlForComparison(QUrl(_overrideServerUrls.at(index))) != normalizedServerUrl) { + continue; + } + + setOverrideServerIndex(index); + setServerUrl(_overrideServerUrls.at(index)); + return true; + } + + return false; +} + bool AccountWizardController::busy() const { return _busy; diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index b108cf8e66476..b9c3ca2be3906 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -121,6 +121,7 @@ class AccountWizardController : public QObject [[nodiscard]] QStringList overrideServerNames() const; [[nodiscard]] int overrideServerIndex() const; void setOverrideServerIndex(int index); + [[nodiscard]] bool setServerUrlForLoginFlow(const QUrl &serverUrl); [[nodiscard]] bool busy() const; [[nodiscard]] bool authPolling() const; [[nodiscard]] QString errorText() const; From 80391722b95ffcea373a4178ef2c7e2a6e7744ee Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 6 Jul 2026 15:09:19 +0200 Subject: [PATCH 7/7] Update src/gui/wizard/accountwizardcontroller.cpp Co-authored-by: Matthieu Gallien Signed-off-by: Rello --- src/gui/wizard/accountwizardcontroller.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index d99f6d6437046..1ba290c21008e 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -945,7 +945,7 @@ void AccountWizardController::copyLoginLink() void AccountWizardController::openSignup() { - Utility::openBrowser(QUrl(QStringLiteral("https://nextcloud.com/sign-up/?flow=V3"))); + Utility::openBrowser(QUrl(u"https://nextcloud.com/sign-up/?flow=V3"_s)); } void AccountWizardController::openSelfHostedServerGuide()