mirror of
https://github.com/JGeek00/adguard-home-manager.git
synced 2025-04-25 00:06:07 +00:00
Added check and save encryption settings
This commit is contained in:
parent
137a976a36
commit
3a2483e9d6
7 changed files with 535 additions and 31 deletions
|
@ -515,5 +515,13 @@
|
||||||
"privateKeyFile": "Set a private key file",
|
"privateKeyFile": "Set a private key file",
|
||||||
"pastePrivateKey": "Paste the private key contents",
|
"pastePrivateKey": "Paste the private key contents",
|
||||||
"usePreviousKey": "Use the previously saved key",
|
"usePreviousKey": "Use the previously saved key",
|
||||||
"privateKeyPath": "Private key path"
|
"privateKeyPath": "Private key path",
|
||||||
|
"invalidCertificate": "Invalid certificate",
|
||||||
|
"invalidPrivateKey": "Invalid private key",
|
||||||
|
"validatingData": "Validating data",
|
||||||
|
"dataValid": "Data is valid",
|
||||||
|
"dataNotValid": "Data not valid",
|
||||||
|
"encryptionConfigSaved": "Encryption configuration saved successfully",
|
||||||
|
"encryptionConfigNotSaved": "Encryption configuration could not be saved",
|
||||||
|
"configError": "Configuration error"
|
||||||
}
|
}
|
|
@ -515,5 +515,13 @@
|
||||||
"privateKeyFile": "Establecer un archivo de clave privada",
|
"privateKeyFile": "Establecer un archivo de clave privada",
|
||||||
"pastePrivateKey": "Pegar el contenido de la clave privada",
|
"pastePrivateKey": "Pegar el contenido de la clave privada",
|
||||||
"usePreviousKey": "Usar la clave privada guardada previamente",
|
"usePreviousKey": "Usar la clave privada guardada previamente",
|
||||||
"privateKeyPath": "Ruta de la clave privada"
|
"privateKeyPath": "Ruta de la clave privada",
|
||||||
|
"invalidCertificate": "Certificado no válido",
|
||||||
|
"invalidPrivateKey": "Clave privada no válida",
|
||||||
|
"validatingData": "Validando datos",
|
||||||
|
"dataValid": "Datos válidos",
|
||||||
|
"dataNotValid": "Datos no válidos",
|
||||||
|
"encryptionConfigSaved": "Configuración de cifrado guardada correctamente",
|
||||||
|
"encryptionConfigNotSaved": "No se pudo guardar la configuración de encriptado",
|
||||||
|
"configError": "Configuration error"
|
||||||
}
|
}
|
|
@ -17,16 +17,20 @@ String encryptionDataToJson(EncryptionData data) => json.encode(data.toJson());
|
||||||
class EncryptionData {
|
class EncryptionData {
|
||||||
final bool validCert;
|
final bool validCert;
|
||||||
final bool validChain;
|
final bool validChain;
|
||||||
|
final String? subject;
|
||||||
|
final String? issuer;
|
||||||
final DateTime notBefore;
|
final DateTime notBefore;
|
||||||
final DateTime notAfter;
|
final DateTime notAfter;
|
||||||
final dynamic dnsNames;
|
final List<String> dnsNames;
|
||||||
final bool validKey;
|
final bool validKey;
|
||||||
|
final String? keyType;
|
||||||
final bool validPair;
|
final bool validPair;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final String? serverName;
|
||||||
final bool forceHttps;
|
final bool forceHttps;
|
||||||
final int portHttps;
|
final int? portHttps;
|
||||||
final int portDnsOverTls;
|
final int? portDnsOverTls;
|
||||||
final int portDnsOverQuic;
|
final int? portDnsOverQuic;
|
||||||
final int portDnscrypt;
|
final int portDnscrypt;
|
||||||
final String dnscryptConfigFile;
|
final String dnscryptConfigFile;
|
||||||
final bool allowUnencryptedDoh;
|
final bool allowUnencryptedDoh;
|
||||||
|
@ -39,16 +43,20 @@ class EncryptionData {
|
||||||
EncryptionData({
|
EncryptionData({
|
||||||
required this.validCert,
|
required this.validCert,
|
||||||
required this.validChain,
|
required this.validChain,
|
||||||
|
this.subject,
|
||||||
|
this.issuer,
|
||||||
required this.notBefore,
|
required this.notBefore,
|
||||||
required this.notAfter,
|
required this.notAfter,
|
||||||
required this.dnsNames,
|
required this.dnsNames,
|
||||||
required this.validKey,
|
required this.validKey,
|
||||||
|
this.keyType,
|
||||||
required this.validPair,
|
required this.validPair,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
|
this.serverName,
|
||||||
required this.forceHttps,
|
required this.forceHttps,
|
||||||
required this.portHttps,
|
this.portHttps,
|
||||||
required this.portDnsOverTls,
|
this.portDnsOverTls,
|
||||||
required this.portDnsOverQuic,
|
this.portDnsOverQuic,
|
||||||
required this.portDnscrypt,
|
required this.portDnscrypt,
|
||||||
required this.dnscryptConfigFile,
|
required this.dnscryptConfigFile,
|
||||||
required this.allowUnencryptedDoh,
|
required this.allowUnencryptedDoh,
|
||||||
|
@ -63,12 +71,16 @@ class EncryptionData {
|
||||||
factory EncryptionData.fromJson(Map<String, dynamic> json) => EncryptionData(
|
factory EncryptionData.fromJson(Map<String, dynamic> json) => EncryptionData(
|
||||||
validCert: json["valid_cert"],
|
validCert: json["valid_cert"],
|
||||||
validChain: json["valid_chain"],
|
validChain: json["valid_chain"],
|
||||||
|
subject: json["subject"],
|
||||||
|
issuer: json["issuer"],
|
||||||
notBefore: DateTime.parse(json["not_before"]),
|
notBefore: DateTime.parse(json["not_before"]),
|
||||||
notAfter: DateTime.parse(json["not_after"]),
|
notAfter: DateTime.parse(json["not_after"]),
|
||||||
dnsNames: json["dns_names"],
|
dnsNames: json["dns_names"] != null ? List<String>.from(json["dns_names"].map((x) => x)) : [],
|
||||||
validKey: json["valid_key"],
|
validKey: json["valid_key"],
|
||||||
|
keyType: json["key_type"],
|
||||||
validPair: json["valid_pair"],
|
validPair: json["valid_pair"],
|
||||||
enabled: json["enabled"],
|
enabled: json["enabled"],
|
||||||
|
serverName: json["server_name"],
|
||||||
forceHttps: json["force_https"],
|
forceHttps: json["force_https"],
|
||||||
portHttps: json["port_https"],
|
portHttps: json["port_https"],
|
||||||
portDnsOverTls: json["port_dns_over_tls"],
|
portDnsOverTls: json["port_dns_over_tls"],
|
||||||
|
@ -86,12 +98,16 @@ class EncryptionData {
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
"valid_cert": validCert,
|
"valid_cert": validCert,
|
||||||
"valid_chain": validChain,
|
"valid_chain": validChain,
|
||||||
|
"subject": subject,
|
||||||
|
"issuer": issuer,
|
||||||
"not_before": notBefore.toIso8601String(),
|
"not_before": notBefore.toIso8601String(),
|
||||||
"not_after": notAfter.toIso8601String(),
|
"not_after": notAfter.toIso8601String(),
|
||||||
"dns_names": dnsNames,
|
"dns_names": List<dynamic>.from(dnsNames.map((x) => x)),
|
||||||
"valid_key": validKey,
|
"valid_key": validKey,
|
||||||
|
"key_type": keyType,
|
||||||
"valid_pair": validPair,
|
"valid_pair": validPair,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
|
"server_name": serverName,
|
||||||
"force_https": forceHttps,
|
"force_https": forceHttps,
|
||||||
"port_https": portHttps,
|
"port_https": portHttps,
|
||||||
"port_dns_over_tls": portDnsOverTls,
|
"port_dns_over_tls": portDnsOverTls,
|
||||||
|
|
25
lib/screens/settings/encryption/config_error_modal.dart
Normal file
25
lib/screens/settings/encryption/config_error_modal.dart
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class EncryptionErrorModal extends StatelessWidget {
|
||||||
|
final String error;
|
||||||
|
|
||||||
|
const EncryptionErrorModal({
|
||||||
|
Key? key,
|
||||||
|
required this.error,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(context)!.configError),
|
||||||
|
content: Text(error),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.close)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,15 @@
|
||||||
|
// ignore_for_file: use_build_context_synchronously
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
import 'package:adguard_home_manager/screens/settings/section_label.dart';
|
import 'package:adguard_home_manager/screens/settings/section_label.dart';
|
||||||
|
import 'package:adguard_home_manager/screens/settings/encryption/config_error_modal.dart';
|
||||||
import 'package:adguard_home_manager/widgets/custom_switch_list_tile.dart';
|
import 'package:adguard_home_manager/widgets/custom_switch_list_tile.dart';
|
||||||
|
|
||||||
|
import 'package:adguard_home_manager/classes/process_modal.dart';
|
||||||
|
import 'package:adguard_home_manager/functions/snackbar.dart';
|
||||||
import 'package:adguard_home_manager/services/http_requests.dart';
|
import 'package:adguard_home_manager/services/http_requests.dart';
|
||||||
import 'package:adguard_home_manager/providers/app_config_provider.dart';
|
import 'package:adguard_home_manager/providers/app_config_provider.dart';
|
||||||
import 'package:adguard_home_manager/providers/servers_provider.dart';
|
import 'package:adguard_home_manager/providers/servers_provider.dart';
|
||||||
|
@ -73,6 +78,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
final TextEditingController pastePrivateKeyController = TextEditingController();
|
final TextEditingController pastePrivateKeyController = TextEditingController();
|
||||||
String? pastePrivateKeyError;
|
String? pastePrivateKeyError;
|
||||||
|
|
||||||
|
bool validData = false;
|
||||||
|
String? validDataError;
|
||||||
|
int dataValidApi = 0;
|
||||||
|
|
||||||
void fetchData({bool? showRefreshIndicator}) async {
|
void fetchData({bool? showRefreshIndicator}) async {
|
||||||
widget.serversProvider.setEncryptionSettingsLoadStatus(0, showRefreshIndicator ?? false);
|
widget.serversProvider.setEncryptionSettingsLoadStatus(0, showRefreshIndicator ?? false);
|
||||||
|
@ -80,7 +88,35 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
final result = await getEncryptionSettings(server: widget.serversProvider.selectedServer!);
|
final result = await getEncryptionSettings(server: widget.serversProvider.selectedServer!);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
await checkValidDataApi();
|
||||||
|
|
||||||
if (result['result'] == 'success') {
|
if (result['result'] == 'success') {
|
||||||
|
setState(() {
|
||||||
|
enabled = result['data'].enabled;
|
||||||
|
domainNameController.text = result['data'].serverName ?? '';
|
||||||
|
redirectHttps = result['data'].forceHttps;
|
||||||
|
httpsPortController.text = result['data'].portHttps != null ? result['data'].portHttps.toString() : '';
|
||||||
|
tlsPortController.text = result['data'].portDnsOverTls != null ? result['data'].portDnsOverTls.toString() : '';
|
||||||
|
dnsOverQuicPortController.text = result['data'].portDnsOverQuic != null ? result['data'].portDnsOverQuic.toString() : '';
|
||||||
|
if (result['data'].certificateChain != '' && result['data'].certificatePath == '') {
|
||||||
|
certificateOption = 1;
|
||||||
|
certificateContentController.text = result['data'].certificateChain;
|
||||||
|
}
|
||||||
|
else if (result['data'].certificateChain == '' && result['data'].certificatePath != '') {
|
||||||
|
certificateOption = 0;
|
||||||
|
certificatePathController.text = result['data'].certificatePath;
|
||||||
|
}
|
||||||
|
if (result['data'].privateKey != '' && result['data'].privateKeyPath == '') {
|
||||||
|
privateKeyOption = 1;
|
||||||
|
pastePrivateKeyController.text = result['data'].privateKey;
|
||||||
|
}
|
||||||
|
else if (result['data'].privateKey == '' && result['data'].privateKeyPath != '') {
|
||||||
|
privateKeyOption = 0;
|
||||||
|
privateKeyPathController.text = result['data'].privateKeyPath;
|
||||||
|
}
|
||||||
|
usePreviouslySavedKey = result['data'].privateKeySaved;
|
||||||
|
});
|
||||||
|
|
||||||
widget.serversProvider.setEncryptionSettings(result['data']);
|
widget.serversProvider.setEncryptionSettings(result['data']);
|
||||||
widget.serversProvider.setEncryptionSettingsLoadStatus(1, true);
|
widget.serversProvider.setEncryptionSettingsLoadStatus(1, true);
|
||||||
}
|
}
|
||||||
|
@ -91,9 +127,186 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void validateDomain(String domain) {
|
||||||
|
RegExp regExp = RegExp(r'^([a-z0-9|-]+\.)*[a-z0-9|-]+\.[a-z]+$');
|
||||||
|
if (regExp.hasMatch(domain)) {
|
||||||
|
setState(() => domainError = null);
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setState(() => domainError = AppLocalizations.of(context)!.domainNotValid);
|
||||||
|
}
|
||||||
|
checkDataValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
void validatePort(String value, String portType) {
|
||||||
|
if (int.tryParse(value) != null && int.parse(value) <= 65535) {
|
||||||
|
setState(() {
|
||||||
|
switch (portType) {
|
||||||
|
case 'https':
|
||||||
|
setState(() => httpsPortError = null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tls':
|
||||||
|
setState(() => tlsPortError = null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'quic':
|
||||||
|
setState(() => dnsOverQuicPortError = null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setState(() {
|
||||||
|
switch (portType) {
|
||||||
|
case 'https':
|
||||||
|
setState(() => httpsPortError = AppLocalizations.of(context)!.invalidPort);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tls':
|
||||||
|
setState(() => tlsPortError = AppLocalizations.of(context)!.invalidPort);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'quic':
|
||||||
|
setState(() => dnsOverQuicPortError = AppLocalizations.of(context)!.invalidPort);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkDataValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateCertificate(String cert) {
|
||||||
|
final regExp = RegExp(r'(-{3,}(\bBEGIN CERTIFICATE\b))|(-{3,}-{3,}(\END CERTIFICATE\b)-{3,})', multiLine: true);
|
||||||
|
if (regExp.hasMatch(cert.replaceAll('\n', ''))) {
|
||||||
|
setState(() => certificateContentError = AppLocalizations.of(context)!.invalidCertificate);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setState(() => certificateContentError = null);
|
||||||
|
}
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
void validatePrivateKey(String cert) {
|
||||||
|
final regExp = RegExp(r'(-{3,}(\bBEGIN\b).*(PRIVATE KEY\b))|(-{3,}-{3,}(\bEND\b).*(PRIVATE KEY\b)-{3,})', multiLine: true);
|
||||||
|
if (regExp.hasMatch(cert.replaceAll('\n', ''))) {
|
||||||
|
setState(() => pastePrivateKeyError = AppLocalizations.of(context)!.invalidPrivateKey);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setState(() => pastePrivateKeyError = null);
|
||||||
|
}
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
void validatePath(String cert, String item) {
|
||||||
|
final regExp = RegExp(r'^(\/{0,1}(?!\/))[A-Za-z0-9\/\-_]+(\.([a-zA-Z]+))?$');
|
||||||
|
if (regExp.hasMatch(cert)) {
|
||||||
|
if (item == 'cert') {
|
||||||
|
setState(() => certificatePathError = null);
|
||||||
|
}
|
||||||
|
else if (item == 'private_key') {
|
||||||
|
setState(() => privateKeyPathError = null);
|
||||||
|
}
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (item == 'cert') {
|
||||||
|
setState(() => certificatePathError = AppLocalizations.of(context)!.invalidPath);
|
||||||
|
}
|
||||||
|
else if (item == 'private_key') {
|
||||||
|
setState(() => privateKeyPathError = AppLocalizations.of(context)!.invalidPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkDataValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future checkValidDataApi() async {
|
||||||
|
setState(() => dataValidApi = 0);
|
||||||
|
|
||||||
|
final result = await checkEncryptionSettings(server: widget.serversProvider.selectedServer!, data: {
|
||||||
|
"enabled": enabled,
|
||||||
|
"server_name": domainNameController.text,
|
||||||
|
"force_https": redirectHttps,
|
||||||
|
"port_https": httpsPortController.text != '' ? int.parse(httpsPortController.text) : null,
|
||||||
|
"port_dns_over_tls": tlsPortController.text != '' ? int.parse(tlsPortController.text) : null,
|
||||||
|
"port_dns_over_quic": dnsOverQuicPortController.text != '' ? int.parse(dnsOverQuicPortController.text) : null,
|
||||||
|
"certificate_chain": certificateContentController.text.replaceAll('\n', ''),
|
||||||
|
"private_key": pastePrivateKeyController.text.replaceAll('\n', ''),
|
||||||
|
"private_key_saved": usePreviouslySavedKey,
|
||||||
|
"certificate_path": certificatePathController.text,
|
||||||
|
"private_key_path": privateKeyPathController.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result['result'] == 'success') {
|
||||||
|
setState(() {
|
||||||
|
if (result['data']['warning_validation'] != null && result['data']['warning_validation'] != '') {
|
||||||
|
dataValidApi = 2;
|
||||||
|
validDataError = result['data']['warning_validation'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dataValidApi = 1;
|
||||||
|
validDataError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (result['log'].resBody != null) {
|
||||||
|
setState(() => validDataError = result['log'].resBody);
|
||||||
|
}
|
||||||
|
setState(() => dataValidApi = 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkDataValid() {
|
||||||
|
if (
|
||||||
|
domainNameController.text != '' &&
|
||||||
|
domainError == null &&
|
||||||
|
httpsPortController.text != '' &&
|
||||||
|
httpsPortError == null &&
|
||||||
|
tlsPortController.text != '' &&
|
||||||
|
tlsPortError == null &&
|
||||||
|
dnsOverQuicPortController.text != '' &&
|
||||||
|
dnsOverQuicPortError == null &&
|
||||||
|
((
|
||||||
|
certificateOption == 0 &&
|
||||||
|
certificatePathController.text != '' &&
|
||||||
|
certificatePathError == null
|
||||||
|
) || (
|
||||||
|
certificateOption == 1 &&
|
||||||
|
certificateContentController.text != '' &&
|
||||||
|
certificateContentError == null
|
||||||
|
)) &&
|
||||||
|
((
|
||||||
|
privateKeyOption == 0 &&
|
||||||
|
privateKeyPathController.text != '' &&
|
||||||
|
privateKeyPathError == null
|
||||||
|
) || (
|
||||||
|
privateKeyOption == 1 &&
|
||||||
|
pastePrivateKeyController.text != '' &&
|
||||||
|
pastePrivateKeyError == null
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
setState(() => validData = true);
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setState(() => validData = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +315,88 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
final serversProvider = Provider.of<ServersProvider>(context);
|
final serversProvider = Provider.of<ServersProvider>(context);
|
||||||
final appConfigProvider = Provider.of<AppConfigProvider>(context);
|
final appConfigProvider = Provider.of<AppConfigProvider>(context);
|
||||||
|
|
||||||
|
void saveData() async {
|
||||||
|
ProcessModal processModal = ProcessModal(context: context);
|
||||||
|
processModal.open(AppLocalizations.of(context)!.savingConfig);
|
||||||
|
|
||||||
|
final result = await saveEncryptionSettings(server: serversProvider.selectedServer!, data: {
|
||||||
|
"enabled": enabled,
|
||||||
|
"server_name": domainNameController.text,
|
||||||
|
"force_https": redirectHttps,
|
||||||
|
"port_https": int.parse(httpsPortController.text),
|
||||||
|
"port_dns_over_tls": int.parse(tlsPortController.text),
|
||||||
|
"port_dns_over_quic": int.parse(dnsOverQuicPortController.text),
|
||||||
|
"certificate_chain": certificateContentController.text,
|
||||||
|
"private_key": pastePrivateKeyController.text,
|
||||||
|
"private_key_saved": usePreviouslySavedKey,
|
||||||
|
"certificate_path": certificatePathController.text,
|
||||||
|
"private_key_path": privateKeyPathController.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
processModal.close();
|
||||||
|
|
||||||
|
if (result['result'] == 'success') {
|
||||||
|
showSnacbkar(
|
||||||
|
context: context,
|
||||||
|
appConfigProvider: appConfigProvider,
|
||||||
|
label: AppLocalizations.of(context)!.encryptionConfigSaved,
|
||||||
|
color: Colors.green
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
appConfigProvider.addLog(result['log']);
|
||||||
|
|
||||||
|
showSnacbkar(
|
||||||
|
context: context,
|
||||||
|
appConfigProvider: appConfigProvider,
|
||||||
|
label: AppLocalizations.of(context)!.encryptionConfigNotSaved,
|
||||||
|
color: Colors.red
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget generateStatus() {
|
||||||
|
if (dataValidApi == 0) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 25,
|
||||||
|
width: 25,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (dataValidApi == 1) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (dataValidApi == 2) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.cancel_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String generateStatusString() {
|
||||||
|
if (dataValidApi == 0) {
|
||||||
|
return AppLocalizations.of(context)!.validatingData;
|
||||||
|
}
|
||||||
|
else if (dataValidApi == 1) {
|
||||||
|
return AppLocalizations.of(context)!.dataValid;
|
||||||
|
}
|
||||||
|
else if (dataValidApi == 2) {
|
||||||
|
return AppLocalizations.of(context)!.dataNotValid;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget generateBody() {
|
Widget generateBody() {
|
||||||
switch (widget.serversProvider.encryptionSettings.loadStatus) {
|
switch (widget.serversProvider.encryptionSettings.loadStatus) {
|
||||||
case 0:
|
case 0:
|
||||||
|
@ -138,7 +433,11 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => setState(() => enabled = !enabled),
|
onTap: () {
|
||||||
|
setState(() => enabled = !enabled);
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
},
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
@ -171,7 +470,11 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: enabled,
|
value: enabled,
|
||||||
onChanged: (value) => setState(() => enabled = value),
|
onChanged: (value) {
|
||||||
|
setState(() => enabled = value);
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
},
|
||||||
activeColor: Theme.of(context).primaryColor,
|
activeColor: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -184,8 +487,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: domainNameController,
|
controller: domainNameController,
|
||||||
// onChanged:
|
onChanged: validateDomain,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.link_rounded),
|
prefixIcon: const Icon(Icons.link_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -196,6 +500,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
errorText: domainError,
|
errorText: domainError,
|
||||||
labelText: AppLocalizations.of(context)!.domainName,
|
labelText: AppLocalizations.of(context)!.domainName,
|
||||||
helperText: AppLocalizations.of(context)!.domainNameDescription,
|
helperText: AppLocalizations.of(context)!.domainNameDescription,
|
||||||
|
helperStyle: TextStyle(
|
||||||
|
color: Theme.of(context).listTileTheme.iconColor
|
||||||
|
),
|
||||||
helperMaxLines: 10
|
helperMaxLines: 10
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -203,15 +510,21 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
CustomSwitchListTile(
|
CustomSwitchListTile(
|
||||||
value: redirectHttps,
|
value: redirectHttps,
|
||||||
onChanged: (value) => setState(() => redirectHttps = value),
|
onChanged: (value) {
|
||||||
|
setState(() => redirectHttps = value);
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
},
|
||||||
title: AppLocalizations.of(context)!.redirectHttps,
|
title: AppLocalizations.of(context)!.redirectHttps,
|
||||||
|
disabled: !enabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: httpsPortController,
|
controller: httpsPortController,
|
||||||
// onChanged:
|
onChanged: (value) => validatePort(value, 'https'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.numbers_rounded),
|
prefixIcon: const Icon(Icons.numbers_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -229,8 +542,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: tlsPortController,
|
controller: tlsPortController,
|
||||||
// onChanged:
|
onChanged: (value) => validatePort(value, 'tls'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.numbers_rounded),
|
prefixIcon: const Icon(Icons.numbers_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -248,8 +562,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: dnsOverQuicPortController,
|
controller: dnsOverQuicPortController,
|
||||||
// onChanged:
|
onChanged: (value) => validatePort(value, 'quic'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.numbers_rounded),
|
prefixIcon: const Icon(Icons.numbers_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -283,7 +598,13 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
RadioListTile(
|
RadioListTile(
|
||||||
value: 0,
|
value: 0,
|
||||||
groupValue: certificateOption,
|
groupValue: certificateOption,
|
||||||
onChanged: (value) => setState(() => certificateOption = value!),
|
onChanged: enabled == true
|
||||||
|
? (value) {
|
||||||
|
setState(() => certificateOption = int.parse(value.toString()));
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
AppLocalizations.of(context)!.certificateFilePath,
|
AppLocalizations.of(context)!.certificateFilePath,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
@ -294,7 +615,13 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
RadioListTile(
|
RadioListTile(
|
||||||
value: 1,
|
value: 1,
|
||||||
groupValue: certificateOption,
|
groupValue: certificateOption,
|
||||||
onChanged: (value) => setState(() => certificateOption = value!),
|
onChanged: enabled == true
|
||||||
|
? (value) {
|
||||||
|
setState(() => certificateOption = int.parse(value.toString()));
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
AppLocalizations.of(context)!.pasteCertificateContent,
|
AppLocalizations.of(context)!.pasteCertificateContent,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
@ -306,8 +633,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
if (certificateOption == 0) Padding(
|
if (certificateOption == 0) Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: certificatePathController,
|
controller: certificatePathController,
|
||||||
// onChanged:
|
onChanged: (value) => validatePath(value, 'cert'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.description_rounded),
|
prefixIcon: const Icon(Icons.description_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -323,8 +651,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
if (certificateOption == 1) Padding(
|
if (certificateOption == 1) Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: certificateContentController,
|
controller: certificateContentController,
|
||||||
// onChanged:
|
onChanged: validateCertificate,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.description_rounded),
|
prefixIcon: const Icon(Icons.description_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -343,7 +672,13 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
RadioListTile(
|
RadioListTile(
|
||||||
value: 0,
|
value: 0,
|
||||||
groupValue: privateKeyOption,
|
groupValue: privateKeyOption,
|
||||||
onChanged: (value) => setState(() => privateKeyOption = value!),
|
onChanged: enabled == true
|
||||||
|
? (value) {
|
||||||
|
setState(() => privateKeyOption = int.parse(value.toString()));
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
AppLocalizations.of(context)!.privateKeyFile,
|
AppLocalizations.of(context)!.privateKeyFile,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
@ -354,7 +689,13 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
RadioListTile(
|
RadioListTile(
|
||||||
value: 1,
|
value: 1,
|
||||||
groupValue: privateKeyOption,
|
groupValue: privateKeyOption,
|
||||||
onChanged: (value) => setState(() => privateKeyOption = value!),
|
onChanged: enabled == true
|
||||||
|
? (value) {
|
||||||
|
setState(() => privateKeyOption = int.parse(value.toString()));
|
||||||
|
checkDataValid();
|
||||||
|
checkValidDataApi();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
AppLocalizations.of(context)!.pastePrivateKey,
|
AppLocalizations.of(context)!.pastePrivateKey,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
@ -374,8 +715,9 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
if (privateKeyOption == 0) Padding(
|
if (privateKeyOption == 0) Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
enabled: enabled,
|
||||||
controller: privateKeyPathController,
|
controller: privateKeyPathController,
|
||||||
// onChanged:
|
onChanged: (value) => validatePath(value, 'private_key'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.description_rounded),
|
prefixIcon: const Icon(Icons.description_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -391,9 +733,11 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
if (privateKeyOption == 1) Padding(
|
if (privateKeyOption == 1) Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
enabled: !usePreviouslySavedKey,
|
enabled: enabled == true
|
||||||
|
? !usePreviouslySavedKey
|
||||||
|
: false,
|
||||||
controller: pastePrivateKeyController,
|
controller: pastePrivateKeyController,
|
||||||
// onChanged:
|
onChanged: validatePrivateKey,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.description_rounded),
|
prefixIcon: const Icon(Icons.description_rounded),
|
||||||
border: const OutlineInputBorder(
|
border: const OutlineInputBorder(
|
||||||
|
@ -445,6 +789,27 @@ class _EncryptionSettingsWidgetState extends State<EncryptionSettingsWidget> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context)!.encryptionSettings),
|
title: Text(AppLocalizations.of(context)!.encryptionSettings),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: validData == true && dataValidApi == 2 && validDataError != null
|
||||||
|
? () => {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => EncryptionErrorModal(error: validDataError!)
|
||||||
|
)
|
||||||
|
} : null,
|
||||||
|
icon: generateStatus(),
|
||||||
|
tooltip: generateStatusString()
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: dataValidApi == 1
|
||||||
|
? () => saveData()
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
tooltip: AppLocalizations.of(context)!.save,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: generateBody(),
|
body: generateBody(),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1741,3 +1741,74 @@ Future getEncryptionSettings({
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future checkEncryptionSettings({
|
||||||
|
required Server server,
|
||||||
|
required Map<String, dynamic> data,
|
||||||
|
}) async {
|
||||||
|
final result = await apiRequest(
|
||||||
|
urlPath: '/tls/validate',
|
||||||
|
method: 'post',
|
||||||
|
server: server,
|
||||||
|
body: data,
|
||||||
|
type: 'check_encryption_settings'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result['hasResponse'] == true) {
|
||||||
|
if (result['statusCode'] == 200) {
|
||||||
|
return {
|
||||||
|
'result': 'success',
|
||||||
|
'data': jsonDecode(result['body'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
'result': 'error',
|
||||||
|
'log': AppLog(
|
||||||
|
type: 'check_encryption_settings',
|
||||||
|
dateTime: DateTime.now(),
|
||||||
|
message: 'error_code_not_expected',
|
||||||
|
statusCode: result['statusCode'].toString(),
|
||||||
|
resBody: result['body'],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future saveEncryptionSettings({
|
||||||
|
required Server server,
|
||||||
|
required Map<String, dynamic> data,
|
||||||
|
}) async {
|
||||||
|
final result = await apiRequest(
|
||||||
|
urlPath: '/tls/configure',
|
||||||
|
method: 'post',
|
||||||
|
server: server,
|
||||||
|
body: data,
|
||||||
|
type: 'update_encryption_settings'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result['hasResponse'] == true) {
|
||||||
|
if (result['statusCode'] == 200) {
|
||||||
|
return { 'result': 'success' };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
'result': 'error',
|
||||||
|
'log': AppLog(
|
||||||
|
type: 'update_encryption_settings',
|
||||||
|
dateTime: DateTime.now(),
|
||||||
|
message: 'error_code_not_expected',
|
||||||
|
statusCode: result['statusCode'].toString(),
|
||||||
|
resBody: result['body'],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,12 +5,14 @@ class CustomSwitchListTile extends StatelessWidget {
|
||||||
final void Function(bool) onChanged;
|
final void Function(bool) onChanged;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
final bool? disabled;
|
||||||
|
|
||||||
const CustomSwitchListTile({
|
const CustomSwitchListTile({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
this.disabled,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -19,7 +21,9 @@ class CustomSwitchListTile extends StatelessWidget {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => onChanged(!value),
|
onTap: disabled != null && disabled == true
|
||||||
|
? null
|
||||||
|
: () => onChanged(!value),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 20, left: 24, right: 10, bottom: 20
|
top: 20, left: 24, right: 10, bottom: 20
|
||||||
|
@ -34,8 +38,11 @@ class CustomSwitchListTile extends StatelessWidget {
|
||||||
width: MediaQuery.of(context).size.width-110,
|
width: MediaQuery.of(context).size.width-110,
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
color: disabled != null && disabled == true
|
||||||
|
? Colors.grey
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -47,7 +54,9 @@ class CustomSwitchListTile extends StatelessWidget {
|
||||||
subtitle!,
|
subtitle!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Theme.of(context).listTileTheme.iconColor,
|
color: disabled != null && disabled == true
|
||||||
|
? Colors.grey
|
||||||
|
: Theme.of(context).listTileTheme.iconColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -56,7 +65,9 @@ class CustomSwitchListTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: disabled != null && disabled == true
|
||||||
|
? null
|
||||||
|
: onChanged,
|
||||||
activeColor: Theme.of(context).primaryColor,
|
activeColor: Theme.of(context).primaryColor,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Reference in a new issue