chore: minor ui fixes (#1446)
* chore: minor ui fixes * chore: update ru translation file * bugfix: fixed config update by ttl for gateway configs * bugfix: fixed proxy bypassing * chore: minor ui fixes * chore: update ru translation file * chore: bump version
This commit is contained in:
parent
728b48044c
commit
678bfffe49
11 changed files with 963 additions and 401 deletions
|
|
@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
|
||||||
|
|
||||||
set(PROJECT AmneziaVPN)
|
set(PROJECT AmneziaVPN)
|
||||||
|
|
||||||
project(${PROJECT} VERSION 4.8.4.2
|
project(${PROJECT} VERSION 4.8.4.3
|
||||||
DESCRIPTION "AmneziaVPN"
|
DESCRIPTION "AmneziaVPN"
|
||||||
HOMEPAGE_URL "https://amnezia.org/"
|
HOMEPAGE_URL "https://amnezia.org/"
|
||||||
)
|
)
|
||||||
|
|
@ -11,7 +11,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
|
||||||
set(RELEASE_DATE "${CURRENT_DATE}")
|
set(RELEASE_DATE "${CURRENT_DATE}")
|
||||||
|
|
||||||
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
||||||
set(APP_ANDROID_VERSION_CODE 2079)
|
set(APP_ANDROID_VERSION_CODE 2080)
|
||||||
|
|
||||||
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
||||||
set(MZ_PLATFORM_NAME "linux")
|
set(MZ_PLATFORM_NAME "linux")
|
||||||
|
|
|
||||||
|
|
@ -157,12 +157,12 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
|
||||||
auto replyProcessingFunction = [&encryptedResponseBody, &reply, &sslErrors, &key, &iv, &salt,
|
auto replyProcessingFunction = [&encryptedResponseBody, &reply, &sslErrors, &key, &iv, &salt,
|
||||||
this](QNetworkReply *nestedReply, const QList<QSslError> &nestedSslErrors) {
|
this](QNetworkReply *nestedReply, const QList<QSslError> &nestedSslErrors) {
|
||||||
encryptedResponseBody = nestedReply->readAll();
|
encryptedResponseBody = nestedReply->readAll();
|
||||||
if (!sslErrors.isEmpty() || !shouldBypassProxy(nestedReply, encryptedResponseBody, true, key, iv, salt)) {
|
reply = nestedReply;
|
||||||
|
if (!sslErrors.isEmpty() || shouldBypassProxy(nestedReply, encryptedResponseBody, true, key, iv, salt)) {
|
||||||
sslErrors = nestedSslErrors;
|
sslErrors = nestedSslErrors;
|
||||||
reply = nestedReply;
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
bypassProxy(endpoint, reply, requestFunction, replyProcessingFunction);
|
bypassProxy(endpoint, reply, requestFunction, replyProcessingFunction);
|
||||||
|
|
@ -212,45 +212,45 @@ QStringList GatewayController::getProxyUrls()
|
||||||
wait.exec();
|
wait.exec();
|
||||||
|
|
||||||
if (reply->error() == QNetworkReply::NetworkError::NoError) {
|
if (reply->error() == QNetworkReply::NetworkError::NoError) {
|
||||||
break;
|
auto encryptedResponseBody = reply->readAll();
|
||||||
}
|
reply->deleteLater();
|
||||||
reply->deleteLater();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto encryptedResponseBody = reply->readAll();
|
EVP_PKEY *privateKey = nullptr;
|
||||||
reply->deleteLater();
|
QByteArray responseBody;
|
||||||
|
try {
|
||||||
|
if (!m_isDevEnvironment) {
|
||||||
|
QCryptographicHash hash(QCryptographicHash::Sha512);
|
||||||
|
hash.addData(key);
|
||||||
|
QByteArray hashResult = hash.result().toHex();
|
||||||
|
|
||||||
EVP_PKEY *privateKey = nullptr;
|
QByteArray key = QByteArray::fromHex(hashResult.left(64));
|
||||||
QByteArray responseBody;
|
QByteArray iv = QByteArray::fromHex(hashResult.mid(64, 32));
|
||||||
try {
|
|
||||||
if (!m_isDevEnvironment) {
|
|
||||||
QCryptographicHash hash(QCryptographicHash::Sha512);
|
|
||||||
hash.addData(key);
|
|
||||||
QByteArray hashResult = hash.result().toHex();
|
|
||||||
|
|
||||||
QByteArray key = QByteArray::fromHex(hashResult.left(64));
|
QByteArray ba = QByteArray::fromBase64(encryptedResponseBody);
|
||||||
QByteArray iv = QByteArray::fromHex(hashResult.mid(64, 32));
|
|
||||||
|
|
||||||
QByteArray ba = QByteArray::fromBase64(encryptedResponseBody);
|
QSimpleCrypto::QBlockCipher blockCipher;
|
||||||
|
responseBody = blockCipher.decryptAesBlockCipher(ba, key, iv);
|
||||||
|
} else {
|
||||||
|
responseBody = encryptedResponseBody;
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
Utils::logException();
|
||||||
|
qCritical() << "error loading private key from environment variables or decrypting payload" << encryptedResponseBody;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
QSimpleCrypto::QBlockCipher blockCipher;
|
auto endpointsArray = QJsonDocument::fromJson(responseBody).array();
|
||||||
responseBody = blockCipher.decryptAesBlockCipher(ba, key, iv);
|
|
||||||
|
QStringList endpoints;
|
||||||
|
for (const auto &endpoint : endpointsArray) {
|
||||||
|
endpoints.push_back(endpoint.toString());
|
||||||
|
}
|
||||||
|
return endpoints;
|
||||||
} else {
|
} else {
|
||||||
responseBody = encryptedResponseBody;
|
reply->deleteLater();
|
||||||
}
|
}
|
||||||
} catch (...) {
|
|
||||||
Utils::logException();
|
|
||||||
qCritical() << "error loading private key from environment variables or decrypting payload" << encryptedResponseBody;
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
auto endpointsArray = QJsonDocument::fromJson(responseBody).array();
|
|
||||||
|
|
||||||
QStringList endpoints;
|
|
||||||
for (const auto &endpoint : endpointsArray) {
|
|
||||||
endpoints.push_back(endpoint.toString());
|
|
||||||
}
|
|
||||||
return endpoints;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool GatewayController::shouldBypassProxy(QNetworkReply *reply, const QByteArray &responseBody, bool checkEncryption, const QByteArray &key,
|
bool GatewayController::shouldBypassProxy(QNetworkReply *reply, const QByteArray &responseBody, bool checkEncryption, const QByteArray &key,
|
||||||
|
|
@ -262,7 +262,7 @@ bool GatewayController::shouldBypassProxy(QNetworkReply *reply, const QByteArray
|
||||||
} else if (responseBody.contains("html")) {
|
} else if (responseBody.contains("html")) {
|
||||||
qDebug() << "The response contains an html tag";
|
qDebug() << "The response contains an html tag";
|
||||||
return true;
|
return true;
|
||||||
} else if (checkEncryption) {
|
} else if (reply->error() == QNetworkReply::NetworkError::NoError && checkEncryption) {
|
||||||
try {
|
try {
|
||||||
QSimpleCrypto::QBlockCipher blockCipher;
|
QSimpleCrypto::QBlockCipher blockCipher;
|
||||||
static_cast<void>(blockCipher.decryptAesBlockCipher(responseBody, key, iv, "", salt));
|
static_cast<void>(blockCipher.decryptAesBlockCipher(responseBody, key, iv, "", salt));
|
||||||
|
|
@ -296,7 +296,7 @@ void GatewayController::bypassProxy(const QString &endpoint, QNetworkReply *repl
|
||||||
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
|
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
|
||||||
wait.exec();
|
wait.exec();
|
||||||
|
|
||||||
if (!replyProcessingFunction(reply, sslErrors)) {
|
if (replyProcessingFunction(reply, sslErrors)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -407,7 +407,7 @@ bool ApiConfigsController::isConfigValid()
|
||||||
return updateServiceFromGateway(serverIndex, "", "");
|
return updateServiceFromGateway(serverIndex, "", "");
|
||||||
} else if (configSource && m_serversModel->isApiKeyExpired(serverIndex)) {
|
} else if (configSource && m_serversModel->isApiKeyExpired(serverIndex)) {
|
||||||
qDebug() << "attempt to update api config by expires_at event";
|
qDebug() << "attempt to update api config by expires_at event";
|
||||||
if (configSource == apiDefs::ConfigSource::Telegram) {
|
if (configSource == apiDefs::ConfigSource::AmneziaGateway) {
|
||||||
return updateServiceFromGateway(serverIndex, "", "");
|
return updateServiceFromGateway(serverIndex, "", "");
|
||||||
} else {
|
} else {
|
||||||
m_serversModel->removeApiConfig(serverIndex);
|
m_serversModel->removeApiConfig(serverIndex);
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
|
||||||
}
|
}
|
||||||
case ServiceDescriptionRole: {
|
case ServiceDescriptionRole: {
|
||||||
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2) {
|
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2) {
|
||||||
return tr("Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to 200 "
|
return tr("Classic VPN for seamless work, downloading large files, and watching videos. Access all websites and online resources. "
|
||||||
"Mb/s");
|
"Speeds up to 200 Mbps");
|
||||||
} else if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
} else if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
||||||
return tr("Free unlimited access to a basic set of websites such as Facebook, Instagram, Twitter (X), Discord, Telegram and "
|
return tr("Free unlimited access to a basic set of websites such as Facebook, Instagram, Twitter (X), Discord, Telegram and "
|
||||||
"more. YouTube is not included in the free plan.");
|
"more. YouTube is not included in the free plan.");
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ PageType {
|
||||||
actionButtonImage: "qrc:/images/controls/settings.svg"
|
actionButtonImage: "qrc:/images/controls/settings.svg"
|
||||||
|
|
||||||
headerText: root.processedServer.name
|
headerText: root.processedServer.name
|
||||||
descriptionText: qsTr("Locations for connection")
|
descriptionText: qsTr("Location for connection")
|
||||||
|
|
||||||
actionButtonFunction: function() {
|
actionButtonFunction: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ PageType {
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
headerText: qsTr("Connected devices")
|
headerText: qsTr("Active devices")
|
||||||
descriptionText: qsTr("To manage connected devices")
|
descriptionText: qsTr("Manage currently connected devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
WarningType {
|
WarningType {
|
||||||
|
|
@ -71,8 +71,13 @@ PageType {
|
||||||
rightImageSource: "qrc:/images/controls/trash.svg"
|
rightImageSource: "qrc:/images/controls/trash.svg"
|
||||||
|
|
||||||
clickedFunction: function() {
|
clickedFunction: function() {
|
||||||
var headerText = qsTr("Deactivate the subscription on selected device")
|
if (isCurrentDevice && ServersModel.isDefaultServerCurrentlyProcessed() && ConnectionController.isConnected) {
|
||||||
var descriptionText = qsTr("The next time the “Connect” button is pressed, the device will be activated again")
|
PageController.showNotificationMessage(qsTr("Cannot unlink device during active connection"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerText = qsTr("Are you sure you want to unlink this device?")
|
||||||
|
var descriptionText = qsTr("This will unlink the device from your subscription. You can reconnect it anytime by pressing Connect.")
|
||||||
var yesButtonText = qsTr("Continue")
|
var yesButtonText = qsTr("Continue")
|
||||||
var noButtonText = qsTr("Cancel")
|
var noButtonText = qsTr("Cancel")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ PageType {
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
headerText: qsTr("How to connect on another device")
|
headerText: qsTr("How to connect on another device")
|
||||||
descriptionText: qsTr("Instructions on the Amnezia website")
|
descriptionText: qsTr("Setup guides on the Amnezia website")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ PageType {
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
headerText: qsTr("Configuration files")
|
headerText: qsTr("Configuration files")
|
||||||
descriptionText: qsTr("To connect a router or AmneziaWG application")
|
descriptionText: qsTr("For router setup or the AmneziaWG app")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,13 +123,13 @@ PageType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.margins: 16
|
Layout.margins: 16
|
||||||
|
|
||||||
headerText: qsTr("Configuration file ") + moreOptionsDrawer.countryName
|
headerText: moreOptionsDrawer.countryName + qsTr(" configuration file")
|
||||||
}
|
}
|
||||||
|
|
||||||
LabelWithButtonType {
|
LabelWithButtonType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
text: qsTr("Create a new")
|
text: qsTr("Generate a new configuration file")
|
||||||
descriptionText: qsTr("The previously created one will stop working")
|
descriptionText: qsTr("The previously created one will stop working")
|
||||||
|
|
||||||
clickedFunction: function() {
|
clickedFunction: function() {
|
||||||
|
|
@ -193,9 +193,15 @@ PageType {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showQuestion(isConfigIssue, countryCode, countryName) {
|
function showQuestion(isConfigIssue, countryCode, countryName) {
|
||||||
var headerText = qsTr("Revoke the actual %1 configuration file?").arg(countryName)
|
var headerText
|
||||||
var descriptionText = qsTr("The previously created file will no longer be valid. It will not be possible to connect using it.")
|
if (isConfigIssue) {
|
||||||
var yesButtonText = qsTr("Continue")
|
headerText = qsTr("Generate a new %1 configuration file?").arg(countryName)
|
||||||
|
} else {
|
||||||
|
headerText = qsTr("Revoke the current %1 configuration file?").arg(countryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptionText = qsTr("Your previous configuration file will no longer work, and it will not be possible to connect using it")
|
||||||
|
var yesButtonText = isConfigIssue ? qsTr("Download") : qsTr("Continue")
|
||||||
var noButtonText = qsTr("Cancel")
|
var noButtonText = qsTr("Cancel")
|
||||||
|
|
||||||
var yesButtonFunction = function() {
|
var yesButtonFunction = function() {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ PageType {
|
||||||
QtObject {
|
QtObject {
|
||||||
id: deviceCountObject
|
id: deviceCountObject
|
||||||
|
|
||||||
readonly property string title: qsTr("Connected devices")
|
readonly property string title: qsTr("Active connections")
|
||||||
readonly property string contentKey: "connectedDevices"
|
readonly property string contentKey: "connectedDevices"
|
||||||
readonly property string objectImageSource: "qrc:/images/controls/monitor.svg"
|
readonly property string objectImageSource: "qrc:/images/controls/monitor.svg"
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ PageType {
|
||||||
|
|
||||||
text: qsTr("Configuration files")
|
text: qsTr("Configuration files")
|
||||||
|
|
||||||
descriptionText: qsTr("To connect a router or AmneziaWG application")
|
descriptionText: qsTr("Manage configuration files")
|
||||||
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
||||||
|
|
||||||
clickedFunction: function() {
|
clickedFunction: function() {
|
||||||
|
|
@ -233,9 +233,9 @@ PageType {
|
||||||
|
|
||||||
visible: footer.isVisibleForAmneziaFree
|
visible: footer.isVisibleForAmneziaFree
|
||||||
|
|
||||||
text: qsTr("Connected devices")
|
text: qsTr("Active devices")
|
||||||
|
|
||||||
descriptionText: qsTr("To manage connected devices")
|
descriptionText: qsTr("Manage currently connected devices")
|
||||||
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
||||||
|
|
||||||
clickedFunction: function() {
|
clickedFunction: function() {
|
||||||
|
|
@ -265,6 +265,8 @@ PageType {
|
||||||
LabelWithButtonType {
|
LabelWithButtonType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
visible: footer.isVisibleForAmneziaFree
|
||||||
|
|
||||||
text: qsTr("How to connect on another device")
|
text: qsTr("How to connect on another device")
|
||||||
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
||||||
|
|
||||||
|
|
@ -273,7 +275,9 @@ PageType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DividerType {}
|
DividerType {
|
||||||
|
visible: footer.isVisibleForAmneziaFree
|
||||||
|
}
|
||||||
|
|
||||||
BasicButtonType {
|
BasicButtonType {
|
||||||
id: resetButton
|
id: resetButton
|
||||||
|
|
@ -325,17 +329,17 @@ PageType {
|
||||||
pressedColor: AmneziaStyle.color.sheerWhite
|
pressedColor: AmneziaStyle.color.sheerWhite
|
||||||
textColor: AmneziaStyle.color.vibrantRed
|
textColor: AmneziaStyle.color.vibrantRed
|
||||||
|
|
||||||
text: qsTr("Deactivate the subscription on this device")
|
text: qsTr("Unlink this device")
|
||||||
|
|
||||||
clickedFunc: function() {
|
clickedFunc: function() {
|
||||||
var headerText = qsTr("Deactivate the subscription on this device?")
|
var headerText = qsTr("Are you sure you want to unlink this device?")
|
||||||
var descriptionText = qsTr("The next time the “Connect” button is pressed, the device will be activated again")
|
var descriptionText = qsTr("This will unlink the device from your subscription. You can reconnect it anytime by pressing Connect.")
|
||||||
var yesButtonText = qsTr("Continue")
|
var yesButtonText = qsTr("Continue")
|
||||||
var noButtonText = qsTr("Cancel")
|
var noButtonText = qsTr("Cancel")
|
||||||
|
|
||||||
var yesButtonFunction = function() {
|
var yesButtonFunction = function() {
|
||||||
if (ServersModel.isDefaultServerCurrentlyProcessed() && ConnectionController.isConnected) {
|
if (ServersModel.isDefaultServerCurrentlyProcessed() && ConnectionController.isConnected) {
|
||||||
PageController.showNotificationMessage(qsTr("Cannot deactivate subscription during active connection"))
|
PageController.showNotificationMessage(qsTr("Cannot unlink device during active connection"))
|
||||||
} else {
|
} else {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
if (ApiConfigsController.deactivateDevice()) {
|
if (ApiConfigsController.deactivateDevice()) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ PageType {
|
||||||
QtObject {
|
QtObject {
|
||||||
id: techSupport
|
id: techSupport
|
||||||
|
|
||||||
readonly property string title: qsTr("For technical support")
|
readonly property string title: qsTr("Email Support")
|
||||||
readonly property string description: qsTr("support@amnezia.org")
|
readonly property string description: qsTr("support@amnezia.org")
|
||||||
readonly property string link: "mailto:support@amnezia.org"
|
readonly property string link: "mailto:support@amnezia.org"
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ PageType {
|
||||||
QtObject {
|
QtObject {
|
||||||
id: paymentSupport
|
id: paymentSupport
|
||||||
|
|
||||||
readonly property string title: qsTr("For payment issues")
|
readonly property string title: qsTr("Email Billing & Orders")
|
||||||
readonly property string description: qsTr("help@vpnpay.io")
|
readonly property string description: qsTr("help@vpnpay.io")
|
||||||
readonly property string link: "mailto:help@vpnpay.io"
|
readonly property string link: "mailto:help@vpnpay.io"
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ PageType {
|
||||||
QtObject {
|
QtObject {
|
||||||
id: site
|
id: site
|
||||||
|
|
||||||
readonly property string title: qsTr("Site")
|
readonly property string title: qsTr("Website")
|
||||||
readonly property string description: qsTr("amnezia.org")
|
readonly property string description: qsTr("amnezia.org")
|
||||||
readonly property string link: LanguageModel.getCurrentSiteUrl()
|
readonly property string link: LanguageModel.getCurrentSiteUrl()
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ PageType {
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
headerText: qsTr("Support")
|
headerText: qsTr("Support")
|
||||||
descriptionText: qsTr("Our technical support specialists are ready to help you at any time")
|
descriptionText: qsTr("Our technical support specialists are available to assist you at any time")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue