feat: add captcha (#2508)

* 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 <vk@amnezia.org>
This commit is contained in:
yp
2026-05-28 08:51:26 +03:00
committed by GitHub
parent 52de1acebf
commit bcee58b08b
13 changed files with 574 additions and 16 deletions
+1 -1
View File
@@ -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
@@ -5,6 +5,7 @@
#include <QEventLoop>
#include <QFutureWatcher>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPromise>
#include <QSet>
#include <QSysInfo>
@@ -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;
}
@@ -956,3 +971,74 @@ QFuture<QPair<ErrorCode, QString>> 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<char16_t>(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;
}
@@ -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;
+12 -5
View File
@@ -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<QPair<ErrorCode, QByteArray>> 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,9 +462,13 @@ 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;
+28 -3
View File
@@ -84,15 +84,14 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &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<QSslError> &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<QSslError> &ssl
if (httpStatusFromBody == httpStatusCodeNotFound) {
return amnezia::ErrorCode::ApiNotFoundError;
}
if (httpStatusFromBody == httpStatusCodeRequestTimeout) {
return amnezia::ErrorCode::ApiConfigTimeoutError;
}
if (httpStatusFromBody == httpStatusCodeNotImplemented) {
return amnezia::ErrorCode::ApiUpdateRequestError;
}
@@ -126,10 +132,29 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &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;
}
if (httpStatusFromBody >= 300) {
return amnezia::ErrorCode::ApiConfigDownloadError;
}
}
if (replyError == QNetworkReply::NoError) {
return amnezia::ErrorCode::NoError;
}
qDebug() << "something went wrong";
return amnezia::ErrorCode::ApiConfigDownloadError;
+4
View File
@@ -101,6 +101,10 @@ namespace amnezia
ApiSubscriptionNotActiveError = 1114,
ApiNoPurchasedSubscriptionsError = 1115,
ApiTrialAlreadyUsedError = 1116,
ApiCaptchaRequiredError = 1117,
ApiCaptchaInvalidError = 1118,
ApiCaptchaRefreshError = 1119,
ApiRateLimitError = 1120,
// QFile errors
OpenError = 1200,
+4
View File
@@ -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;
@@ -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());
@@ -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<QString> getQrCodes();
int getQrCodesCount();
@@ -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)
}
}
}
@@ -130,6 +130,9 @@ PageType {
PageController.showBusyIndicator(false)
if (!result) {
if (SubscriptionUiController.isCaptchaAwaitingUser()) {
return
}
var endpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(endpoint)
PageController.closePage()
+42
View File
@@ -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 {
+1
View File
@@ -24,6 +24,7 @@
<file>Controls2/BackButtonType.qml</file>
<file>Controls2/BasicButtonType.qml</file>
<file>Controls2/BusyIndicatorType.qml</file>
<file>Controls2/CaptchaDialogType.qml</file>
<file>Controls2/CardType.qml</file>
<file>Controls2/CardWithIconsType.qml</file>
<file>Controls2/CheckBoxType.qml</file>