mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
update QML Captha
This commit is contained in:
@@ -286,7 +286,7 @@ bool SubscriptionUiController::importFreeFromGateway()
|
|||||||
m_captchaState.isPending = true;
|
m_captchaState.isPending = true;
|
||||||
|
|
||||||
emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64,
|
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;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
emit errorOccurred(errorCode);
|
emit errorOccurred(errorCode);
|
||||||
@@ -351,7 +351,7 @@ void SubscriptionUiController::onRefreshCaptchaRequested()
|
|||||||
|
|
||||||
if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) {
|
if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) {
|
||||||
emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64,
|
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) {
|
} else if (errorCode != ErrorCode::NoError) {
|
||||||
m_captchaState.isPending = false;
|
m_captchaState.isPending = false;
|
||||||
emit errorOccurred(errorCode);
|
emit errorOccurred(errorCode);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Window
|
||||||
|
import Qt5Compat.GraphicalEffects
|
||||||
|
|
||||||
import Style 1.0
|
import Style 1.0
|
||||||
|
|
||||||
|
import "."
|
||||||
import "TextTypes"
|
import "TextTypes"
|
||||||
import "../Config"
|
import "../Config"
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ Popup {
|
|||||||
|
|
||||||
property string captchaId
|
property string captchaId
|
||||||
property string captchaImageBase64
|
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 captchaSolved(string captchaId, string solution)
|
||||||
signal refreshCaptchaRequested()
|
signal refreshCaptchaRequested()
|
||||||
@@ -33,8 +36,8 @@ Popup {
|
|||||||
|
|
||||||
onOpened: {
|
onOpened: {
|
||||||
timer.start()
|
timer.start()
|
||||||
solutionInput.text = ""
|
solutionField.textField.text = ""
|
||||||
solutionInput.focus = true
|
solutionField.textField.focus = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onClosed: {
|
onClosed: {
|
||||||
@@ -43,8 +46,8 @@ Popup {
|
|||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "white"
|
color: AmneziaStyle.color.slateGray
|
||||||
radius: 4
|
radius: 22
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -52,7 +55,7 @@ Popup {
|
|||||||
interval: 200
|
interval: 200
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
FocusController.pushRootObject(root)
|
FocusController.pushRootObject(root)
|
||||||
FocusController.setFocusItem(solutionInput)
|
FocusController.setFocusItem(solutionField.textField)
|
||||||
}
|
}
|
||||||
repeat: false
|
repeat: false
|
||||||
running: true
|
running: true
|
||||||
@@ -68,140 +71,181 @@ Popup {
|
|||||||
id: contentLayout
|
id: contentLayout
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 16
|
anchors.leftMargin: 20
|
||||||
anchors.rightMargin: 16
|
anchors.rightMargin: 20
|
||||||
anchors.topMargin: 16
|
anchors.topMargin: 20
|
||||||
anchors.bottomMargin: 16
|
anchors.bottomMargin: 20
|
||||||
|
|
||||||
spacing: 12
|
spacing: 16
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: titleText
|
||||||
|
|
||||||
CaptionTextType {
|
|
||||||
text: qsTr("CAPTCHA Verification")
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
horizontalAlignment: Text.AlignLeft
|
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
|
||||||
}
|
|
||||||
|
|
||||||
ParagraphTextType {
|
text: root.hint
|
||||||
text: hint
|
|
||||||
Layout.fillWidth: true
|
|
||||||
wrapMode: Text.WordWrap
|
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
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
verticalAlignment: Text.AlignTop
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 200
|
Layout.preferredHeight: 200
|
||||||
color: AmneziaStyle.color.lightGray
|
|
||||||
radius: 4
|
|
||||||
|
|
||||||
Image {
|
Rectangle {
|
||||||
id: captchaImage
|
id: imagePanel
|
||||||
anchors.centerIn: parent
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
anchors.fill: parent
|
||||||
if (captchaImageBase64 !== "") {
|
color: AmneziaStyle.color.pearlGray
|
||||||
source = "data:image/png;base64," + captchaImageBase64
|
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 {
|
BusyIndicator {
|
||||||
target: root
|
anchors.centerIn: parent
|
||||||
function onCaptchaImageBase64Changed() {
|
running: captchaImage.status === Image.Loading
|
||||||
captchaImage.source = "data:image/png;base64," + root.captchaImageBase64
|
}
|
||||||
|
|
||||||
|
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 {
|
TextFieldWithHeaderType {
|
||||||
anchors.centerIn: parent
|
id: solutionField
|
||||||
running: captchaImage.status === Image.Loading
|
|
||||||
|
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
|
Layout.fillWidth: true
|
||||||
spacing: 8
|
Layout.preferredHeight: 8
|
||||||
|
}
|
||||||
|
|
||||||
ParagraphTextType {
|
BasicButtonType {
|
||||||
text: qsTr("Can't read the image?")
|
id: continueButton
|
||||||
Layout.fillWidth: true
|
|
||||||
horizontalAlignment: Text.AlignLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicButtonType {
|
Layout.fillWidth: true
|
||||||
text: qsTr("Refresh")
|
implicitHeight: 52
|
||||||
implicitHeight: 32
|
|
||||||
|
|
||||||
onClicked: {
|
text: qsTr("Continue")
|
||||||
root.refreshCaptchaRequested()
|
defaultColor: AmneziaStyle.color.paleGray
|
||||||
}
|
hoveredColor: AmneziaStyle.color.lightGray
|
||||||
|
pressedColor: AmneziaStyle.color.mutedGray
|
||||||
|
textColor: AmneziaStyle.color.midnightBlack
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
submitIfNonEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ParagraphTextType {
|
BasicButtonType {
|
||||||
text: qsTr("Enter the numbers from the image:")
|
id: closeButton
|
||||||
Layout.fillWidth: true
|
|
||||||
horizontalAlignment: Text.AlignLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField {
|
|
||||||
id: solutionInput
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
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 {
|
clickedFunc: function() {
|
||||||
border.color: AmneziaStyle.color.charcoalGray
|
root.close()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitIfNonEmpty() {
|
||||||
|
const t = solutionField.textField.text.trim()
|
||||||
|
if (t !== "") {
|
||||||
|
root.captchaSolved(root.captchaId, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
tools/local_gateway/README.md
Normal file
55
tools/local_gateway/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -174,7 +174,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"error": "rate_limit_exceeded",
|
"error": "rate_limit_exceeded",
|
||||||
"captcha_id": id,
|
"captcha_id": id,
|
||||||
"captcha_image": b64,
|
"captcha_image": b64,
|
||||||
"hint": "Please solve the CAPTCHA to continue",
|
"hint": "Enter the digits from the image to continue",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user