diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 955301163..123aa8d8a 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -286,7 +286,7 @@ bool SubscriptionUiController::importFreeFromGateway() m_captchaState.isPending = true; emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, - captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + captchaInfo.hint.isEmpty() ? tr("Enter the digits from the image to continue") : captchaInfo.hint); return false; } else { emit errorOccurred(errorCode); @@ -351,7 +351,7 @@ void SubscriptionUiController::onRefreshCaptchaRequested() if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, - captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + 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); diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml index e8c07cdb8..35cf981e2 100644 --- a/client/ui/qml/Controls2/CaptchaDialogType.qml +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -1,9 +1,12 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Window +import Qt5Compat.GraphicalEffects import Style 1.0 +import "." import "TextTypes" import "../Config" @@ -12,7 +15,7 @@ Popup { property string captchaId property string captchaImageBase64 - property string hint: "Please solve the CAPTCHA to continue" + property string hint: qsTr("Enter the digits from the image to continue") signal captchaSolved(string captchaId, string solution) signal refreshCaptchaRequested() @@ -33,8 +36,8 @@ Popup { onOpened: { timer.start() - solutionInput.text = "" - solutionInput.focus = true + solutionField.textField.text = "" + solutionField.textField.focus = true } onClosed: { @@ -43,8 +46,8 @@ Popup { background: Rectangle { anchors.fill: parent - color: "white" - radius: 4 + color: AmneziaStyle.color.slateGray + radius: 22 } Timer { @@ -52,7 +55,7 @@ Popup { interval: 200 onTriggered: { FocusController.pushRootObject(root) - FocusController.setFocusItem(solutionInput) + FocusController.setFocusItem(solutionField.textField) } repeat: false running: true @@ -68,140 +71,181 @@ Popup { id: contentLayout anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - anchors.topMargin: 16 - anchors.bottomMargin: 16 + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + anchors.bottomMargin: 20 - spacing: 12 + spacing: 16 + + Text { + id: titleText - CaptionTextType { - text: qsTr("CAPTCHA Verification") Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } + Layout.alignment: Qt.AlignLeft | Qt.AlignTop - ParagraphTextType { - text: hint - Layout.fillWidth: true + 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 } - Rectangle { + Item { Layout.fillWidth: true Layout.preferredHeight: 200 - color: AmneziaStyle.color.lightGray - radius: 4 - Image { - id: captchaImage - anchors.centerIn: parent - cache: false + Rectangle { + id: imagePanel - Component.onCompleted: { - if (captchaImageBase64 !== "") { - source = "data:image/png;base64," + captchaImageBase64 + 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 + } } } - 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() } } } + } - BusyIndicator { - anchors.centerIn: parent - running: captchaImage.status === Image.Loading + 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() } } - RowLayout { + Item { Layout.fillWidth: true - spacing: 8 + Layout.preferredHeight: 8 + } - ParagraphTextType { - text: qsTr("Can't read the image?") - Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } + BasicButtonType { + id: continueButton - BasicButtonType { - text: qsTr("Refresh") - implicitHeight: 32 + Layout.fillWidth: true + implicitHeight: 52 - onClicked: { - root.refreshCaptchaRequested() - } + text: qsTr("Continue") + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + submitIfNonEmpty() } } - ParagraphTextType { - text: qsTr("Enter the numbers from the image:") - Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } - - TextField { - id: solutionInput + BasicButtonType { + id: closeButton Layout.fillWidth: true - implicitHeight: 40 + implicitHeight: 52 - placeholderText: qsTr("Enter CAPTCHA solution") + 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 - background: Rectangle { - border.color: AmneziaStyle.color.charcoalGray - border.width: 1 - radius: 4 - color: "white" - } - - onAccepted: { - if (solutionInput.text.trim() !== "") { - root.captchaSolved(root.captchaId, solutionInput.text.trim()) - } - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - BasicButtonType { - id: submitButton - - Layout.fillWidth: true - implicitHeight: 40 - - text: qsTr("Submit") - - onClicked: { - if (solutionInput.text.trim() !== "") { - root.captchaSolved(root.captchaId, solutionInput.text.trim()) - } - } - } - - BasicButtonType { - id: cancelButton - - Layout.fillWidth: true - implicitHeight: 40 - - text: qsTr("Cancel") - defaultColor: AmneziaStyle.color.lightGray - hoveredColor: AmneziaStyle.color.charcoalGray - textColor: AmneziaStyle.color.midnightBlack - - onClicked: { - root.close() - } + clickedFunc: function() { + root.close() } } } } + + function submitIfNonEmpty() { + const t = solutionField.textField.text.trim() + if (t !== "") { + root.captchaSolved(root.captchaId, t) + } + } } diff --git a/tools/local_gateway/README.md b/tools/local_gateway/README.md new file mode 100644 index 000000000..1ca566012 --- /dev/null +++ b/tools/local_gateway/README.md @@ -0,0 +1,55 @@ +# Local gateway (plaintext mock) + +Минимальный HTTP-сервер на Go, который имитирует ответы Amnezia API gateway **без шифрования**: те же JSON-тела, что клиент отправляет в зашифрованном виде на прод. Удобно для отладки UI (в том числе CAPTCHA) и сценария **Amnezia Free**. + +## Требования + +- [Go](https://go.dev/dl/) **1.21** или новее (см. `go.mod`). + +## Запуск + +Из каталога `tools/local_gateway`: + +```bash +cd tools/local_gateway +go mod download +go run . +``` + +Сервер слушает **`http://127.0.0.1:8080`** (в коде задано явно). + +В логах должно появиться сообщение вида: + +`plaintext mock listening on :8080 POST /v1/services POST /v1/config` + +## Эндпоинты + +| Метод | Путь | Назначение | +|--------|------|------------| +| `POST` | `/v1/services` | Минимальный ответ со списком сервисов (в т.ч. `amnezia-free` / `awg`). | +| `POST` | `/v1/config` | Импорт конфига: лимит/CAPTCHA (`dchest/captcha`), проверка решения, мок-ответы. | + +Других маршрутов нет. + +## Связка с клиентом AmneziaVPN + +1. Соберите клиент с флагом CMake **`AMNEZIA_LOCAL_GATEWAY=ON`** — тогда для `localhost` запросы к gateway уходят **plaintext JSON** без RSA/AES (см. `GatewayController`, `SecureAppSettingsRepository`). +2. В настройках приложения endpoint gateway должен указывать на **`http://localhost:8080/`** (или `http://127.0.0.1:8080/`). При включённом `AMNEZIA_LOCAL_GATEWAY` дефолтный URL в коде уже `http://localhost:8080/`. + +После этого сценарии вроде **Amnezia Free → Continue** будут ходить в этот mock. + +## Поведение CAPTCHA (для разработчика) + +В `main.go` константа **`rateLimitExcessAfter`**: при `0` «лимит» срабатывает сразу и первый запрос к `/v1/config` для `amnezia-free` чаще возвращает ответ с CAPTCHA; большее значение имитирует N успешных запросов до CAPTCHA. + +Опционально в теле `POST /v1/config` mock обрабатывает **`refresh_captcha": true`** (отдельная ветка в коде); кнопка «Обновить» в клиенте может повторно вызывать обычный импорт без этого поля — смотрите актуальную логику в `SubscriptionUiController`. + +## Зависимости + +- `github.com/dchest/captcha` — генерация и проверка картинки CAPTCHA. + +После изменения зависимостей: + +```bash +go mod tidy +``` diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go index 661e9a909..35905aa19 100644 --- a/tools/local_gateway/main.go +++ b/tools/local_gateway/main.go @@ -174,7 +174,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { "error": "rate_limit_exceeded", "captcha_id": id, "captcha_image": b64, - "hint": "Please solve the CAPTCHA to continue", + "hint": "Enter the digits from the image to continue", }) return }