Compare commits
17 commits
dev
...
feat/add-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b103d3966d | ||
|
|
d3c1f0a6f8 | ||
|
|
55572eddd1 | ||
|
|
344e7106c9 | ||
|
|
083f5b19ba | ||
|
|
63cc1e6a8e | ||
|
|
2729682309 | ||
|
|
924f6703d0 | ||
|
|
e5bbfe0036 | ||
|
|
118d11c194 | ||
|
|
ccf005eb9a | ||
|
|
0669939c9d | ||
|
|
73dc6ba6f1 | ||
|
|
bdf8a292ff | ||
|
|
576668dce6 | ||
|
|
6c91b41bfa | ||
|
|
8327de66dd |
22 changed files with 992 additions and 38 deletions
|
|
@ -4,7 +4,7 @@ set(CMAKE_CXX_STANDARD 17)
|
|||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
set(PROJECT AmneziaVPN)
|
||||
set(AMNEZIAVPN_VERSION 4.8.15.4)
|
||||
set(AMNEZIAVPN_VERSION 4.9.0.1)
|
||||
|
||||
set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE)
|
||||
set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}")
|
|||
add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}")
|
||||
add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}")
|
||||
|
||||
if(AMNEZIA_LOCAL_GATEWAY)
|
||||
add_definitions(-DAMNEZIA_LOCAL_GATEWAY=1)
|
||||
endif()
|
||||
|
||||
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
|
||||
set(PACKAGES ${PACKAGES} Widgets)
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <QEventLoop>
|
||||
#include <QFutureWatcher>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QPromise>
|
||||
#include <QSet>
|
||||
#include <QSysInfo>
|
||||
|
|
@ -209,16 +210,25 @@ void SubscriptionController::updateApiConfigInJson(QJsonObject &serverConfigJson
|
|||
serverConfigJson[apiDefs::key::apiConfig] = apiConfig;
|
||||
}
|
||||
|
||||
ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase)
|
||||
ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody,
|
||||
bool isTestPurchase, QString *outEffectiveRequestBase,
|
||||
const QString &reuseRequestBase)
|
||||
{
|
||||
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs,
|
||||
m_appSettingsRepository->isStrictKillSwitchEnabled());
|
||||
return gatewayController.post(endpoint, apiPayload, responseBody);
|
||||
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
|
||||
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs,
|
||||
m_appSettingsRepository->isStrictKillSwitchEnabled(), nullptr, reuseRequestBase);
|
||||
return gatewayController.post(endpoint, apiPayload, responseBody, outEffectiveRequestBase);
|
||||
}
|
||||
|
||||
void SubscriptionController::clearGatewayCaptchaSticky()
|
||||
{
|
||||
m_gatewayCaptchaStickyBase.clear();
|
||||
}
|
||||
|
||||
ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCountryCode, const QString &serviceType,
|
||||
const QString &serviceProtocol, const ProtocolData &protocolData,
|
||||
ServerConfig &serverConfig)
|
||||
ServerConfig &serverConfig,
|
||||
CaptchaInfo &captchaInfo)
|
||||
{
|
||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||
QString(APP_VERSION),
|
||||
|
|
@ -234,7 +244,25 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo
|
|||
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
|
||||
|
||||
QByteArray responseBody;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody);
|
||||
QString effectiveRequestBase;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, false, &effectiveRequestBase,
|
||||
m_gatewayCaptchaStickyBase);
|
||||
|
||||
if (errorCode == ErrorCode::ApiCaptchaRequiredError) {
|
||||
m_gatewayCaptchaStickyBase = effectiveRequestBase;
|
||||
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;
|
||||
}
|
||||
|
||||
m_gatewayCaptchaStickyBase.clear();
|
||||
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
return errorCode;
|
||||
}
|
||||
|
|
@ -244,11 +272,11 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo
|
|||
if (errorCode != ErrorCode::NoError) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
|
||||
updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody);
|
||||
|
||||
|
||||
ServerConfig serverConfigModel = ServerConfig::fromJson(serverConfigJson);
|
||||
|
||||
|
||||
if (!serverConfigModel.isApiV2()) {
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
|
@ -1090,3 +1118,79 @@ QFuture<QPair<ErrorCode, QString>> SubscriptionController::getRenewalLink(int se
|
|||
return promise->future();
|
||||
}
|
||||
|
||||
ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &userCountryCode,
|
||||
const QString &serviceType,
|
||||
const QString &serviceProtocol,
|
||||
const ProtocolData &protocolData,
|
||||
const QString &captchaId,
|
||||
const QString &captchaSolution,
|
||||
ServerConfig &serverConfig,
|
||||
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;
|
||||
QString effectiveRequestBase;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, false, &effectiveRequestBase,
|
||||
m_gatewayCaptchaStickyBase);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
m_gatewayCaptchaStickyBase = effectiveRequestBase;
|
||||
if (retryCaptchaOut
|
||||
&& (errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError)) {
|
||||
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;
|
||||
}
|
||||
|
||||
m_gatewayCaptchaStickyBase.clear();
|
||||
|
||||
QJsonObject serverConfigJson;
|
||||
errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody);
|
||||
|
||||
ServerConfig serverConfigModel = ServerConfig::fromJson(serverConfigJson);
|
||||
|
||||
if (!serverConfigModel.isApiV2()) {
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
m_serversRepository->addServer(serverConfigModel);
|
||||
serverConfig = serverConfigModel;
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ public:
|
|||
QJsonObject toJsonObject() const;
|
||||
};
|
||||
|
||||
struct CaptchaInfo {
|
||||
QString captchaId;
|
||||
QString captchaImageBase64;
|
||||
QString hint;
|
||||
bool isRequired = false;
|
||||
};
|
||||
|
||||
explicit SubscriptionController(SecureServersRepository* serversRepository,
|
||||
SecureAppSettingsRepository* appSettingsRepository);
|
||||
|
||||
|
|
@ -52,7 +59,8 @@ public:
|
|||
|
||||
ErrorCode importServiceFromGateway(const QString &userCountryCode, const QString &serviceType,
|
||||
const QString &serviceProtocol, const ProtocolData &protocolData,
|
||||
ServerConfig &serverConfig);
|
||||
ServerConfig &serverConfig,
|
||||
CaptchaInfo &captchaInfo);
|
||||
ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType,
|
||||
const QString &serviceProtocol, const QString &email,
|
||||
ServerConfig &serverConfig);
|
||||
|
|
@ -104,8 +112,17 @@ 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,
|
||||
ServerConfig &serverConfig, CaptchaInfo *retryCaptchaOut = nullptr);
|
||||
|
||||
void clearGatewayCaptchaSticky();
|
||||
|
||||
private:
|
||||
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
|
||||
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody,
|
||||
bool isTestPurchase = false, QString *outEffectiveRequestBase = nullptr,
|
||||
const QString &reuseRequestBase = QString());
|
||||
bool isApiKeyExpired(int serverIndex) const;
|
||||
|
||||
ErrorCode extractServerConfigJsonFromResponse(const QByteArray &apiResponseBody, const QString &protocol,
|
||||
|
|
@ -116,6 +133,8 @@ private:
|
|||
|
||||
SecureServersRepository* m_serversRepository;
|
||||
SecureAppSettingsRepository* m_appSettingsRepository;
|
||||
|
||||
QString m_gatewayCaptchaStickyBase;
|
||||
};
|
||||
|
||||
#endif // SUBSCRIPTIONCONTROLLER_H
|
||||
|
|
|
|||
|
|
@ -41,17 +41,38 @@ namespace
|
|||
|
||||
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
|
||||
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
bool gatewayUrlIsPlaintextMockTarget(const QString &gatewayEndpoint, const QString &proxyUrl)
|
||||
{
|
||||
const auto hostOf = [](const QString &urlString) -> QString {
|
||||
return QUrl(urlString).host().toLower();
|
||||
};
|
||||
const QString host = proxyUrl.isEmpty() ? hostOf(gatewayEndpoint) : hostOf(proxyUrl);
|
||||
return host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")
|
||||
|| host == QLatin1String("[::1]");
|
||||
}
|
||||
#endif
|
||||
constexpr int proxyStorageRequestTimeoutMsecs = 3000;
|
||||
}
|
||||
|
||||
GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent)
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent, const QString &reuseAgwRequestBase)
|
||||
: QObject(parent),
|
||||
m_gatewayEndpoint(gatewayEndpoint),
|
||||
m_isDevEnvironment(isDevEnvironment),
|
||||
m_requestTimeoutMsecs(requestTimeoutMsecs),
|
||||
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled)
|
||||
{
|
||||
if (!reuseAgwRequestBase.isEmpty()) {
|
||||
m_proxyUrl = reuseAgwRequestBase;
|
||||
}
|
||||
}
|
||||
|
||||
void GatewayController::writeEffectiveRequestBase(QString *outEffectiveRequestBase) const
|
||||
{
|
||||
if (outEffectiveRequestBase) {
|
||||
*outEffectiveRequestBase = m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl;
|
||||
}
|
||||
}
|
||||
|
||||
GatewayController::EncryptedRequestData GatewayController::prepareRequest(const QString &endpoint, const QJsonObject &apiPayload)
|
||||
|
|
@ -69,6 +90,16 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
|
|||
encRequestData.request.setRawHeader(QString("X-Client-Request-ID").toUtf8(), QUuid::createUuid().toString(QUuid::WithoutBraces).toUtf8());
|
||||
encRequestData.request.setUrl(endpoint.arg(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl));
|
||||
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
if (gatewayUrlIsPlaintextMockTarget(m_gatewayEndpoint, m_proxyUrl)) {
|
||||
encRequestData.requestBody = QJsonDocument(apiPayload).toJson();
|
||||
encRequestData.key.clear();
|
||||
encRequestData.iv.clear();
|
||||
encRequestData.salt.clear();
|
||||
return encRequestData;
|
||||
}
|
||||
#endif
|
||||
|
||||
// bypass killSwitch exceptions for API-gateway
|
||||
#ifdef AMNEZIA_DESKTOP
|
||||
if (m_isStrictKillSwitchEnabled) {
|
||||
|
|
@ -151,10 +182,12 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co
|
|||
return result;
|
||||
}
|
||||
|
||||
ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody)
|
||||
ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody,
|
||||
QString *outEffectiveRequestBase)
|
||||
{
|
||||
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
|
||||
if (encRequestData.errorCode != ErrorCode::NoError) {
|
||||
writeEffectiveRequestBase(outEffectiveRequestBase);
|
||||
return encRequestData.errorCode;
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +207,19 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
|
|||
|
||||
reply->deleteLater();
|
||||
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
const bool plaintextMock = encRequestData.key.isEmpty();
|
||||
DecryptionResult decryptionResult;
|
||||
decryptionResult.decryptedBody = encryptedResponseBody;
|
||||
decryptionResult.isDecryptionSuccessful = true;
|
||||
#else
|
||||
const bool plaintextMock = false;
|
||||
auto decryptionResult =
|
||||
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
|
||||
#endif
|
||||
|
||||
if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
|
||||
if (!plaintextMock && sslErrors.isEmpty()
|
||||
&& shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) {
|
||||
auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) {
|
||||
encRequestData.request.setUrl(url);
|
||||
return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
|
||||
|
|
@ -194,7 +236,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
|
|||
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
|
||||
|
||||
if (!sslErrors.isEmpty()
|
||||
|| shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
|
||||
|| shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) {
|
||||
sslErrors = nestedSslErrors;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -206,18 +248,21 @@ 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) {
|
||||
writeEffectiveRequestBase(outEffectiveRequestBase);
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
if (!decryptionResult.isDecryptionSuccessful) {
|
||||
qCritical() << "error when decrypting the request body";
|
||||
writeEffectiveRequestBase(outEffectiveRequestBase);
|
||||
return ErrorCode::ApiConfigDecryptionError;
|
||||
}
|
||||
|
||||
responseBody = decryptionResult.decryptedBody;
|
||||
writeEffectiveRequestBase(outEffectiveRequestBase);
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
|
|
@ -247,8 +292,18 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
|||
|
||||
reply->deleteLater();
|
||||
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
const bool plaintextMock = encRequestData.key.isEmpty();
|
||||
DecryptionResult decryptionResult;
|
||||
decryptionResult.decryptedBody = encryptedResponseBody;
|
||||
decryptionResult.isDecryptionSuccessful = true;
|
||||
#else
|
||||
// TODO
|
||||
/// Temp var plaintextMock
|
||||
const bool plaintextMock = false;
|
||||
auto decryptionResult =
|
||||
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
|
||||
#endif
|
||||
|
||||
auto processResponse = [promise, encRequestData](const GatewayController::DecryptionResult &decryptionResult,
|
||||
const QList<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
|
||||
|
|
@ -256,7 +311,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;
|
||||
}
|
||||
|
|
@ -273,7 +328,8 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
|||
promise->finish();
|
||||
};
|
||||
|
||||
if (sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
|
||||
if (!plaintextMock && sslErrors->isEmpty()
|
||||
&& shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) {
|
||||
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
|
||||
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
|
||||
|
||||
|
|
@ -432,7 +488,7 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
|
|||
}
|
||||
|
||||
bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
|
||||
bool isDecryptionSuccessful)
|
||||
bool isDecryptionSuccessful, int httpStatusCode)
|
||||
{
|
||||
const QByteArray &responseBody = decryptedResponseBody;
|
||||
|
||||
|
|
@ -446,6 +502,22 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
|
|||
apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed();
|
||||
}
|
||||
} else {
|
||||
// Plaintext JSON error (e.g. HTTP 402 CAPTCHA) is not encrypted — do not treat as proxy failure.
|
||||
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"))) {
|
||||
return false;
|
||||
}
|
||||
const QString err = jsonObj.value(QStringLiteral("error")).toString();
|
||||
if (err.contains(QLatin1String("captcha"), Qt::CaseInsensitive) || err == QLatin1String("rate_limit_exceeded") || err == QLatin1String("refresh_captcha")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Reverse proxy or unknown route returns plaintext (e.g. "404 page not found") — not a proxy/CDN issue.
|
||||
if (httpStatusCode == httpStatusCodeNotFound || replyError == QNetworkReply::ContentNotFoundError) {
|
||||
return false;
|
||||
}
|
||||
qDebug() << "failed to decrypt the data";
|
||||
return true;
|
||||
}
|
||||
|
|
@ -511,6 +583,10 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv
|
|||
|
||||
qDebug() << "go to the next proxy endpoint";
|
||||
QNetworkReply *reply = requestFunction(endpoint.arg(proxyUrl));
|
||||
if (!reply) {
|
||||
qWarning() << "GatewayController::bypassProxy: requestFunction returned null";
|
||||
return false;
|
||||
}
|
||||
|
||||
QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
|
||||
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
|
||||
|
|
@ -533,6 +609,10 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv
|
|||
for (const QString &proxyUrl : proxyUrls) {
|
||||
request.setUrl(proxyUrl + "lmbd-health");
|
||||
reply = amnApp->networkManager()->get(request);
|
||||
if (!reply) {
|
||||
qWarning() << "GatewayController::bypassProxy: health check get() returned null";
|
||||
continue;
|
||||
}
|
||||
|
||||
connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
|
||||
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ class GatewayController : public QObject
|
|||
|
||||
public:
|
||||
explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr);
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr,
|
||||
const QString &reuseAgwRequestBase = QString());
|
||||
|
||||
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
|
||||
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody,
|
||||
QString *outEffectiveRequestBase = nullptr);
|
||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
|
||||
|
||||
private:
|
||||
|
|
@ -49,7 +51,8 @@ private:
|
|||
const QByteArray &key, const QByteArray &iv, const QByteArray &salt);
|
||||
|
||||
QStringList getProxyUrls(const QString &serviceType, const QString &userCountryCode);
|
||||
bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful);
|
||||
bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
|
||||
bool isDecryptionSuccessful, int httpStatusCode);
|
||||
void bypassProxy(const QString &endpoint, const QString &serviceType, const QString &userCountryCode,
|
||||
std::function<QNetworkReply *(const QString &url)> requestFunction,
|
||||
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
|
||||
|
|
@ -61,12 +64,14 @@ private:
|
|||
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
|
||||
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete);
|
||||
|
||||
void writeEffectiveRequestBase(QString *outEffectiveRequestBase) const;
|
||||
|
||||
int m_requestTimeoutMsecs;
|
||||
QString m_gatewayEndpoint;
|
||||
bool m_isDevEnvironment = false;
|
||||
bool m_isStrictKillSwitchEnabled = false;
|
||||
|
||||
inline static QString m_proxyUrl;
|
||||
QString m_proxyUrl;
|
||||
};
|
||||
|
||||
#endif // GATEWAYCONTROLLER_H
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepos
|
|||
{
|
||||
}
|
||||
|
||||
UpdateController::~UpdateController() = default;
|
||||
|
||||
QString UpdateController::getRawChangelogText() const
|
||||
{
|
||||
return m_changelogText;
|
||||
|
|
@ -97,6 +99,7 @@ void UpdateController::fetchGatewayUrl()
|
|||
m_appSettingsRepository->isDevGatewayEnv(),
|
||||
7000,
|
||||
m_appSettingsRepository->isStrictKillSwitchEnabled());
|
||||
m_activeGatewayController = gatewayController;
|
||||
|
||||
QJsonObject apiPayload;
|
||||
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
|
||||
|
|
@ -107,6 +110,7 @@ void UpdateController::fetchGatewayUrl()
|
|||
QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() {
|
||||
gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload)
|
||||
.then(this, [this](QPair<ErrorCode, QByteArray> result) {
|
||||
m_activeGatewayController.clear();
|
||||
auto [err, gatewayResponse] = result;
|
||||
if (err != ErrorCode::NoError) {
|
||||
logger.error() << errorString(err);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
|
||||
#include "core/repositories/secureAppSettingsRepository.h"
|
||||
|
||||
#include <QSharedPointer>
|
||||
|
||||
class GatewayController;
|
||||
|
||||
class UpdateController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr);
|
||||
~UpdateController() override;
|
||||
|
||||
QString getRawChangelogText() const;
|
||||
QString getReleaseDate() const;
|
||||
|
|
@ -38,6 +43,8 @@ private:
|
|||
|
||||
SecureAppSettingsRepository* m_appSettingsRepository;
|
||||
|
||||
QSharedPointer<GatewayController> m_activeGatewayController;
|
||||
|
||||
QString m_baseUrl;
|
||||
QString m_changelogText;
|
||||
QString m_version;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@
|
|||
using namespace amnezia;
|
||||
|
||||
namespace {
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
constexpr char gatewayEndpoint[] = "http://localhost:8080/";
|
||||
#else
|
||||
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
|
||||
#endif
|
||||
}
|
||||
|
||||
SecureAppSettingsRepository::SecureAppSettingsRepository(SecureQSettings* settings, QObject *parent)
|
||||
|
|
@ -260,7 +264,11 @@ void SecureAppSettingsRepository::setGatewayEndpoint(const QString &endpoint)
|
|||
void SecureAppSettingsRepository::resetGatewayEndpoint()
|
||||
{
|
||||
m_gatewayEndpoint = gatewayEndpoint;
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
setValue(QStringLiteral("Conf/devGatewayEnv"), true);
|
||||
#else
|
||||
setValue("Conf/gatewayEndpoint", gatewayEndpoint);
|
||||
#endif
|
||||
}
|
||||
|
||||
void SecureAppSettingsRepository::setDevGatewayEndpoint()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ namespace
|
|||
return value.isString() ? value.toString().trimmed() : QString();
|
||||
}
|
||||
|
||||
QString apiErrorTokenFromJson(const QJsonObject &jsonObj)
|
||||
{
|
||||
const QString message = apiErrorMessageFromJson(jsonObj);
|
||||
if (!message.isEmpty()) {
|
||||
return message;
|
||||
}
|
||||
return jsonObj.value(QStringLiteral("error")).toString().trimmed();
|
||||
}
|
||||
|
||||
QString escapeUnicode(const QString &input)
|
||||
{
|
||||
QString output;
|
||||
|
|
@ -140,15 +149,13 @@ 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 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;
|
||||
|
|
@ -163,35 +170,75 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
|
|||
qDebug() << replyError;
|
||||
qDebug() << httpStatusCode;
|
||||
|
||||
if (httpStatusCode == httpStatusCodeNotFound) {
|
||||
const QJsonDocument probe = QJsonDocument::fromJson(responseBody);
|
||||
if (!probe.isObject()) {
|
||||
return amnezia::ErrorCode::ApiNotFoundError;
|
||||
}
|
||||
}
|
||||
|
||||
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
|
||||
if (jsonDoc.isObject()) {
|
||||
QJsonObject jsonObj = jsonDoc.object();
|
||||
const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1);
|
||||
if (httpStatusFromBody == httpStatusCodeConflict) {
|
||||
int status = jsonObj.value(QStringLiteral("http_status")).toInt(-1);
|
||||
if (status < 0) {
|
||||
status = httpStatusCode;
|
||||
}
|
||||
if (status == 0 && httpStatusCode >= 400) {
|
||||
status = httpStatusCode;
|
||||
}
|
||||
|
||||
if (status == httpStatusCodeTooManyRequests) {
|
||||
return amnezia::ErrorCode::ApiRateLimitError;
|
||||
}
|
||||
if (status == httpStatusCodeConflict) {
|
||||
if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) {
|
||||
return amnezia::ErrorCode::ApiTrialAlreadyUsedError;
|
||||
}
|
||||
return amnezia::ErrorCode::ApiConfigLimitError;
|
||||
}
|
||||
if (httpStatusFromBody == httpStatusCodeNotFound) {
|
||||
if (status == httpStatusCodeNotFound) {
|
||||
return amnezia::ErrorCode::ApiNotFoundError;
|
||||
}
|
||||
if (httpStatusFromBody == httpStatusCodeNotImplemented) {
|
||||
if (status == httpStatusCodeNotImplemented) {
|
||||
return amnezia::ErrorCode::ApiUpdateRequestError;
|
||||
}
|
||||
if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) {
|
||||
if (status == httpStatusCodeUnprocessableEntity) {
|
||||
if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) {
|
||||
return amnezia::ErrorCode::ApiSubscriptionExpiredError;
|
||||
}
|
||||
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||
}
|
||||
if (httpStatusFromBody == httpStatusCodePaymentRequired) {
|
||||
if (status == httpStatusCodePaymentRequired) {
|
||||
const QString errorToken = apiErrorTokenFromJson(jsonObj);
|
||||
if (errorToken.contains(QLatin1String("refresh_captcha"), Qt::CaseInsensitive)) {
|
||||
return amnezia::ErrorCode::ApiCaptchaRefreshError;
|
||||
}
|
||||
if (errorToken.contains(QLatin1String("invalid_captcha"), Qt::CaseInsensitive)) {
|
||||
return amnezia::ErrorCode::ApiCaptchaInvalidError;
|
||||
}
|
||||
if (jsonObj.contains(QStringLiteral("captcha_id")) || jsonObj.contains(QStringLiteral("captcha_image"))
|
||||
|| errorToken.compare(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive) == 0
|
||||
|| errorToken.contains(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive)) {
|
||||
return amnezia::ErrorCode::ApiCaptchaRequiredError;
|
||||
}
|
||||
return amnezia::ErrorCode::ApiSubscriptionNotActiveError;
|
||||
}
|
||||
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||
|
||||
if (replyError == QNetworkReply::NoError && status > 0 && status < 400) {
|
||||
return amnezia::ErrorCode::NoError;
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "something went wrong";
|
||||
if (replyError == QNetworkReply::NoError) {
|
||||
return amnezia::ErrorCode::NoError;
|
||||
}
|
||||
|
||||
qDebug() << "something went wrong" << replyErrorString;
|
||||
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ namespace amnezia
|
|||
ApiSubscriptionNotActiveError = 1114,
|
||||
ApiNoPurchasedSubscriptionsError = 1115,
|
||||
ApiTrialAlreadyUsedError = 1116,
|
||||
ApiCaptchaRequiredError = 1117,
|
||||
ApiCaptchaInvalidError = 1118,
|
||||
ApiCaptchaRefreshError = 1119,
|
||||
ApiRateLimitError = 1120,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,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;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ SubscriptionUiController::SubscriptionUiController(ServersController* serversCon
|
|||
});
|
||||
}
|
||||
|
||||
bool SubscriptionUiController::isCaptchaAwaitingUser() const
|
||||
{
|
||||
return m_captchaState.isPending;
|
||||
}
|
||||
|
||||
bool SubscriptionUiController::exportVpnKey(int serverIndex, const QString &fileName)
|
||||
{
|
||||
if (fileName.isEmpty()) {
|
||||
|
|
@ -250,6 +255,10 @@ bool SubscriptionUiController::restoreServiceFromAppStore()
|
|||
|
||||
bool SubscriptionUiController::importFreeFromGateway()
|
||||
{
|
||||
if (!isCaptchaAwaitingUser()) {
|
||||
m_subscriptionController->clearGatewayCaptchaSticky();
|
||||
}
|
||||
|
||||
QString userCountryCode = m_apiServicesModel->getCountryCode();
|
||||
QString serviceType = m_apiServicesModel->getSelectedServiceType();
|
||||
QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol();
|
||||
|
|
@ -260,21 +269,110 @@ bool SubscriptionUiController::importFreeFromGateway()
|
|||
}
|
||||
|
||||
SubscriptionController::ProtocolData protocolData = m_subscriptionController->generateProtocolData(serviceProtocol);
|
||||
SubscriptionController::CaptchaInfo captchaInfo;
|
||||
|
||||
ServerConfig serverConfig;
|
||||
ErrorCode errorCode = m_subscriptionController->importServiceFromGateway(userCountryCode, serviceType,
|
||||
serviceProtocol, protocolData,
|
||||
serverConfig);
|
||||
serverConfig, 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;
|
||||
|
||||
ServerConfig serverConfig;
|
||||
SubscriptionController::CaptchaInfo retryCaptcha;
|
||||
ErrorCode errorCode = m_subscriptionController->resolveImportServiceCaptcha(
|
||||
m_captchaState.userCountryCode,
|
||||
m_captchaState.serviceType,
|
||||
m_captchaState.serviceProtocol,
|
||||
protocolData,
|
||||
captchaId,
|
||||
solution,
|
||||
serverConfig,
|
||||
&retryCaptcha);
|
||||
|
||||
if (errorCode == ErrorCode::NoError) {
|
||||
m_captchaState.isPending = false;
|
||||
emit captchaFlowDismissRequested();
|
||||
m_serversController->addServer(serverConfig);
|
||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError)
|
||||
&& 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;
|
||||
ServerConfig serverConfig;
|
||||
|
||||
ErrorCode errorCode = m_subscriptionController->importServiceFromGateway(
|
||||
m_captchaState.userCountryCode,
|
||||
m_captchaState.serviceType,
|
||||
m_captchaState.serviceProtocol,
|
||||
protocolData,
|
||||
serverConfig,
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ public slots:
|
|||
void setCurrentProtocol(int serverIndex, const QString &protocolName);
|
||||
bool isVlessProtocol(int serverIndex);
|
||||
|
||||
bool isCaptchaAwaitingUser() const;
|
||||
void onCaptchaSolved(const QString &captchaId, const QString &solution);
|
||||
void onRefreshCaptchaRequested();
|
||||
|
||||
void removeApiConfig(int serverIndex);
|
||||
|
||||
bool getAccountInfo(int serverIndex, bool reload);
|
||||
|
|
@ -79,6 +83,20 @@ signals:
|
|||
void apiConfigRemoved(const QString &message);
|
||||
|
||||
void vpnKeyExportReady();
|
||||
void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint);
|
||||
void captchaFlowDismissRequested();
|
||||
|
||||
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();
|
||||
|
|
|
|||
263
client/ui/qml/Controls2/CaptchaDialogType.qml
Normal file
263
client/ui/qml/Controls2/CaptchaDialogType.qml
Normal file
|
|
@ -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,24 @@ Window {
|
|||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
objectName: "captchaDialogItem"
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
CaptchaDialogType {
|
||||
id: captchaDialog
|
||||
|
||||
onCaptchaSolved: function(captchaId, solution) {
|
||||
SubscriptionUiController.onCaptchaSolved(captchaId, solution)
|
||||
}
|
||||
|
||||
onRefreshCaptchaRequested: function() {
|
||||
SubscriptionUiController.onRefreshCaptchaRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
objectName: "privateKeyPassphraseDrawerItem"
|
||||
|
||||
|
|
@ -310,6 +328,17 @@ Window {
|
|||
function onSubscriptionExpiredOnServer() {
|
||||
subscriptionExpiredDrawer.openTriggered()
|
||||
}
|
||||
|
||||
function onCaptchaRequired(captchaId, captchaImageBase64, hint) {
|
||||
captchaDialog.captchaId = captchaId
|
||||
captchaDialog.captchaImageBase64 = captchaImageBase64
|
||||
captchaDialog.hint = hint
|
||||
captchaDialog.open()
|
||||
}
|
||||
|
||||
function onCaptchaFlowDismissRequested() {
|
||||
captchaDialog.close()
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
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
|
||||
```
|
||||
5
tools/local_gateway/go.mod
Normal file
5
tools/local_gateway/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module gateway_plaintext_mock
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/dchest/captcha v1.1.0
|
||||
2
tools/local_gateway/go.sum
Normal file
2
tools/local_gateway/go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||
194
tools/local_gateway/main.go
Normal file
194
tools/local_gateway/main.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Plaintext mock for AmneziaVPN client (CMake AMNEZIA_LOCAL_GATEWAY=ON + localhost DEV_AGW_ENDPOINT).
|
||||
// No RSA/AES — POST JSON is the same object the client sends inside api_payload when encrypted.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
)
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 10 {
|
||||
return id
|
||||
}
|
||||
return id[:10] + "…"
|
||||
}
|
||||
|
||||
// Set to 5 to mimic "more than 5 requests per 24h". Set to 0 so the first amnezia-free request returns CAPTCHA (faster UI test).
|
||||
const rateLimitExcessAfter = 0
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requests = map[string][]time.Time{} // installation_uuid -> timestamps (sliding window simplified: count in session)
|
||||
)
|
||||
|
||||
func pruneRequests(uuid string) {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-24 * time.Hour)
|
||||
var kept []time.Time
|
||||
for _, t := range requests[uuid] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
requests[uuid] = kept
|
||||
}
|
||||
|
||||
func overLimit(uuid string) bool {
|
||||
pruneRequests(uuid)
|
||||
return len(requests[uuid]) > rateLimitExcessAfter
|
||||
}
|
||||
|
||||
func handleServices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = r.Body.Close()
|
||||
|
||||
// Minimal shape for ApiServicesModel::updateModel + importFreeFromGateway (service_protocol "awg").
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"user_country_code": "ZZ",
|
||||
"services": []map[string]any{
|
||||
{
|
||||
"service_type": "amnezia-free",
|
||||
"service_protocol": "awg",
|
||||
"service_info": map[string]any{},
|
||||
"is_available": true,
|
||||
"service_description": map[string]any{
|
||||
"service_name": "Amnezia Free (mock)",
|
||||
"card_description": "Local plaintext mock",
|
||||
"description": "For CAPTCHA UI test only",
|
||||
},
|
||||
"available_countries": []any{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
st, _ := body["service_type"].(string)
|
||||
if st != "amnezia-free" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "mock: only amnezia-free"})
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := body["installation_uuid"].(string)
|
||||
if uuid == "" {
|
||||
uuid = "anonymous"
|
||||
}
|
||||
|
||||
captchaID, _ := body["captcha_id"].(string)
|
||||
solution, _ := body["captcha_solution"].(string)
|
||||
refresh, _ := body["refresh_captcha"].(bool)
|
||||
|
||||
if refresh {
|
||||
var buf bytes.Buffer
|
||||
id := captcha.NewLen(6)
|
||||
_ = captcha.WriteImage(&buf, id, 240, 80)
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
log.Printf("captcha REFRESH id=%s uuid=%s", shortID(id), uuid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"captcha_id": id,
|
||||
"captcha_image": b64,
|
||||
"hint": "Refreshed CAPTCHA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if captchaID != "" && solution != "" {
|
||||
if captcha.VerifyString(captchaID, solution) {
|
||||
mu.Lock()
|
||||
requests[uuid] = nil
|
||||
mu.Unlock()
|
||||
log.Printf("captcha VERIFIED id=%s uuid=%s (dchest.VerifyString ok) -> HTTP 200", shortID(captchaID), uuid)
|
||||
// HTTP 200, no http_status:501 in body — client maps 501 to ApiUpdateRequestError ("update the app").
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"captcha_verified": true,
|
||||
"message": "mock gateway: captcha ok — no vpn:// config in this mock (expect empty-config error in client)",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("captcha REJECTED id=%s uuid=%s solution_len=%d (dchest.VerifyString failed) -> HTTP 402 invalid_captcha",
|
||||
shortID(captchaID), uuid, len(solution))
|
||||
var buf bytes.Buffer
|
||||
id := captcha.NewLen(6)
|
||||
_ = captcha.WriteImage(&buf, id, 240, 80)
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "invalid_captcha",
|
||||
"captcha_id": id,
|
||||
"captcha_image": b64,
|
||||
"hint": "Try again",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
requests[uuid] = append(requests[uuid], time.Now())
|
||||
limit := overLimit(uuid)
|
||||
mu.Unlock()
|
||||
|
||||
if limit {
|
||||
var buf bytes.Buffer
|
||||
id := captcha.NewLen(6)
|
||||
_ = captcha.WriteImage(&buf, id, 240, 80)
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
log.Printf("captcha ISSUED id=%s uuid=%s (402 rate_limit_exceeded)", shortID(id), uuid)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "rate_limit_exceeded",
|
||||
"captcha_id": id,
|
||||
"captcha_image": b64,
|
||||
"hint": "Enter the digits from the image to continue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "mock: under rate limit, no config payload",
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/v1/services", handleServices)
|
||||
http.HandleFunc("/v1/config", handleConfig)
|
||||
log.Println("plaintext mock listening on :8080 POST /v1/services POST /v1/config")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue