From bcee58b08bb1eab39d6cbedbb5b189fe8b5011ec Mon Sep 17 00:00:00 2001 From: yp Date: Thu, 28 May 2026 08:51:26 +0300 Subject: [PATCH] feat: add captcha (#2508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test capcha * add test AMNEZIA_GATEWAY_PLAINTEXT_MOCK * ref * remove first QNetworkReply::NoError * fixed macros * fixed http code * add test server * fix cmake * add CAPTCHA refreshed * fixed captcha * update QML Captha * fixed crash app & up vercion & fix qml captha * ver 4.9.0.1 * remove m_gatewayCaptchaStickyBase & outEffectiveRequestBase * reset code PR * remove mock & temp var AMNEZIA_LOCAL_GATEWAY * ref code & remove AMNEZIA_LOCAL_GATEWAY * remove check httpStatusCode & error * add 408 status code * fix update captca * remove fallback на transport * chore: add loader after captcha solved * chore: remove logs from api utils * chore: minor fixes --------- Co-authored-by: vkamn --- CMakeLists.txt | 2 +- .../api/subscriptionController.cpp | 92 +++++- .../controllers/api/subscriptionController.h | 15 +- client/core/controllers/gatewayController.cpp | 19 +- client/core/utils/api/apiUtils.cpp | 33 ++- client/core/utils/errorCodes.h | 4 + client/core/utils/errorStrings.cpp | 4 + .../api/subscriptionUiController.cpp | 94 ++++++- .../api/subscriptionUiController.h | 18 ++ client/ui/qml/Controls2/CaptchaDialogType.qml | 263 ++++++++++++++++++ .../qml/Pages2/PageSetupWizardApiFreeInfo.qml | 3 + client/ui/qml/main2.qml | 42 +++ client/ui/qml/qml.qrc | 1 + 13 files changed, 574 insertions(+), 16 deletions(-) create mode 100644 client/ui/qml/Controls2/CaptchaDialogType.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index c01a6e229..34d073a66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.9.0.0) +set(AMNEZIAVPN_VERSION 4.9.0.1) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index 39b30add2..d5db87f5b 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -216,7 +217,8 @@ ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const } ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCountryCode, const QString &serviceType, - const QString &serviceProtocol, const ProtocolData &protocolData) + const QString &serviceProtocol, const ProtocolData &protocolData, + CaptchaInfo &captchaInfo) { GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -233,6 +235,19 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo QByteArray responseBody; ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + + if (errorCode == ErrorCode::ApiCaptchaRequiredError) { + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + captchaInfo.captchaId = jsonObj.value("captcha_id").toString(); + captchaInfo.captchaImageBase64 = jsonObj.value("captcha_image").toString(); + captchaInfo.hint = jsonObj.value("hint").toString(); + captchaInfo.isRequired = true; + } + return errorCode; + } + if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -242,9 +257,9 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo if (errorCode != ErrorCode::NoError) { return errorCode; } - + updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody); - + if (serverConfigJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) { return ErrorCode::InternalError; } @@ -956,3 +971,74 @@ QFuture> SubscriptionController::getRenewalLink(const return promise->future(); } +ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &userCountryCode, + const QString &serviceType, + const QString &serviceProtocol, + const ProtocolData &protocolData, + const QString &captchaId, + const QString &captchaSolution, + CaptchaInfo *retryCaptchaOut) +{ + GatewayRequestData gatewayRequestData{QSysInfo::productType(), + QString(APP_VERSION), + m_appSettingsRepository->getAppLanguage().name().split("_").first(), + m_appSettingsRepository->getInstallationUuid(true), + userCountryCode, + "", + serviceType, + serviceProtocol, + QJsonObject()}; + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload); + + apiPayload["captcha_id"] = captchaId; + QString normalizedSolution; + normalizedSolution.reserve(captchaSolution.size()); + for (const QChar &ch : captchaSolution) { + const ushort u = ch.unicode(); + if (u >= '0' && u <= '9') { + normalizedSolution += ch; + } else if (u >= 0xFF10 && u <= 0xFF19) { + normalizedSolution += QChar(static_cast(u - 0xFF10 + '0')); + } + } + apiPayload["captcha_solution"] = normalizedSolution.isEmpty() ? captchaSolution.trimmed() : normalizedSolution; + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + if (retryCaptchaOut + && (errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError + || errorCode == ErrorCode::ApiCaptchaRequiredError)) { + const QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + const QJsonObject jsonObj = jsonDoc.object(); + if (jsonObj.contains(QStringLiteral("captcha_id")) && jsonObj.contains(QStringLiteral("captcha_image"))) { + retryCaptchaOut->captchaId = jsonObj.value(QStringLiteral("captcha_id")).toString(); + retryCaptchaOut->captchaImageBase64 = jsonObj.value(QStringLiteral("captcha_image")).toString(); + retryCaptchaOut->hint = jsonObj.value(QStringLiteral("hint")).toString(); + retryCaptchaOut->isRequired = true; + } + } + } + return errorCode; + } + + QJsonObject serverConfigJson; + errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson); + if (errorCode != ErrorCode::NoError) { + return errorCode; + } + + updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody); + + if (serverConfigJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) { + return ErrorCode::InternalError; + } + + ApiV2ServerConfig apiV2ServerConfig = ApiV2ServerConfig::fromJson(serverConfigJson); + m_serversRepository->addServer(QString(), apiV2ServerConfig.toJson(), + serverConfigUtils::configTypeFromJson(apiV2ServerConfig.toJson())); + return ErrorCode::NoError; +} diff --git a/client/core/controllers/api/subscriptionController.h b/client/core/controllers/api/subscriptionController.h index a0ac5d24b..5f5429abc 100644 --- a/client/core/controllers/api/subscriptionController.h +++ b/client/core/controllers/api/subscriptionController.h @@ -42,6 +42,13 @@ public: QJsonObject toJsonObject() const; }; + struct CaptchaInfo { + QString captchaId; + QString captchaImageBase64; + QString hint; + bool isRequired = false; + }; + explicit SubscriptionController(SecureServersRepository* serversRepository, SecureAppSettingsRepository* appSettingsRepository); @@ -49,7 +56,8 @@ public: void appendProtocolDataToApiPayload(const QString &protocol, const ProtocolData &protocolData, QJsonObject &apiPayload); ErrorCode importServiceFromGateway(const QString &userCountryCode, const QString &serviceType, - const QString &serviceProtocol, const ProtocolData &protocolData); + const QString &serviceProtocol, const ProtocolData &protocolData, + CaptchaInfo &captchaInfo); ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const QString &email); @@ -98,6 +106,11 @@ public: AppStoreRestoreResult processAppStoreRestore(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol); + ErrorCode resolveImportServiceCaptcha(const QString &userCountryCode, const QString &serviceType, + const QString &serviceProtocol, const ProtocolData &protocolData, + const QString &captchaId, const QString &captchaSolution, + CaptchaInfo *retryCaptchaOut = nullptr); + private: ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false); bool isApiKeyExpired(const QString &serverId) const; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 9b22ad6ad..d400de3a2 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -30,6 +30,8 @@ namespace constexpr QLatin1String errorResponsePattern1("No active configuration found for"); constexpr QLatin1String errorResponsePattern2("No non-revoked public key found for"); constexpr QLatin1String errorResponsePattern3("Account not found."); + constexpr QLatin1String errorResponsePatternQrSessionNotFound("QR session not found"); + constexpr QLatin1String errorResponsePatternSessionNotFound("Session not found"); constexpr QLatin1String updateRequestResponsePattern("client version update is required"); @@ -37,6 +39,7 @@ namespace constexpr int httpStatusCodeConflict = 409; constexpr int httpStatusCodeNotImplemented = 501; constexpr int httpStatusCodePaymentRequired = 402; + constexpr int httpStatusCodeRequestTimeout = 408; constexpr int httpStatusCodeUnprocessableEntity = 422; constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); @@ -206,8 +209,9 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api bypassProxy(endpoint, serviceType, userCountryCode, requestFunction, replyProcessingFunction); } - auto errorCode = - apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); + responseBody = decryptionResult.decryptedBody; + const auto errorCode = + apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, responseBody); if (errorCode) { return errorCode; } @@ -217,7 +221,6 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api return ErrorCode::ApiConfigDecryptionError; } - responseBody = decryptionResult.decryptedBody; return ErrorCode::NoError; } @@ -256,7 +259,7 @@ QFuture> GatewayController::postAsync(const QString auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); if (errorCode) { - promise->addResult(qMakePair(errorCode, QByteArray())); + promise->addResult(qMakePair(errorCode, decryptionResult.decryptedBody)); promise->finish(); return; } @@ -459,15 +462,19 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << "the response contains an html tag"; return true; } + if (apiHttpStatus == httpStatusCodeRequestTimeout) { + return false; + } if (apiHttpStatus == httpStatusCodeNotFound) { if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2) - || responseBody.contains(errorResponsePattern3)) { + || responseBody.contains(errorResponsePattern3) || responseBody.contains(errorResponsePatternQrSessionNotFound) + || responseBody.contains(errorResponsePatternSessionNotFound)) { return false; } else { qDebug() << replyError; return true; } - } + } if (apiHttpStatus == httpStatusCodeNotImplemented) { if (responseBody.contains(updateRequestResponsePattern)) { return false; diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index 6856652d5..9ce472d40 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -84,15 +84,14 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; const int httpStatusCodePaymentRequired = 402; + const int httpStatusCodeTooManyRequests = 429; + const int httpStatusCodeRequestTimeout = 408; const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; } - if (replyError == QNetworkReply::NoError) { - return amnezia::ErrorCode::NoError; - } if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; @@ -107,6 +106,10 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1); + + if (httpStatusFromBody == httpStatusCodeTooManyRequests) { + return amnezia::ErrorCode::ApiRateLimitError; + } if (httpStatusFromBody == httpStatusCodeConflict) { if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) { return amnezia::ErrorCode::ApiTrialAlreadyUsedError; @@ -116,6 +119,9 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (httpStatusFromBody == httpStatusCodeNotFound) { return amnezia::ErrorCode::ApiNotFoundError; } + if (httpStatusFromBody == httpStatusCodeRequestTimeout) { + return amnezia::ErrorCode::ApiConfigTimeoutError; + } if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; } @@ -126,9 +132,28 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl return amnezia::ErrorCode::ApiConfigDownloadError; } if (httpStatusFromBody == httpStatusCodePaymentRequired) { + const QString message = apiErrorMessageFromJson(jsonObj); + if (message.contains(QLatin1String("refresh_captcha"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaRefreshError; + } + if (message.contains(QLatin1String("invalid_captcha"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaInvalidError; + } + if (jsonObj.contains(QStringLiteral("captcha_id")) || jsonObj.contains(QStringLiteral("captcha_image")) + || message.compare(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive) == 0 + || message.contains(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaRequiredError; + } return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } - return amnezia::ErrorCode::ApiConfigDownloadError; + + if (httpStatusFromBody >= 300) { + return amnezia::ErrorCode::ApiConfigDownloadError; + } + } + + if (replyError == QNetworkReply::NoError) { + return amnezia::ErrorCode::NoError; } qDebug() << "something went wrong"; diff --git a/client/core/utils/errorCodes.h b/client/core/utils/errorCodes.h index e4e83ef57..d5f99fb4f 100644 --- a/client/core/utils/errorCodes.h +++ b/client/core/utils/errorCodes.h @@ -101,6 +101,10 @@ namespace amnezia ApiSubscriptionNotActiveError = 1114, ApiNoPurchasedSubscriptionsError = 1115, ApiTrialAlreadyUsedError = 1116, + ApiCaptchaRequiredError = 1117, + ApiCaptchaInvalidError = 1118, + ApiCaptchaRefreshError = 1119, + ApiRateLimitError = 1120, // QFile errors OpenError = 1200, diff --git a/client/core/utils/errorStrings.cpp b/client/core/utils/errorStrings.cpp index 269a73133..5a0e282ba 100644 --- a/client/core/utils/errorStrings.cpp +++ b/client/core/utils/errorStrings.cpp @@ -93,6 +93,10 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break; + case (ErrorCode::ApiCaptchaRequiredError): errorMessage = QObject::tr("CAPTCHA verification is required"); break; + case (ErrorCode::ApiCaptchaInvalidError): errorMessage = QObject::tr("CAPTCHA was incorrect. Please try again"); break; + case (ErrorCode::ApiCaptchaRefreshError): errorMessage = QObject::tr("CAPTCHA refreshed. Please try again"); break; + case (ErrorCode::ApiRateLimitError): errorMessage = QObject::tr("Too many requests. Please try again later"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index ab661d19c..cf02e19c8 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -102,6 +102,11 @@ SubscriptionUiController::SubscriptionUiController(ServersController* serversCon }); } +bool SubscriptionUiController::isCaptchaAwaitingUser() const +{ + return m_captchaState.isPending; +} + bool SubscriptionUiController::exportVpnKey(const QString &serverId, const QString &fileName) { if (fileName.isEmpty()) { @@ -288,18 +293,105 @@ bool SubscriptionUiController::importFreeFromGateway() } SubscriptionController::ProtocolData protocolData = m_subscriptionController->generateProtocolData(serviceProtocol); + SubscriptionController::CaptchaInfo captchaInfo; + ErrorCode errorCode = m_subscriptionController->importServiceFromGateway(userCountryCode, serviceType, - serviceProtocol, protocolData); + serviceProtocol, protocolData, + captchaInfo); if (errorCode == ErrorCode::NoError) { emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); return true; + } else if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { + m_captchaState.userCountryCode = userCountryCode; + m_captchaState.serviceType = serviceType; + m_captchaState.serviceProtocol = serviceProtocol; + m_captchaState.openvpnPrivKey = protocolData.certPrivKey; + m_captchaState.wireguardClientPrivKey = protocolData.wireGuardClientPrivKey; + m_captchaState.wireguardClientPubKey = protocolData.wireGuardClientPubKey; + m_captchaState.xrayUuid = protocolData.xrayUuid; + m_captchaState.isPending = true; + + emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, + captchaInfo.hint.isEmpty() ? tr("Enter the digits from the image to continue") : captchaInfo.hint); + return false; } else { emit errorOccurred(errorCode); return false; } } +void SubscriptionUiController::onCaptchaSolved(const QString &captchaId, const QString &solution) +{ + if (!m_captchaState.isPending) { + return; + } + + SubscriptionController::ProtocolData protocolData; + protocolData.certPrivKey = m_captchaState.openvpnPrivKey; + protocolData.wireGuardClientPrivKey = m_captchaState.wireguardClientPrivKey; + protocolData.wireGuardClientPubKey = m_captchaState.wireguardClientPubKey; + protocolData.xrayUuid = m_captchaState.xrayUuid; + + SubscriptionController::CaptchaInfo retryCaptcha; + ErrorCode errorCode = m_subscriptionController->resolveImportServiceCaptcha( + m_captchaState.userCountryCode, + m_captchaState.serviceType, + m_captchaState.serviceProtocol, + protocolData, + captchaId, + solution, + &retryCaptcha); + + if (errorCode == ErrorCode::NoError) { + m_captchaState.isPending = false; + emit captchaFlowDismissRequested(); + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); + return; + } + + if ((errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError + || errorCode == ErrorCode::ApiCaptchaRequiredError) + && retryCaptcha.isRequired) { + emit captchaRequired(retryCaptcha.captchaId, retryCaptcha.captchaImageBase64, + retryCaptcha.hint.isEmpty() ? tr("Enter the digits from the image to continue") : retryCaptcha.hint); + return; + } + + m_captchaState.isPending = false; + emit errorOccurred(errorCode); +} + +void SubscriptionUiController::onRefreshCaptchaRequested() +{ + if (!m_captchaState.isPending) { + return; + } + + SubscriptionController::ProtocolData protocolData; + protocolData.certPrivKey = m_captchaState.openvpnPrivKey; + protocolData.wireGuardClientPrivKey = m_captchaState.wireguardClientPrivKey; + protocolData.wireGuardClientPubKey = m_captchaState.wireguardClientPubKey; + protocolData.xrayUuid = m_captchaState.xrayUuid; + + SubscriptionController::CaptchaInfo captchaInfo; + + ErrorCode errorCode = m_subscriptionController->importServiceFromGateway( + m_captchaState.userCountryCode, + m_captchaState.serviceType, + m_captchaState.serviceProtocol, + protocolData, + captchaInfo); + + if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { + emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, + captchaInfo.hint.isEmpty() ? tr("Enter the digits from the image to continue") : captchaInfo.hint); + } else if (errorCode != ErrorCode::NoError) { + m_captchaState.isPending = false; + emit errorOccurred(errorCode); + } +} + bool SubscriptionUiController::importTrialFromGateway(const QString &email) { emit trialEmailError(QString()); diff --git a/client/ui/controllers/api/subscriptionUiController.h b/client/ui/controllers/api/subscriptionUiController.h index a127bb74e..72716e810 100644 --- a/client/ui/controllers/api/subscriptionUiController.h +++ b/client/ui/controllers/api/subscriptionUiController.h @@ -58,6 +58,10 @@ public slots: void setCurrentProtocol(const QString &serverId, const QString &protocolName); bool isVlessProtocol(const QString &serverId); + bool isCaptchaAwaitingUser() const; + void onCaptchaSolved(const QString &captchaId, const QString &solution); + void onRefreshCaptchaRequested(); + void removeApiConfig(const QString &serverId); void removeServer(const QString &serverId); @@ -85,9 +89,23 @@ signals: void apiServerRemoved(const QString &message); void vpnKeyExportReady(); + void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint); + void captchaFlowDismissRequested(); void unsupportedConnectDrawerRequested(); +private: + struct CaptchaState { + QString userCountryCode; + QString serviceType; + QString serviceProtocol; + QString openvpnPrivKey; + QString wireguardClientPrivKey; + QString wireguardClientPubKey; + QString xrayUuid; + bool isPending = false; + } m_captchaState; + private: QList getQrCodes(); int getQrCodesCount(); diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml new file mode 100644 index 000000000..d95389864 --- /dev/null +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Qt5Compat.GraphicalEffects + +import Style 1.0 + +import "." +import "TextTypes" +import "../Config" + +Popup { + id: root + + property string captchaId + property string captchaImageBase64 + property string hint: qsTr("Enter the digits from the image to continue") + + signal captchaSolved(string captchaId, string solution) + signal refreshCaptchaRequested() + + leftMargin: 25 + rightMargin: 25 + bottomMargin: 70 + SettingsController.safeAreaBottomMargin + + width: parent.width - leftMargin - rightMargin + + anchors.centerIn: parent + modal: true + closePolicy: Popup.NoAutoClose + + Overlay.modal: Rectangle { + color: AmneziaStyle.color.translucentMidnightBlack + } + + onOpened: { + timer.start() + solutionField.textField.text = "" + solutionField.textField.focus = true + } + + onCaptchaIdChanged: { + if (opened) { + solutionField.textField.text = "" + } + } + + onCaptchaImageBase64Changed: { + if (opened) { + solutionField.textField.text = "" + } + } + + onClosed: { + FocusController.dropRootObject(root) + } + + background: Rectangle { + anchors.fill: parent + color: AmneziaStyle.color.slateGray + radius: 22 + } + + Timer { + id: timer + interval: 200 + onTriggered: { + FocusController.pushRootObject(root) + FocusController.setFocusItem(solutionField.textField) + } + repeat: false + running: true + } + + contentItem: Item { + implicitWidth: contentLayout.implicitWidth + implicitHeight: contentLayout.implicitHeight + + anchors.fill: parent + + ColumnLayout { + id: contentLayout + + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + anchors.bottomMargin: 20 + + spacing: 16 + + Text { + id: titleText + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + + text: root.hint + wrapMode: Text.WordWrap + color: AmneziaStyle.color.paleGray + font.pixelSize: 18 + font.weight: Font.Bold + font.family: "PT Root UI VF" + lineHeight: 24 + LanguageUiController.getLineHeightAppend() + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignTop + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 200 + + Rectangle { + id: imagePanel + + anchors.fill: parent + color: AmneziaStyle.color.pearlGray + radius: 16 + + Image { + id: captchaImage + + anchors.centerIn: parent + fillMode: Image.PreserveAspectFit + cache: false + + Component.onCompleted: { + if (captchaImageBase64 !== "") { + source = "data:image/png;base64," + captchaImageBase64 + } + } + + Connections { + target: root + function onCaptchaImageBase64Changed() { + captchaImage.source = "data:image/png;base64," + root.captchaImageBase64 + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: captchaImage.status === Image.Loading + } + + Rectangle { + id: refreshHit + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + width: 44 + height: 44 + radius: width / 2 + color: AmneziaStyle.color.charcoalGray + + Image { + id: refreshIcon + + anchors.centerIn: parent + width: 26 + height: 26 + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + antialiasing: true + source: "qrc:/images/controls/refresh-cw.svg" + // Rasterize SVG at high resolution, then scale down — avoids blocky edges on HiDPI. + readonly property real _dpr: (Window.window && Window.window.screen) + ? Window.window.screen.devicePixelRatio : 2.0 + readonly property int _raster: Math.ceil(64 * Math.min(Math.max(_dpr, 1.0), 4.0)) + sourceSize: Qt.size(_raster, _raster) + + layer.enabled: true + layer.smooth: true + layer.textureSize: Qt.size(_raster, _raster) + layer.effect: ColorOverlay { + color: AmneziaStyle.color.goldenApricot + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.refreshCaptchaRequested() + } + } + } + } + + TextFieldWithHeaderType { + id: solutionField + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + headerText: qsTr("Digits from the image") + headerTextColor: AmneziaStyle.color.mutedGray + + textField.placeholderText: qsTr("_ _ _ _ _ _") + textField.placeholderTextColor: AmneziaStyle.color.mutedGray + textField.inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhNoPredictiveText + textField.maximumLength: 6 + textField.font.letterSpacing: 2 + + textField.onAccepted: { + submitIfNonEmpty() + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 8 + } + + BasicButtonType { + id: continueButton + + Layout.fillWidth: true + implicitHeight: 52 + + text: qsTr("Continue") + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + submitIfNonEmpty() + } + } + + BasicButtonType { + id: closeButton + + Layout.fillWidth: true + implicitHeight: 52 + + text: qsTr("Close") + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.paleGray + borderWidth: 1 + borderColor: AmneziaStyle.color.mutedGray + borderFocusedColor: AmneziaStyle.color.paleGray + + clickedFunc: function() { + root.close() + } + } + } + } + + function submitIfNonEmpty() { + const t = solutionField.textField.text.trim() + if (t !== "") { + root.captchaSolved(root.captchaId, t) + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml index 90135962e..af974915d 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -130,6 +130,9 @@ PageType { PageController.showBusyIndicator(false) if (!result) { + if (SubscriptionUiController.isCaptchaAwaitingUser()) { + return + } var endpoint = ApiServicesModel.getStoreEndpoint() Qt.openUrlExternally(endpoint) PageController.closePage() diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 18c59b264..6db6df41b 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -205,6 +205,27 @@ Window { } } + Item { + objectName: "captchaDialogItem" + + anchors.fill: parent + + CaptchaDialogType { + id: captchaDialog + + onCaptchaSolved: function(captchaId, solution) { + PageController.showBusyIndicator(true) + Qt.callLater(function() { + SubscriptionUiController.onCaptchaSolved(captchaId, solution) + }) + } + + onRefreshCaptchaRequested: function() { + SubscriptionUiController.onRefreshCaptchaRequested() + } + } + } + Item { objectName: "privateKeyPassphraseDrawerItem" @@ -318,6 +339,27 @@ Window { function onSubscriptionExpiredOnServer() { subscriptionExpiredDrawer.openTriggered() } + + function onCaptchaRequired(captchaId, captchaImageBase64, hint) { + if (captchaDialog.opened) { + PageController.showBusyIndicator(false) + } + captchaDialog.captchaId = captchaId + captchaDialog.captchaImageBase64 = captchaImageBase64 + captchaDialog.hint = hint + captchaDialog.open() + } + + function onCaptchaFlowDismissRequested() { + PageController.showBusyIndicator(false) + captchaDialog.close() + } + + function onErrorOccurred(error) { + if (captchaDialog.opened) { + PageController.showBusyIndicator(false) + } + } } Connections { diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index 5785b4c78..b7e65a681 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -24,6 +24,7 @@ Controls2/BackButtonType.qml Controls2/BasicButtonType.qml Controls2/BusyIndicatorType.qml + Controls2/CaptchaDialogType.qml Controls2/CardType.qml Controls2/CardWithIconsType.qml Controls2/CheckBoxType.qml