mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user