diff --git a/QS.Cloud.Client/BasicAuthInfoProvider.cs b/QS.Cloud.Client/BasicAuthInfoProvider.cs index fb0700d16..573a84194 100644 --- a/QS.Cloud.Client/BasicAuthInfoProvider.cs +++ b/QS.Cloud.Client/BasicAuthInfoProvider.cs @@ -9,7 +9,9 @@ public BasicAuthInfoProvider(string userName, string password) public string UserName { get; } - public string Password { get; } + public string Password { get; private set; } + + public void UpdatePassword(string newPassword) => Password = newPassword; } public interface IBasicAuthInfoProvider @@ -17,5 +19,7 @@ public interface IBasicAuthInfoProvider string UserName { get; } string Password { get; } + + void UpdatePassword(string newPassword); } } diff --git a/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs b/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs index d7babc3d9..2d0a80cf2 100644 --- a/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs +++ b/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs @@ -4,19 +4,44 @@ namespace QS.Cloud.Client { - public class CloudClientByBasicAuth : CloudClientServiceBase - { + public class CloudClientByBasicAuth : CloudClientServiceBase { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + /// Столько ждём соединения в CanConnect - это проверка доступности, а не сама операция + private const int ConnectTimeoutSeconds = 5; + + private readonly IBasicAuthInfoProvider authInfo; + public CloudClientByBasicAuth(IBasicAuthInfoProvider basicAuthInfoProvider, string serviceAddress, int servicePort) - : base(serviceAddress, servicePort) - { - headers = new Metadata - { { - "Authorization", - $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{basicAuthInfoProvider.UserName}:{basicAuthInfoProvider.Password}"))}" - } }; - + : base(serviceAddress, servicePort) { + authInfo = basicAuthInfoProvider ?? throw new ArgumentNullException(nameof(basicAuthInfoProvider)); + headers = BuildHeaders(); } - public override bool CanConnect => throw new NotImplementedException(); + private Metadata BuildHeaders() => new Metadata + { { + "Authorization", + $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authInfo.UserName}:{authInfo.Password}"))}" + } }; + + public virtual void UpdatePassword(string newPassword) { + authInfo.UpdatePassword(newPassword); + headers = BuildHeaders(); + } + + public override bool CanConnect { get { + try { + Channel.ConnectAsync(DateTime.UtcNow.AddSeconds(ConnectTimeoutSeconds)) + .GetAwaiter().GetResult(); + return true; + } + catch(Exception ex) { + // Недоступное облако здесь - штатный ответ «нельзя», операцию не валим. + // Но причину молча терять нельзя: без неё непонятно, почему пропали кнопки. + logger.Debug(ex, "Нет соединения с облаком QS"); + return false; + } + } + } } } diff --git a/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs b/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs index 7463f9b90..5b20c1ab3 100644 --- a/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs +++ b/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs @@ -5,6 +5,8 @@ namespace QS.Cloud.Client { public abstract class CloudClientServiceBase : IDisposable { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + private readonly string serviceAddress; private readonly int servicePort; private readonly ChannelCredentials credentials; @@ -39,10 +41,20 @@ protected Channel Channel public abstract bool CanConnect { get; } - public virtual async void Dispose() + public virtual void Dispose() { - if(channel != null) - await channel.ShutdownAsync(); + var opened = channel; + channel = null; + if(opened == null) + return; + + try { + opened.ShutdownAsync().GetAwaiter().GetResult(); + } + catch(Exception ex) { + // исключение из Dispose подменяет собой настоящую причину, по которой закрывались. + logger.Debug(ex, "Не удалось закрыть канал к облаку QS"); + } } } } diff --git a/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs b/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs new file mode 100644 index 000000000..a53c8b1ff --- /dev/null +++ b/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs @@ -0,0 +1,38 @@ +using Grpc.Core; +using QS.Cloud.Core; +using QS.Project.Versioning; +using System.Threading; + +namespace QS.Cloud.Client.Clients { + public class DatabaseManagementCloudClient : CloudClientByBasicAuth { + private readonly uint ProductCode; + public DatabaseManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider, uint productCode) + : base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443) + { + ProductCode = productCode; + } + public virtual ClearDatabaseResponse ClearDatabase(int baseId) { + var client = new DatabaseManagement.DatabaseManagementClient(Channel); + var request = new ClearDatabaseRequest { BaseId = baseId, ProductId = ProductCode }; + return client.ClearDatabase(request, headers); + } + + public virtual CheckDatabaseExistsResponse CheckDatabaseExists(string dbName) { + var client = new DatabaseManagement.DatabaseManagementClient(Channel); + var request = new CheckDatabaseExistsRequest { Name = dbName, ProductId = ProductCode }; + return client.CheckDatabaseExists(request, headers); + } + + public virtual CreateDatabaseResponse CreateDatabase(string dbName, string dbTitle) { + var client = new DatabaseManagement.DatabaseManagementClient(Channel); + var request = new CreateDatabaseRequest { Name = dbName, Title = dbTitle, ProductId = ProductCode }; + return client.CreateDatabase(request, headers); + } + + public virtual DropDatabaseResponse DropDatabase(int baseId) { + var client = new DatabaseManagement.DatabaseManagementClient(Channel); + var request = new DropDatabaseRequest { BaseId = baseId, ProductId = ProductCode }; + return client.DropDatabase(request, headers); + } + } +} diff --git a/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs b/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs index 28def483c..4bedbceaf 100644 --- a/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs +++ b/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs @@ -9,14 +9,14 @@ public class LoginManagementCloudClient : CloudClientByBasicAuth public LoginManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider) : base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443) { } - public StartResponse Start(string launcherVersion) { + public virtual StartResponse Start(string launcherVersion) { var client = new LoginManagement.LoginManagementClient(Channel); var request = new StartRequest { LauncherVersion = launcherVersion }; var response = client.Start(request, headers); return response; } - public StartSessionResponse StartSession(int baseId) + public virtual StartSessionResponse StartSession(int baseId) { var client = new LoginManagement.LoginManagementClient(Channel); @@ -29,7 +29,7 @@ public StartSessionResponse StartSession(int baseId) return response; } - public List GetBasesForUser(uint productId) + public virtual List GetBasesForUser(uint productId) { var client = new LoginManagement.LoginManagementClient(Channel); var request = new GetBasesForUserRequest(); @@ -38,7 +38,7 @@ public List GetBasesForUser(uint productId) return response.Bases.ToList(); } - public ChangePasswordResponse ChangePassword(string newPassword) { + public virtual ChangePasswordResponse ChangePassword(string newPassword) { var client = new LoginManagement.LoginManagementClient(Channel); var request = new ChangePasswordRequest { NewPassword = newPassword }; var response = client.ChangePassword(request, headers); diff --git a/QS.Cloud.Client/Clients/UserManagementCloudClient.cs b/QS.Cloud.Client/Clients/UserManagementCloudClient.cs index 77ed41744..bad91a4a0 100644 --- a/QS.Cloud.Client/Clients/UserManagementCloudClient.cs +++ b/QS.Cloud.Client/Clients/UserManagementCloudClient.cs @@ -1,63 +1,81 @@ +using System.Collections.Generic; +using System.Linq; using QS.Cloud.Core; namespace QS.Cloud.Client { - public class UserManagementCloudClient : CloudClientBySession + public class UserManagementCloudClient : CloudClientByBasicAuth { - public UserManagementCloudClient(ISessionInfoProvider sessionInfoProvider) - : base(sessionInfoProvider, "core.cloud.qsolution.ru", 443) { } + public UserManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider) + : base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443) { } - - public CreateUserResponse CreateUser(string login, string userName, string email, string password) + public virtual List GetUsers() { var client = new UserManagement.UserManagementClient(Channel); + var response = client.GetUsers(new GetUsersRequest(), headers); + return response.Users.ToList(); + } - var request = new CreateUserRequest - { - Login = login, Name = userName, Email = email, Password = password + public virtual CreateUserResponse CreateUser(UserInfo user, string password) + { + var client = new UserManagement.UserManagementClient(Channel); + var request = new CreateUserRequest { + Login = user.Login, + Name = user.Name ?? "", + Email = user.Email ?? "", + Password = password ?? "", + Phone = user.Phone ?? "", + Post = user.Post ?? "", + Comment = user.Comment ?? "", + IsAccountAdmin = user.IsAccountAdmin }; - - var response = client.CreateUser(request, headers); - - return response; + return client.CreateUser(request, headers); } - public DeleteUserResponse DeleteUser(string login) + public virtual UpdateUserResponse UpdateUser(UserInfo user, string newPassword) { var client = new UserManagement.UserManagementClient(Channel); + var request = new UpdateUserRequest { + Login = user.Login, + Name = user.Name ?? "", + Email = user.Email ?? "", + Phone = user.Phone ?? "", + Post = user.Post ?? "", + Comment = user.Comment ?? "", + Disabled = user.Disabled, + IsAccountAdmin = user.IsAccountAdmin, + NewPassword = newPassword ?? "" + }; + return client.UpdateUser(request, headers); + } + public virtual DeleteUserResponse DeleteUser(string login) + { + var client = new UserManagement.UserManagementClient(Channel); var request = new DeleteUserRequest { User = login }; - var response = client.DeleteUser(request, headers); - - return response; + return client.DeleteUser(request, headers); } - // strange, but protobuf has the same signature - public UpdateUserResponse UpdateUser() + public virtual List GetUserBaseAccess(string login, uint productId) { var client = new UserManagement.UserManagementClient(Channel); - - var request = new UpdateUserRequest(); - var response = client.UpdateUser(request, headers); - - return response; + var request = new GetUserBaseAccessRequest { User = login, ProductId = productId }; + var response = client.GetUserBaseAccess(request, headers); + return response.Bases.ToList(); } - public bool ChangeBaseAccess(string user, int baseId, bool grant, bool admin) + public virtual ChangeBaseAccessResponse ChangeBaseAccess(string user, int baseId, bool grant, bool admin, bool readOnly, uint productId) { var client = new UserManagement.UserManagementClient(Channel); - - var request = new ChangeBaseAccessRequest - { + var request = new ChangeBaseAccessRequest { User = user, BaseId = baseId, Grant = grant, - Admin = admin + Admin = admin, + ReadOnly = readOnly, + ProductId = productId }; - - var response = client.ChangeBaseAccess(request); - - return response.Success; + return client.ChangeBaseAccess(request, headers); } } } diff --git a/QS.Cloud.Client/Database/CloudDbSession.cs b/QS.Cloud.Client/Database/CloudDbSession.cs new file mode 100644 index 000000000..78f5224ee --- /dev/null +++ b/QS.Cloud.Client/Database/CloudDbSession.cs @@ -0,0 +1,53 @@ +using System; +using MySqlConnector; +using QS.Cloud.Client.Clients; +using QS.Cloud.Core; + +namespace QS.Cloud.Client.Database { + /// + /// Временное подключение к облачной базе + /// + public sealed class CloudDbSession : IDisposable { + private readonly AliveCloudClient sessionLife; + + public bool Success { get; } + public string Description { get; } + public bool IsAdmin { get; } + public BaseConnection Db { get; } + public MySqlConnectionStringBuilder ConnectionStringBuilder { get; } + + private CloudDbSession(StartSessionResponse session, AliveCloudClient sessionLife, MySqlConnectionStringBuilder connectionStringBuilder) { + Success = session.Success; + Description = session.Description; + IsAdmin = session.IsAdmin; + Db = session.Db; + this.sessionLife = sessionLife; + ConnectionStringBuilder = connectionStringBuilder; + } + + /// + /// Открывает сессию к базе. При успехе запускает keep-alive и собирает строку подключения; + /// при отказе возвращает сессию с = false + /// + public static CloudDbSession Open(LoginManagementCloudClient loginClient, int baseId) { + var session = loginClient.StartSession(baseId); + if(!session.Success) + return new CloudDbSession(session, null, null); + + var sessionLife = new AliveCloudClient(new SessionInfoProvider(session.SessionId)); + sessionLife.KeepAlive(); + + var builder = new MySqlConnectionStringBuilder { + Server = session.Db.Server, + Port = session.Db.Port, + UserID = session.Db.Login, + Password = session.Db.Password, + Database = session.Db.BaseName, + AllowUserVariables = true + }; + return new CloudDbSession(session, sessionLife, builder); + } + + public void Dispose() => sessionLife?.Dispose(); + } +} diff --git a/QS.Cloud.Client/Database/QSCloudProvider.cs b/QS.Cloud.Client/Database/QSCloudProvider.cs new file mode 100644 index 000000000..194023ad3 --- /dev/null +++ b/QS.Cloud.Client/Database/QSCloudProvider.cs @@ -0,0 +1,377 @@ +using Grpc.Core; +using MySqlConnector; +using QS.Cloud.Client.Clients; +using QS.Cloud.Core; +using QS.DbManagement; +using QS.DbManagement.Entities; +using QS.DBScripts.Controllers; +using QS.Dialog; +using QS.ErrorReporting; +using QS.Project.Versioning; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; + +namespace QS.Cloud.Client.Database { + public class QSCloudProvider : IDbProvider { + + public bool IsAdmin { get; protected set; } + public string Account { get; private set; } + public string UserName { get; private set; } + public byte ProductCode { get; private set; } + + public bool CanCreateDatabase => dbClient.CanConnect && IsAdmin; + public bool CanDropDatabase => CanCreateDatabase; + public bool CanBackupDatabase => true; + private const string MessageTitle = "Создание базы в облаке"; + + private readonly LoginManagementCloudClient loginClient; + private readonly DatabaseManagementCloudClient dbClient; + private readonly UserManagementCloudClient userClient; + + /// Клиентов создали мы сами - значит нам их и закрывать + private readonly bool ownsClients; + + public QSCloudProvider(IList parameters, byte productCode, string password = null) + : this(parameters.First(p => p.Name == "Account").Value, + parameters.First(p => p.Name == "Login").Value, + productCode, password) { } + private QSCloudProvider(string account, string userName, byte productCode, string password) + : this(account, userName, productCode, + new BasicAuthInfoProvider($@"{account}\{userName}", password)) { } + + private QSCloudProvider(string account, string userName, byte productCode, IBasicAuthInfoProvider authInfo) + : this(account, userName, productCode, + new LoginManagementCloudClient(authInfo), + new DatabaseManagementCloudClient(authInfo, productCode), + new UserManagementCloudClient(authInfo)) { + ownsClients = true; + } + + /// + /// Нужен тестам + /// + public QSCloudProvider(string account, string userName, byte productCode, + LoginManagementCloudClient loginClient, DatabaseManagementCloudClient dbClient, UserManagementCloudClient userClient) { + Account = account; + UserName = userName; + ProductCode = productCode; + + this.loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + this.dbClient = dbClient ?? throw new ArgumentNullException(nameof(dbClient)); + this.userClient = userClient ?? throw new ArgumentNullException(nameof(userClient)); + } + + #region Управление пользователями + + public bool CanManageUsers => IsAdmin; + public bool CanManageBaseAccess => IsAdmin; + + public DbUserFields SupportedUserFields => + DbUserFields.Name | DbUserFields.Email | DbUserFields.Phone | DbUserFields.Post + | DbUserFields.Comment | DbUserFields.AdminFlag | DbUserFields.Disabling | DbUserFields.BaseReadOnly; + + public bool CanChangeOwnPassword => true; + + public bool ChangeOwnPassword(string newPassword) => Call(() => + { + if(!loginClient.ChangePassword(newPassword).Success) + return false; + + loginClient.UpdatePassword(newPassword); + dbClient.UpdatePassword(newPassword); + userClient.UpdatePassword(newPassword); + return true; + }); + + public List GetUsers() => Call(() => + userClient.GetUsers().Select(ToDbUserInfo).ToList()); + + public bool CreateUser(DbUserInfo user, string password) => Call(() => { + var response = userClient.CreateUser(ToCloudUser(user), password); + return EnsureSuccess(response.Success, response.Message); + }); + + public bool UpdateUser(DbUserInfo user, string newPassword = null) => Call(() => { + var response = userClient.UpdateUser(ToCloudUser(user), newPassword); + return EnsureSuccess(response.Success, response.Message); + }); + + public bool DeleteUser(string login) => + Call(() => { + var response = userClient.DeleteUser(login); + return EnsureSuccess(response.Success, response.Message); + }); + + public List GetUserBaseAccess(string login) => Call(() => + userClient.GetUserBaseAccess(login, ProductCode) + .Select(b => new DbUserBaseAccess { + BaseId = b.BaseId, + Title = b.BaseTitle, + HasAccess = b.HasAccess, + IsAdmin = b.Admin, + ReadOnly = b.ReadOnly + }).ToList()); + + public bool SetUserBaseAccess(string login, DbUserBaseAccess access) => + Call(() => { + var response = userClient.ChangeBaseAccess( + login, access.BaseId, access.HasAccess, access.IsAdmin, access.ReadOnly, ProductCode); + return EnsureSuccess(response.Success, string.IsNullOrEmpty(response.Message) + ? "Не удалось изменить доступ к базе" + : response.Message); + }); + + private static T Call(Func operation) { + try { + return operation(); + } + catch(RpcException ex) { + throw CloudError(ex); + } + } + + private static bool EnsureSuccess(bool success, string message) { + if(!success) + throw new OperationRefusedException(message); + return true; + } + + private DbUserInfo ToDbUserInfo(UserInfo user) => new DbUserInfo { + Login = user.Login, + Name = user.Name, + Email = user.Email, + Phone = user.Phone, + Post = user.Post, + Comment = user.Comment, + Disabled = user.Disabled, + IsAdmin = user.IsAccountAdmin, + IsCurrentUser = string.Equals(user.Login, UserName, StringComparison.OrdinalIgnoreCase) + }; + + private static UserInfo ToCloudUser(DbUserInfo user) => new UserInfo { + Login = user.Login ?? "", + Name = user.Name ?? "", + Email = user.Email ?? "", + Phone = user.Phone ?? "", + Post = user.Post ?? "", + Comment = user.Comment ?? "", + Disabled = user.Disabled, + IsAccountAdmin = user.IsAdmin + }; + + #endregion + + #region Управление базами + + public bool CanRefreshMetadata => false; + + public RefreshMetadataResponse RefreshMetadata() => throw NoMetadataSync(); + + public RefreshMetadataResponse RefreshBases() => throw NoMetadataSync(); + + public RefreshMetadataResponse RefreshUsers() => throw NoMetadataSync(); + + private static Exception NoMetadataSync() => + new InvalidOperationException("Облако ведёт список баз само, синхронизация метаинформации ему не нужна."); + + public bool CreateDatabase(DbCreationRequest request) { + if(request == null) + throw new ArgumentNullException(nameof(request)); + try { + int? baseId = PrepareEmptyDatabase(request); + if(baseId == null) + return false; + + using(var session = CloudDbSession.Open(loginClient, baseId.Value)) { + if(!session.Success) { + request.Interaction.ReportError("Не удалось открыть сессию к созданной базе: " + session.Description, MessageTitle); + return false; + } + if(!session.IsAdmin) { + request.Interaction.ReportError("Вы не имеете прав администратора для наполнения базы", MessageTitle); + return false; + } + + request.CreationResources.ConnectionString = session.ConnectionStringBuilder.ConnectionString; + request.CreationResources.JustCreated = true; + var creationModel = request.CreationFactory.Create(request.CreationResources); + return creationModel.RunCreation(session.Db.BaseName, request.DbTitle); + } + } + catch(RpcException ex) { + throw CloudError(ex); + } + } + + private int? PrepareEmptyDatabase(DbCreationRequest request) { + var existing = dbClient.CheckDatabaseExists(request.DbName); + if(!existing.Exists) + return dbClient.CreateDatabase(request.DbName, request.DbTitle).BaseId; + + switch(request.Interaction.AskDropExistingDatabase(request.DbName)) { + case ToDoWithExistingDatabase.Recreate: + if(!dbClient.DropDatabase(existing.BaseId).Success) { + request.Interaction.ReportError("Не удалось удалить существующую базу: " + existing.BaseId, MessageTitle); + return null; + } + return dbClient.CreateDatabase(request.DbName, request.DbTitle).BaseId; + + case ToDoWithExistingDatabase.Rewrite: + // облако пересоздаст пустую базу, сохранив записи реестра и права доступа + if(!dbClient.ClearDatabase(existing.BaseId).Success) { + request.Interaction.ReportError("Не удалось очистить существующую базу: " + existing.BaseId, MessageTitle); + return null; + } + return existing.BaseId; + + default: // Nothing + return null; + } + } + + private bool disposed; + + protected virtual void Dispose(bool disposing) + { + if(disposed) + return; + + // Клиентов, пришедших снаружи, освобождает тот, кто их создал. + // Свои закрываем сами - за ними каналы gRPC + if(disposing && ownsClients) { + loginClient.Dispose(); + dbClient.Dispose(); + userClient.Dispose(); + } + + disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public bool DropDatabase(DbInfo database) => Call(() => + EnsureSuccess(dbClient.DropDatabase(database.BaseId).Success, "Облако не удалило базу")); + + public void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation) { + try { + using(var session = CloudDbSession.Open(loginClient, database.BaseId)) { + if(!session.Success) + throw new InvalidOperationException("Не удалось открыть сессию к облачной базе: " + session.Description); + new MariaDbExportService().Export(session.ConnectionStringBuilder, session.Db.BaseName, filePath, progress, cancellation); + } + } + catch(RpcException ex) { + throw CloudError(ex); + } + } + + public List GetUserDatabases() => Call(() => + loginClient.GetBasesForUser(ProductCode).Select(bi => new DbInfo { + Title = bi.BaseTitle, + BaseId = bi.BaseId, + BaseName = bi.BaseName, + Version = bi.BaseVersion + }).ToList()); + + public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) { + LoginToDatabaseResponse resp; + + try { + var cloudResponse = loginClient.StartSession(dbInfo.BaseId); + if(!cloudResponse.Success || cloudResponse.Db == null) + return new LoginToDatabaseResponse { + Success = false, + ErrorMessage = string.IsNullOrEmpty(cloudResponse.Description) + ? "Облако не открыло сессию к базе" + : cloudResponse.Description + }; + + var builder = new MySqlConnectionStringBuilder { + Server = cloudResponse.Db.Server, + Port = cloudResponse.Db.Port, + UserID = cloudResponse.Db.Login, + Password = cloudResponse.Db.Password, + Database = cloudResponse.Db.BaseName + }; + resp = new LoginToDatabaseResponse { + Success = cloudResponse.Success, + ConnectionString = builder.ConnectionString, + Login = UserName, + Parameters = new Dictionary(StringComparer.Ordinal) { + {"SessionId", cloudResponse.SessionId}, + {"BaseTitle", dbInfo.Title} + } + }; + } + // ErrorMessage уходит прямо в окно пользователю, а ex.Message у RpcException - это + // строка вида Status(StatusCode="PermissionDenied", Detail="Нет доступа к базе"): + // человеку читать в ней нечего. Берём Detail, как и во всех остальных вызовах + catch(RpcException ex) { + resp = new LoginToDatabaseResponse { + Success = false, + ErrorMessage = Describe(ex) + }; + } + catch(Exception ex) { + resp = new LoginToDatabaseResponse { + Success = false, + ErrorMessage = ex.Message + }; + } + + return resp; + } + + public LoginToServerResponse LoginToServer() { + // вход - ожидаемая точка отказа, поэтому не исключение, а Response с текстом для пользователя + try { + var cloudResponce = loginClient.Start(Assembly.GetExecutingAssembly().GetName().Version.ToString()); + + // права вызывающего читаются со свойств провайдера, в ответе их не дублируем + IsAdmin = cloudResponce.YouAccountAdmin; + return new LoginToServerResponse { + Success = true, + NeedToUpdateLauncher = cloudResponce.NeedUpdateLauncher + }; + } + catch(RpcException ex) when(ex.StatusCode == StatusCode.Unauthenticated || ex.StatusCode == StatusCode.PermissionDenied) { + return new LoginToServerResponse { + Success = false, + ErrorMessage = "Неверные данные для входа: " + Describe(ex) + }; + } + catch(RpcException ex) { + return new LoginToServerResponse { + Success = false, + ErrorMessage = "Не удалось подключиться к облаку QS: " + Describe(ex) + }; + } + } + + private static string Describe(RpcException ex) => + string.IsNullOrEmpty(ex.Status.Detail) ? ex.Message : ex.Status.Detail; + + private static Exception CloudError(RpcException ex) { + string message = Describe(ex); + switch(ex.StatusCode) { + case StatusCode.Unauthenticated: + case StatusCode.PermissionDenied: + return new UnauthorizedAccessException(message, ex); + case StatusCode.NotFound: + case StatusCode.AlreadyExists: + case StatusCode.InvalidArgument: + return new OperationRefusedException(message, ex); + default: + return new InvalidOperationException(message, ex); + } + } + #endregion + } +} diff --git a/QS.Cloud.Client/QsCloudConnectionTypeBase.cs b/QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs similarity index 78% rename from QS.Cloud.Client/QsCloudConnectionTypeBase.cs rename to QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs index 504c04fe5..8ba77eebb 100644 --- a/QS.Cloud.Client/QsCloudConnectionTypeBase.cs +++ b/QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs @@ -1,18 +1,20 @@ -using QS.DbManagement; +using QS.DbManagement; +using QS.DbManagement.Entities; +using QS.Utilities.Extensions; using System.Collections.Generic; using System.Linq; using System.Reflection; -using QS.Utilities.Extensions; -namespace QS.Cloud.Client { +namespace QS.Cloud.Client.Database { public class QsCloudConnectionTypeBase : ConnectionTypeBase { + public QsCloudConnectionTypeBase() { Title = "QS: Облако"; ConnectionTypeName = "QSCloud"; - + Parameters.Add(new ConnectionParameter("Account","Организация")); Parameters.Add(new ConnectionParameter("Login","Пользователь")); - + IconBytes = Assembly.GetExecutingAssembly().GetResourceByteArray("QS.Cloud.Client.Icons.qscloud.ico"); } @@ -21,7 +23,8 @@ public override bool CanConnect(IEnumerable parameters parameters.Any(p => p.Name == "Login" && !string.IsNullOrEmpty(p.Value)); } - public override IDbProvider CreateProvider(IList parameters, string password = null) - => new QSCloudProvider(parameters, password); + public override IDbProvider CreateProvider(IList parameters, byte productCode, string password = null) { + return new QSCloudProvider(parameters, productCode, password); + } } } diff --git a/QS.Cloud.Client/Protos/DatabaseManagement.proto b/QS.Cloud.Client/Protos/DatabaseManagement.proto new file mode 100644 index 000000000..3119fea6f --- /dev/null +++ b/QS.Cloud.Client/Protos/DatabaseManagement.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package QS.Cloud.Core; + +service DatabaseManagement{ + rpc CheckDatabaseExists (CheckDatabaseExistsRequest) returns (CheckDatabaseExistsResponse); + rpc ClearDatabase (ClearDatabaseRequest) returns (ClearDatabaseResponse); + // Создать пустую базу + rpc CreateDatabase (CreateDatabaseRequest) returns (CreateDatabaseResponse); + // Удалить базу и очистить реестр + rpc DropDatabase (DropDatabaseRequest) returns (DropDatabaseResponse); +} + +message ClearDatabaseRequest{ + int32 base_id = 1; + uint32 product_id = 2; +} + +message ClearDatabaseResponse{ + bool success = 1; +} + +message CheckDatabaseExistsRequest{ + string name = 1; + uint32 product_id = 2; +} + +message CheckDatabaseExistsResponse{ + bool exists = 1; + optional int32 base_id = 2; +} + +message CreateDatabaseRequest{ + string name = 1; + string title = 2; + uint32 product_id = 3; +} + +message CreateDatabaseResponse{ + int32 base_id = 1; + string base_guid = 2; +} + +message DropDatabaseRequest{ + int32 base_id = 1; + uint32 product_id = 2; +} + +message DropDatabaseResponse{ + bool success = 1; +} diff --git a/QS.Cloud.Client/Protos/LoginManagement.proto b/QS.Cloud.Client/Protos/LoginManagement.proto index 1dd64025d..c7005db71 100644 --- a/QS.Cloud.Client/Protos/LoginManagement.proto +++ b/QS.Cloud.Client/Protos/LoginManagement.proto @@ -53,6 +53,7 @@ message BaseInfo{ int32 base_id = 1; string base_title = 2; string base_version = 3; + string base_name = 4; } //ChangePassword - смена собственного пароля от учетной записи diff --git a/QS.Cloud.Client/Protos/UserManagement.proto b/QS.Cloud.Client/Protos/UserManagement.proto index 3d002dcaf..7498341e8 100644 --- a/QS.Cloud.Client/Protos/UserManagement.proto +++ b/QS.Cloud.Client/Protos/UserManagement.proto @@ -2,23 +2,56 @@ syntax = "proto3"; package QS.Cloud.Core; service UserManagement{ + rpc GetUsers (GetUsersRequest) returns (GetUsersResponse); rpc CreateUser (CreateUserRequest) returns (CreateUserResponse); rpc UpdateUser (UpdateUserRequest) returns (UpdateUserResponse); rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse); + rpc GetUserBaseAccess (GetUserBaseAccessRequest) returns (GetUserBaseAccessResponse); rpc ChangeBaseAccess (ChangeBaseAccessRequest) returns (ChangeBaseAccessResponse); } +message UserInfo{ + string login = 1; + string name = 2; + string email = 3; + string phone = 4; + string post = 5; + string comment = 6; + bool disabled = 7; + bool is_account_admin = 8; +} + +message GetUsersRequest{ +} +message GetUsersResponse{ + repeated UserInfo users = 1; +} + message CreateUserRequest{ string login = 1; string name = 2; string email = 3; string password = 4; + string phone = 5; + string post = 6; + string comment = 7; + bool is_account_admin = 8; } message CreateUserResponse{ bool success = 1; string message = 2; } + message UpdateUserRequest{ + string login = 1; + string name = 2; + string email = 3; + string phone = 4; + string post = 5; + string comment = 6; + bool disabled = 7; + bool is_account_admin = 8; + string new_password = 9; } message UpdateUserResponse{ bool success = 1; @@ -28,18 +61,33 @@ message UpdateUserResponse{ message DeleteUserRequest{ string user = 1; } - message DeleteUserResponse { bool success = 1; string message = 2; } +message GetUserBaseAccessRequest{ + string user = 1; + uint32 product_id = 2; +} +message BaseAccessInfo{ + int32 base_id = 1; + string base_title = 2; + bool has_access = 3; + bool admin = 4; + bool read_only = 5; +} +message GetUserBaseAccessResponse{ + repeated BaseAccessInfo bases = 1; +} + message ChangeBaseAccessRequest{ string user = 1; int32 base_id = 2; bool grant = 3; bool admin = 4; - + bool read_only = 5; + uint32 product_id = 6; } message ChangeBaseAccessResponse { bool success = 1; diff --git a/QS.Cloud.Client/QS.Cloud.Client.csproj b/QS.Cloud.Client/QS.Cloud.Client.csproj index ec2e96649..00dc4c656 100644 --- a/QS.Cloud.Client/QS.Cloud.Client.csproj +++ b/QS.Cloud.Client/QS.Cloud.Client.csproj @@ -18,13 +18,16 @@ + + + diff --git a/QS.Cloud.Client/QSCloudProvider.cs b/QS.Cloud.Client/QSCloudProvider.cs deleted file mode 100644 index c6e33dd8e..000000000 --- a/QS.Cloud.Client/QSCloudProvider.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Grpc.Core; -using MySqlConnector; -using QS.Cloud.Core; -using QS.DbManagement.Responces; -using QS.DbManagement; -using QS.Project.Versioning; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System; - -namespace QS.Cloud.Client -{ - public class QSCloudProvider : IDbProvider { - public string ConnectionString { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public bool IsConnected => throw new NotImplementedException(); - - public bool IsAdmin { get; protected set; } - - #region Параметры подключени - public string Account { get; private set; } - - - #endregion - public string UserName { get; private set; } - - private CloudFeaturesClient featuresClient; - private LoginManagementCloudClient loginClient; - private SessionManagementCloudClient sessionClient; - private UserManagementCloudClient userClient; - - - public QSCloudProvider(IList parameters, string password = null) { - Account = parameters.First(p => p.Name == "Account").Value; - UserName = parameters.First(p => p.Name == "Login").Value; - BasicAuthInfoProvider authInfo = new BasicAuthInfoProvider($@"{Account}\{UserName}", password); - - loginClient = new LoginManagementCloudClient(authInfo); - } - - public bool AddUser(string username, string password) - { - throw new NotImplementedException(); - } - - public bool ChangePassword(string username, string oldPassword, string newPassword) - { - throw new NotImplementedException(); - } - - public bool CreateDatabase(string databaseName) - { - throw new NotImplementedException(); - } - - public void Dispose() - { - loginClient.Dispose(); - } - - public bool DropDatabase(string databaseName) - { - throw new NotImplementedException(); - } - - public List GetUserDatabases(IApplicationInfo applicationInfo) { - return loginClient.GetBasesForUser(applicationInfo.ProductCode).Select(bi => new DbInfo - { - Title = bi.BaseTitle, - BaseId = bi.BaseId, - Version = bi.BaseVersion - }).ToList(); - } - - public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) { - LoginToDatabaseResponse resp; - - try { - var cloudResponse = loginClient.StartSession(dbInfo.BaseId); - var builder = new MySqlConnectionStringBuilder { - Server = cloudResponse.Db.Server, - Port = cloudResponse.Db.Port, - UserID = cloudResponse.Db.Login, - Password = cloudResponse.Db.Password, - Database = cloudResponse.Db.BaseName - }; - resp = new LoginToDatabaseResponse { - Success = cloudResponse.Success, - ConnectionString = builder.ConnectionString, - Login = UserName, - Parameters = new Dictionary() { - {"SessionId", cloudResponse.SessionId}, - {"BaseTitle", dbInfo.Title} - } - }; - } - catch(Exception ex) { - resp = new LoginToDatabaseResponse { - Success = false, - ErrorMessage = ex.Message - }; - } - - return resp; - } - - public LoginToServerResponse LoginToServer() { - LoginToServerResponse resp; - - StartResponse cloudResponce; - try { - cloudResponce = loginClient.Start(Assembly.GetExecutingAssembly().GetName().Version.ToString()); - - resp = new LoginToServerResponse { - Success = true, - IsAdmin = cloudResponce.YouAccountAdmin, - NeedToUpdateLauncher = cloudResponce.NeedUpdateLauncher - }; - } - catch(RpcException ex) when(ex.StatusCode == Grpc.Core.StatusCode.Unauthenticated || ex.StatusCode == Grpc.Core.StatusCode.PermissionDenied) { - resp = new LoginToServerResponse { - Success = false, - ErrorMessage = "Неверные данные для входа: " + ex.Message - }; - } - - return resp; - } - } -} - - diff --git a/QS.DbManagement/Connection.cs b/QS.DbManagement/Connection.cs index 581921fea..3b15565b6 100644 --- a/QS.DbManagement/Connection.cs +++ b/QS.DbManagement/Connection.cs @@ -1,7 +1,9 @@ +using QS.DbManagement.Entities; +using QS.Project.Versioning; +using ReactiveUI; using System; using System.Collections.Generic; using System.Linq; -using ReactiveUI; namespace QS.DbManagement { public class Connection : ReactiveObject, ICloneable { @@ -62,7 +64,7 @@ public Connection(Connection other) { public bool CanConnect() => ConnectionType.CanConnect(CustomParameters); - public IDbProvider CreateProvider(string password) => ConnectionType.CreateProvider(CustomParameters, password); + public IDbProvider CreateProvider(string password, byte productCode) => ConnectionType.CreateProvider(CustomParameters, productCode, password); public Dictionary GetConfigDefinitions() { var config = new Dictionary { {"Type", ConnectionType.ConnectionTypeName}, diff --git a/QS.DbManagement/ConnectionTypeBase.cs b/QS.DbManagement/ConnectionTypeBase.cs index 377ecad56..b902efa39 100644 --- a/QS.DbManagement/ConnectionTypeBase.cs +++ b/QS.DbManagement/ConnectionTypeBase.cs @@ -1,8 +1,11 @@ -using System; +using QS.DbManagement.Entities; using System.Collections.Generic; namespace QS.DbManagement { + /// + /// метаданные, проверка параметров и создание провайдера + /// public abstract class ConnectionTypeBase { public string Title { get; protected set; } public string ConnectionTypeName { get; protected set; } @@ -10,9 +13,9 @@ public abstract class ConnectionTypeBase { public List Parameters { get; } = new List(); public byte[] IconBytes { get; protected set; } - + public abstract bool CanConnect(IEnumerable parameters); - public abstract IDbProvider CreateProvider(IList parameters, string password = null); + public abstract IDbProvider CreateProvider(IList parameters, byte productCode, string password = null); } } diff --git a/QS.DbManagement/Creation/DbCreationFactory.cs b/QS.DbManagement/Creation/DbCreationFactory.cs new file mode 100644 index 000000000..044d4e29d --- /dev/null +++ b/QS.DbManagement/Creation/DbCreationFactory.cs @@ -0,0 +1,24 @@ +using QS.DBScripts.Controllers; +using System; +using System.Reflection; + +namespace QS.DbManagement.Creation { + public class DbCreationFactory + { + private readonly DbResourcesCreationMap map; + + public DbCreationFactory(DbResourcesCreationMap map) { + this.map = map ?? throw new ArgumentNullException(nameof(map)); + } + + public IDbCreatorModel Create(DbCreationResources resources) + { + try { + return (IDbCreatorModel)map.Resolve(resources); + } + catch(TargetInvocationException ex) when(ex.InnerException != null) { + throw ex.InnerException; + } + } + } +} diff --git a/QS.DbManagement/Creation/DbDumpResources.cs b/QS.DbManagement/Creation/DbDumpResources.cs new file mode 100644 index 000000000..5e5e4128f --- /dev/null +++ b/QS.DbManagement/Creation/DbDumpResources.cs @@ -0,0 +1,8 @@ +using QS.DBScripts.Controllers; +using QS.Dialog; + +namespace QS.DbManagement.Creation { + public class DbDumpResources : DbCreationResources { + public string DumpFilePath { get; set; } + } +} diff --git a/QS.DbManagement/Creation/DbResourcesCreationMap.cs b/QS.DbManagement/Creation/DbResourcesCreationMap.cs new file mode 100644 index 000000000..f2c7c14d4 --- /dev/null +++ b/QS.DbManagement/Creation/DbResourcesCreationMap.cs @@ -0,0 +1,34 @@ +using QS.DBScripts.Controllers; +using System; +using System.Collections.Generic; + +namespace QS.DbManagement.Creation { + public class DbResourcesCreationMap + { + private Dictionary> _map = new Dictionary>(); + + public bool Contains(Type target) { + return _map.ContainsKey(target); + } + + public void Register(Type resource, Type creator) + { + if(!typeof(DbCreationResources).IsAssignableFrom(resource)) + throw new ArgumentException($"{resource} не наследует DbCreationResources", nameof(resource)); + + if(!typeof(IDbCreatorModel).IsAssignableFrom(creator)) + throw new ArgumentException($"{creator} не реализует IDbCreatorModel", nameof(creator)); + + _map[resource] = arg => Activator.CreateInstance(creator, arg); + } + + public object Resolve(DbCreationResources arg) + { + if(arg == null) + throw new ArgumentNullException(nameof(arg)); + if(!_map.TryGetValue(arg.GetType(), out var creator)) + throw new InvalidOperationException($"Нет зарегистрированного создателя для ресурса {arg.GetType().Name}"); + return creator(arg); + } + } +} diff --git a/QS.DbManagement/Creation/MariaDbImportModel.cs b/QS.DbManagement/Creation/MariaDbImportModel.cs new file mode 100644 index 000000000..9325e6d05 --- /dev/null +++ b/QS.DbManagement/Creation/MariaDbImportModel.cs @@ -0,0 +1,43 @@ +using MySqlConnector; +using QS.DBScripts.Models; + +namespace QS.DbManagement.Creation { + /// + /// Наполнение MariaDB базы пользовательским дампом. + /// Метод блокирует вызывающий поток — выносить в фон ответственность вызывающего кода. + /// + public class MariaDbImportModel : BaseMySqlDbLoader { + private string DumpFilePath { get; } + + public MariaDbImportModel( + DbDumpResources resources) + : base(resources) { + DumpFilePath = resources.DumpFilePath; + } + + protected override void ExecutScript(MySqlCommand cmd) { + cmd.CommandTimeout = 0; + using(var backup = new MySqlBackup(cmd)) { + // ускоряет дамп + backup.Command.CommandText = "SET SESSION foreign_key_checks = 0, unique_checks = 0;"; + backup.Command.ExecuteNonQuery(); + + bool started = false; + backup.ImportProgressChanged += (sender, args) => { + if(cancellationToken.IsCancellationRequested) { + ((MySqlBackup)sender).StopAllProcess(); + return; + } + if(!started) { + + logger.Debug("Предполагаем наличие {0} команд в скрипте.", args.TotalBytes); + progress?.Start(maxValue: args.TotalBytes, text: "Импорт дампа в базу данных"); + started = true; + } + progress?.Update(args.CurrentBytes); + }; + backup.ImportFromFile(DumpFilePath); + } + } + } +} diff --git a/QS.DbManagement/DbCapabilities.cs b/QS.DbManagement/DbCapabilities.cs new file mode 100644 index 000000000..97bb3b167 --- /dev/null +++ b/QS.DbManagement/DbCapabilities.cs @@ -0,0 +1,38 @@ +using QS.DbManagement.Creation; +using QS.DBScripts; +using QS.DBScripts.Models; + +namespace QS.DbManagement { + /// + /// комбинирует права пользователя с конфигурацией приложения + /// + public class DbCapabilities { + private readonly IDbScriptsConfiguration scripts; + private readonly DbResourcesCreationMap creationMap; + public DbCapabilities(IDbScriptsConfiguration scripts = null, DbResourcesCreationMap creationMap = null) { + this.scripts = scripts; + this.creationMap = creationMap; + } + + /// Провайдер отвечает за то, что разрешает сервер + public DbCapabilitySet For(IDbProvider provider) { + if(provider == null) + return DbCapabilitySet.None; + + return new DbCapabilitySet { + CanCreate = provider.CanCreateDatabase + && scripts?.HasCreationScript() == true + && creationMap != null + && creationMap.Contains(typeof(EmbeddedCreationResources)), + CanImport = provider.CanCreateDatabase + && creationMap != null + && creationMap.Contains(typeof(DbDumpResources)), + CanDrop = provider.CanDropDatabase, + CanBackup = provider.CanBackupDatabase, + CanRefreshMetadata = provider.CanRefreshMetadata, + CanChangeOwnPassword = provider.CanChangeOwnPassword, + CanManageUsers = provider.CanManageUsers + }; + } + } +} diff --git a/QS.DbManagement/DbCapabilitySet.cs b/QS.DbManagement/DbCapabilitySet.cs new file mode 100644 index 000000000..8980f7cdf --- /dev/null +++ b/QS.DbManagement/DbCapabilitySet.cs @@ -0,0 +1,30 @@ +namespace QS.DbManagement { + /// собирается один раз на смену провайдера и дальше не меняется + public class DbCapabilitySet { + /// Ничего не доступно - состояние до входа на сервер + public static readonly DbCapabilitySet None = new DbCapabilitySet(); + + /// Создание базы из встроенного скрипта + public bool CanCreate { get; internal set; } + + /// Наполнение новой базы дампом + public bool CanImport { get; internal set; } + + public bool CanDrop { get; internal set; } + + public bool CanBackup { get; internal set; } + + /// Пересборка локальной метаинформации по реальному состоянию сервера + public bool CanRefreshMetadata { get; internal set; } + + public bool CanChangeOwnPassword { get; internal set; } + + public bool CanManageUsers { get; internal set; } + + /// Есть ли что показать в меню строки списка баз + public bool CanManageDatabases => CanDrop || CanBackup; + + /// Есть ли что показать в меню операций с базами + public bool CanOpenDbOperations => CanCreate || CanImport || CanRefreshMetadata; + } +} diff --git a/QS.DbManagement/Entities/BaseUserRow.cs b/QS.DbManagement/Entities/BaseUserRow.cs new file mode 100644 index 000000000..03dd56d57 --- /dev/null +++ b/QS.DbManagement/Entities/BaseUserRow.cs @@ -0,0 +1,9 @@ +namespace QS.DbManagement.Entities { + internal class BaseUserRow { + public string Login { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public bool Admin { get; set; } + public bool Deactivated { get; set; } + } +} diff --git a/QS.DbManagement/ConnectionParameter.cs b/QS.DbManagement/Entities/ConnectionParameter.cs similarity index 85% rename from QS.DbManagement/ConnectionParameter.cs rename to QS.DbManagement/Entities/ConnectionParameter.cs index ddbaaa231..4498b2f8d 100644 --- a/QS.DbManagement/ConnectionParameter.cs +++ b/QS.DbManagement/Entities/ConnectionParameter.cs @@ -1,4 +1,4 @@ -namespace QS.DbManagement +namespace QS.DbManagement.Entities { public class ConnectionParameter { diff --git a/QS.DbManagement/ConnectionParameterValue.cs b/QS.DbManagement/Entities/ConnectionParameterValue.cs similarity index 94% rename from QS.DbManagement/ConnectionParameterValue.cs rename to QS.DbManagement/Entities/ConnectionParameterValue.cs index 18a24b8f6..11bf825f3 100644 --- a/QS.DbManagement/ConnectionParameterValue.cs +++ b/QS.DbManagement/Entities/ConnectionParameterValue.cs @@ -1,7 +1,7 @@ using System; using ReactiveUI; -namespace QS.DbManagement { +namespace QS.DbManagement.Entities { public class ConnectionParameterValue : ReactiveObject { private readonly ConnectionParameter parameter; diff --git a/QS.DbManagement/Entities/DbCreationPhase.cs b/QS.DbManagement/Entities/DbCreationPhase.cs new file mode 100644 index 000000000..f7e35be5c --- /dev/null +++ b/QS.DbManagement/Entities/DbCreationPhase.cs @@ -0,0 +1,16 @@ +using System; + +namespace QS.DbManagement.Entities { + /// + /// Один шаг пайплайна длительной операции с базами - создания, импорта, синхронизации + /// + public sealed class DbCreationPhase { + public string Title { get; } + public Func Action { get; } + + public DbCreationPhase(string title, Func action) { + Title = title ?? throw new ArgumentNullException(nameof(title)); + Action = action ?? throw new ArgumentNullException(nameof(action)); + } + } +} diff --git a/QS.DbManagement/Entities/DbCreationRequest.cs b/QS.DbManagement/Entities/DbCreationRequest.cs new file mode 100644 index 000000000..7fc98db69 --- /dev/null +++ b/QS.DbManagement/Entities/DbCreationRequest.cs @@ -0,0 +1,16 @@ +using QS.DbManagement.Creation; +using QS.DBScripts.Controllers; + +namespace QS.DbManagement.Entities { + public sealed class DbCreationRequest { + public string DbName { get; set; } + public string DbTitle { get; set; } + + public IDbCreatorInteraction Interaction { get; set; } + + /// Чем наполнять созданную базу + public DbCreationFactory CreationFactory { get; set; } + + public DbCreationResources CreationResources { get; set; } + } +} diff --git a/QS.DbManagement/DbInfo.cs b/QS.DbManagement/Entities/DbInfo.cs similarity index 82% rename from QS.DbManagement/DbInfo.cs rename to QS.DbManagement/Entities/DbInfo.cs index 3fd58f347..d9e54c7ad 100644 --- a/QS.DbManagement/DbInfo.cs +++ b/QS.DbManagement/Entities/DbInfo.cs @@ -1,4 +1,4 @@ -namespace QS.DbManagement { +namespace QS.DbManagement.Entities { public class DbInfo { public string Title { get; set; } public string BaseName { get; set; } diff --git a/QS.DbManagement/Entities/DbPhaseArgs.cs b/QS.DbManagement/Entities/DbPhaseArgs.cs new file mode 100644 index 000000000..4471fdf55 --- /dev/null +++ b/QS.DbManagement/Entities/DbPhaseArgs.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using QS.Dialog; + +namespace QS.DbManagement.Entities { + /// + /// Контекст выполнения одной фазы операциии + /// + public class DbPhaseArgs { + public IDbManager Provider { get; set; } + public IProgressBarDisplayable Progress { get; set; } + public CancellationToken CancellationToken { get; set; } + public IServiceProvider ServiceProvider { get; set; } + public string FailureReason { get; set; } + } +} diff --git a/QS.DbManagement/Entities/DbUserBaseAccess.cs b/QS.DbManagement/Entities/DbUserBaseAccess.cs new file mode 100644 index 000000000..5c6d21c63 --- /dev/null +++ b/QS.DbManagement/Entities/DbUserBaseAccess.cs @@ -0,0 +1,20 @@ +namespace QS.DbManagement.Entities { + public class DbUserBaseAccess { + /// Идентификатор базы в облаке + public int BaseId { get; set; } + + /// Имя базы на сервере + public string BaseName { get; set; } + + public string Title { get; set; } + + public bool HasAccess { get; set; } + + public bool IsAdmin { get; set; } + + public bool ReadOnly { get; set; } + public bool CanEdit { get; set; } = true; + public string Name { get; set; } + public string Email { get; set; } + } +} diff --git a/QS.DbManagement/Entities/DbUserInfo.cs b/QS.DbManagement/Entities/DbUserInfo.cs new file mode 100644 index 000000000..91790d52f --- /dev/null +++ b/QS.DbManagement/Entities/DbUserInfo.cs @@ -0,0 +1,33 @@ +using System; + +namespace QS.DbManagement.Entities { + public class DbUserInfo { + public string Login { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public string Post { get; set; } + public string Comment { get; set; } + + /// не может входить + public bool Disabled { get; set; } + + /// может управлять другими пользователями + public bool IsAdmin { get; set; } + /// текущий пользователь подключения + public bool IsCurrentUser { get; set; } + } + + [Flags] + public enum DbUserFields { + None = 0, + Name = 1, + Email = 2, + Phone = 4, + Post = 8, + Comment = 16, + AdminFlag = 32, + Disabling = 64, + BaseReadOnly = 128 + } +} diff --git a/QS.DbManagement/Entities/LauncherUserInfo.cs b/QS.DbManagement/Entities/LauncherUserInfo.cs new file mode 100644 index 000000000..fa7ad24c9 --- /dev/null +++ b/QS.DbManagement/Entities/LauncherUserInfo.cs @@ -0,0 +1,12 @@ +namespace QS.DbManagement.Entities { + internal class LauncherUserInfo { + public int Id { get; set; } + public string Login { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + + public bool IsAdmin { get; set; } + public bool Disabled { get; set; } + } +} diff --git a/QS.DbManagement/ProviderResponces.cs b/QS.DbManagement/Entities/ProviderResponces.cs similarity index 68% rename from QS.DbManagement/ProviderResponces.cs rename to QS.DbManagement/Entities/ProviderResponces.cs index d354ac0cd..235ee34dd 100644 --- a/QS.DbManagement/ProviderResponces.cs +++ b/QS.DbManagement/Entities/ProviderResponces.cs @@ -1,24 +1,19 @@ using System.Collections.Generic; -namespace QS.DbManagement.Responces +namespace QS.DbManagement.Entities { public class Response { public bool Success { get; set; } public string ErrorMessage { get; set; } } - public class LoginToServerResponse : Response { - public bool CanCreateDatabase { get; set; } - public bool IsAdmin { get; set; } + public class LoginToServerResponse : Response + { public bool NeedToUpdateLauncher { get; set; } } public class ChangePasswordResponse : Response { } - public class CreateDatabaseResponse : Response { } - - public class DropDatabaseResponse : Response { } - public class AddUserResponse : Response { } public class DeleteUserResponse : Response { } @@ -27,6 +22,12 @@ public class GetUserDatabasesResponse : Response { public List Bases { get; set; } } + /// счётчики синхронизированных + public class RefreshMetadataResponse : Response { + public int SyncedBases { get; set; } + public int SyncedUsers { get; set; } + } + public class LoginToDatabaseResponse : Response { public string ConnectionString { get; set; } public string Login { get; set; } diff --git a/QS.DbManagement/IDbProvider.cs b/QS.DbManagement/IDbProvider.cs index da6ea6964..30b822821 100644 --- a/QS.DbManagement/IDbProvider.cs +++ b/QS.DbManagement/IDbProvider.cs @@ -1,30 +1,84 @@ -using QS.DbManagement.Responces; +using QS.Dialog; using QS.Project.Versioning; using System.Collections.Generic; +using System.Threading; using System; +using QS.DbManagement.Entities; namespace QS.DbManagement { - public interface IDbProvider : IDisposable + public interface IDbManager { string UserName { get; } - - bool ChangePassword(string username, string oldPassword, string newPassword); - - bool CreateDatabase(string databaseName); - - bool DropDatabase(string databaseName); - - bool AddUser(string username, string password); + + bool IsAdmin { get; } + bool CanCreateDatabase { get; } + bool CanDropDatabase { get; } + + /// умеет ли провайдер выгружать базу в файл + bool CanBackupDatabase { get; } + + bool CanRefreshMetadata { get; } LoginToServerResponse LoginToServer(); - List GetUserDatabases(IApplicationInfo applicationInfo); + RefreshMetadataResponse RefreshMetadata(); + + RefreshMetadataResponse RefreshBases(); + + RefreshMetadataResponse RefreshUsers(); + + List GetUserDatabases(); LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo); - - bool IsConnected { get; } - bool IsAdmin { get; } + /// + /// Создаёт базу и сразу наполняет её + /// + bool CreateDatabase(DbCreationRequest request); + + bool DropDatabase(DbInfo database); + + void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation); + } + + /// + /// Управление пользователями сервера и собственным паролем. + /// + public interface IDbUserManager + { + bool ChangeOwnPassword(string newPassword); + + /// может ли пользователь сменить себе пароль средствами провайдера + bool CanChangeOwnPassword { get; } + + bool CanManageUsers { get; } + + bool CanManageBaseAccess { get; } + + /// + /// Какие поля пользователя и виды доступа поддерживает провайдер + /// + DbUserFields SupportedUserFields { get; } + + List GetUsers(); + + bool CreateUser(DbUserInfo user, string password); + + bool UpdateUser(DbUserInfo user, string newPassword = null); + + bool DeleteUser(string login); + + /// + /// Список баз продукта с текущим доступом указанного пользователя + /// + List GetUserBaseAccess(string login); + + /// + /// меняет доступ пользователя к базе согласно флагам + /// + bool SetUserBaseAccess(string login, DbUserBaseAccess access); } + + public interface IDbProvider : IDbManager, IDbUserManager, IDisposable {} } diff --git a/QS.DbManagement/MariaDb/BaseParametersReader.cs b/QS.DbManagement/MariaDb/BaseParametersReader.cs new file mode 100644 index 000000000..459f1a55b --- /dev/null +++ b/QS.DbManagement/MariaDb/BaseParametersReader.cs @@ -0,0 +1,93 @@ +using Dapper; +using NLog; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; + +namespace QS.DbManagement.MariaDb { + internal static class BaseParametersReader { + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + + private const string ParametersTable = "base_parameters"; + private const string BaseNameColumn = "BaseName"; + private const string Projection = "`name` AS Name, `str_value` AS StrValue"; + private const int DefaultBatchSize = 50; + + /// + /// Параметры перечисленных баз. Базы без таблицы base_parameters и базы, которые прочитать не удалось, в результат не попадают + /// + /// какие параметры нужны, null - все + /// base_name -> (name -> str_value) + public static Dictionary> ReadMany( + IDbConnection connection, IEnumerable databases, + IEnumerable names = null, int batchSize = DefaultBatchSize) { + if(connection == null) + throw new ArgumentNullException(nameof(connection)); + if(batchSize < 1) + throw new ArgumentOutOfRangeException(nameof(batchSize), "Размер пачки должен быть положительным"); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var wanted = MySqlMultiBase.Distinct(databases); + if(wanted.Count == 0) + return result; + + var nameFilter = names?.ToList(); + var withTable = MySqlMultiBase.DatabasesWithTable(connection, wanted, ParametersTable); + + for(int offset = 0; offset < withTable.Count; offset += batchSize) { + var batch = withTable.Skip(offset).Take(batchSize).ToList(); + try { + ReadBatch(connection, batch, nameFilter, result); + } + catch(DbException ex) { + logger.Warn(ex, "Пакетное чтение {0} не удалось, читаем базы по одной", ParametersTable); + foreach(var database in batch) + ReadOne(connection, database, nameFilter, result); + } + } + + return result; + } + + private static void ReadBatch(IDbConnection connection, IEnumerable databases, + IList names, IDictionary> result) { + var parameters = new DynamicParameters(); + var projections = databases.Select(db => new KeyValuePair(db, Projection)); + string union = MySqlMultiBase.UnionAll(projections, ParametersTable, BaseNameColumn, null, parameters); + + // имена фильтруем снаружи, одним условием на весь запрос, а не по разу в каждой ветке + string sql = $"SELECT * FROM ({union}) p"; + if(names != null && names.Count > 0) { + sql += " WHERE p.Name IN @names"; + parameters.Add("names", names); + } + + foreach(var row in connection.Query(sql, parameters)) { + if(!result.TryGetValue(row.BaseName, out var byName)) { + byName = new Dictionary(); + result[row.BaseName] = byName; + } + byName[row.Name] = row.StrValue; + } + } + + private static void ReadOne(IDbConnection connection, string database, + IList names, IDictionary> result) { + try { + ReadBatch(connection, new[] { database }, names, result); + } + catch(DbException ex) { + logger.Debug(ex, "Не удалось прочитать {0} в базе {1}", ParametersTable, database); + } + } + + /// Строка base_parameters вместе с базой, из которой она приехала + private sealed class ParameterRow { + public string BaseName { get; set; } + public string Name { get; set; } + public string StrValue { get; set; } + } + } +} diff --git a/QS.DbManagement/MariaDb/MariaDBProvider.cs b/QS.DbManagement/MariaDb/MariaDBProvider.cs index b0343a132..3302c885d 100644 --- a/QS.DbManagement/MariaDb/MariaDBProvider.cs +++ b/QS.DbManagement/MariaDb/MariaDBProvider.cs @@ -1,90 +1,288 @@ using Dapper; using MySqlConnector; -using QS.DbManagement.Responces; -using QS.Project.Versioning; +using QS.DbManagement.Entities; +using QS.DbManagement.MariaDb; +using QS.DbManagement.MariaDb.QSLauncher; +using QS.DBScripts.Controllers; +using QS.Dialog; +using QS.ErrorReporting; using System; using System.Collections.Generic; using System.Data; using System.Linq; +using System.Threading; -namespace QS.DbManagement -{ +namespace QS.DbManagement { public class MariaDBProvider : IDbProvider { private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - private static readonly string[] SystemDatabases = { "information_schema", "mysql", "performance_schema", "sys" }; + private const string MessageTitle = "Создание базы данных"; + private const string AnyHost = "%"; + private const string LocalHost = "localhost"; + private const int ER_NONEXISTING_GRANT = 1141; - readonly MySqlConnection connection; - readonly MySqlConnectionStringBuilder connectionStringBuilder; + private MySqlConnection connection; - public bool IsConnected => connection.State == ConnectionState.Open; + /// + /// Публичный - в типе подключения нужен доступ, реализацию он знает и так + /// + public MySqlConnectionStringBuilder ConnectionStringBuilder { get; } public bool IsAdmin { get; private set; } - /// - /// Есть ли у текущего пользователя право создавать базы данных. - /// Определяется в момент из SHOW GRANTS. - /// public bool CanCreateDatabase { get; private set; } + public bool CanDropDatabase { get; private set; } + public bool CanManageBaseAccess { get; private set; } #region Параметры подключения public string Server { get; } public string UserName { get; } - private readonly string password; + public byte ProductCode { get; } #endregion - public MariaDBProvider(IList parameters, string password = null) { + public MariaDBProvider(IList parameters, byte productCode, string password = null) { if(parameters == null) throw new ArgumentNullException(nameof(parameters)); - string serverValue = parameters.First(p => p.Name == "Server").Value; + Server = parameters.First(p => p.Name == "Server").Value; UserName = parameters.First(p => p.Name == "Login").Value; - this.password = password; + ProductCode = productCode; - string host = serverValue; - uint? port = null; - if(serverValue.Contains(":")) { - var parts = serverValue.Split(':'); - host = parts[0]; - if(uint.TryParse(parts[1], out var parsedPort)) - port = parsedPort; - } - Server = serverValue; - - connectionStringBuilder = new MySqlConnectionStringBuilder { - Server = host, + var address = Server.Split(':'); + ConnectionStringBuilder = new MySqlConnectionStringBuilder { + Server = address[0], UserID = UserName, Password = password, AllowUserVariables = true }; - if(port != null) - connectionStringBuilder.Port = port.Value; - connection = new MySqlConnection(connectionStringBuilder.ConnectionString); + if(address.Length > 1 && uint.TryParse(address[1], out var port)) + ConnectionStringBuilder.Port = port; + + connection = new MySqlConnection(ConnectionStringBuilder.ConnectionString); + } + + #region QSLauncher + + public bool CanRefreshMetadata => IsAdmin; + public bool CanBackupDatabase { get; } = true; + + private LauncherMetadataManagement metadata; + /// false - собрать метабазу ещё не пробовали; true - пробовали, и повторять не будем + private bool metadataMade; + private bool serverLoggedIn; + + private LauncherMetadataManagement Metadata + { + get + { + // до логина права ещё не посчитаны - собранная сейчас метабаза запомнила бы их заниженными + if(!serverLoggedIn) + return null; + + // один раз не собралась - больше не пробуем + if(metadataMade) + return metadata; + + try + { + metadata = new LauncherMetadataManagement( + new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString), + IsAdmin, UserName, ProductCode); + } + catch(Exception ex) when( + ex is MySqlException //базы нет, нет прав, сервер недоступен + || ex is InvalidOperationException|| ex is KeyNotFoundException //схема не та, что можно читать + || ex is ArgumentException + ) + { + logger.Debug(ex, "QSLauncher база недоступна, используем прямой доступ к серверу"); + } + metadataMade = true; + return metadata; + } + } + + private T FromMetadataOrDirect(Func fromMetadata, Func direct) + { + var launcher = Metadata; + if(launcher != null) + { + try + { + return fromMetadata(launcher); + } + catch(Exception ex) + { + logger.Debug(ex, "Не удалось прочитать из метабазы, работаем напрямую с сервером."); + } + } + return direct(); + } + + // Синхронизация таблицы users без привязки к базе + private BaseUsersManagement baseUsers; + private BaseUsersManagement BaseUsers + => baseUsers ?? (baseUsers = new BaseUsersManagement(new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString))); + + public RefreshMetadataResponse RefreshMetadata() + { + var bases = RefreshBases(); + if(!bases.Success) + return bases; + + var users = RefreshUsers(); + if(!users.Success) + return users; + + return new RefreshMetadataResponse + { + Success = true, + SyncedBases = bases.SyncedBases, + SyncedUsers = users.SyncedUsers + }; } - #region IDbProvider + public RefreshMetadataResponse RefreshBases() + { + var launcher = Metadata; + if(launcher == null) + return MetadataUnavailable(); + + return new RefreshMetadataResponse + { + Success = true, + SyncedBases = launcher.Bases.SyncBases() + }; + } + + public RefreshMetadataResponse RefreshUsers() + { + var launcher = Metadata; + if(launcher == null) + return MetadataUnavailable(); + + var users = GetUsersDirect().ConvertAll(ToLauncherUser); + return new RefreshMetadataResponse + { + Success = true, + SyncedUsers = launcher.Users.SyncUsers(users) + }; + } + + private static RefreshMetadataResponse MetadataUnavailable() => + new RefreshMetadataResponse + { + Success = false, + ErrorMessage = $"База {LauncherMetadataManagement.LauncherBaseName} недоступна, " + + "синхронизировать метаинформацию некуда." + }; + + private void RegisterInLauncherMetadata(DbCreationRequest request) + { + try + { + Metadata?.CreateBaseWithCreatorAccess(new DbInfo { + Title = request.DbTitle, + BaseName = request.DbName, + Version = ReadBaseVersion(request.DbName) + }); + } + catch(Exception ex) + { + logger.Warn(ex, "Не удалось зарегистрировать базу {0} в метабазе QSLauncher.", request.DbName); + } + } + + /// + /// Версию база сообщает о себе сама: её кладёт в base_parameters дамп, которым базу наполнили. + /// null - параметра в базе нет, каталог обновится ближайшей синхронизацией + /// + private string ReadBaseVersion(string dbName) { + var parameters = OnConnection(c => + BaseParametersReader.ReadMany(c, new[] { dbName }, new[] { VersionParameter })); + + return parameters.TryGetValue(dbName, out var byName) + && byName.TryGetValue(VersionParameter, out var version) + ? version + : null; + } + + #region Отображение сущностей метабазы в публичные + + private DbUserInfo ToDbUserInfo(LauncherUserInfo u) => new DbUserInfo + { + Login = u.Login, + Name = u.Name, + Email = u.Email, + Phone = u.Phone, + Disabled = u.Disabled, + IsAdmin = u.IsAdmin, + IsCurrentUser = string.Equals(u.Login, UserName, StringComparison.OrdinalIgnoreCase) + }; + + private static LauncherUserInfo ToLauncherUser(DbUserInfo u) => new LauncherUserInfo + { + Login = u.Login, + Name = u.Name, + Email = u.Email, + Phone = u.Phone, + Disabled = u.Disabled, + IsAdmin = u.IsAdmin + }; + + #endregion + + private void ReflectInMetadata(Action action, string operation, string subject) + { + var launcher = Metadata; + if(launcher == null) + return; + try + { + action(launcher); + } + catch(Exception ex) + { + logger.Warn(ex, "Не удалось отразить {0} ({1}) в метабазе.", operation, subject); + } + } + + private void ReflectUserUpdateInMetadata(DbUserInfo user) + { + ReflectInMetadata(m => { + var target = m.Users.GetUserByLogin(user.Login); + if(target == null) + return; + var launcherUser = ToLauncherUser(user); + launcherUser.Id = target.Id; + m.Users.UpdateUser(launcherUser); + }, "обновление", user.Login); + } + + #endregion public LoginToServerResponse LoginToServer() { try { - if(connection.State != ConnectionState.Open) - connection.Open(); + var grants = OnConnection(c => c.Query("SHOW GRANTS FOR CURRENT_USER").ToList()); + + IsAdmin = MySqlGrants.HasGlobalAdmin(grants); + CanManageBaseAccess = MySqlGrants.HasGlobalGrantOption(grants); - var grants = connection.Query("SHOW GRANTS FOR CURRENT_USER").ToList(); + var privileges = new HashSet(grants + .Where(g => MySqlGrants.Scope(g) == "*") + .SelectMany(MySqlGrants.Privileges), StringComparer.Ordinal); - IsAdmin = grants.Any(g => - g.IndexOf("ALL PRIVILEGES ON *.*", StringComparison.OrdinalIgnoreCase) >= 0 - || g.IndexOf("GRANT OPTION", StringComparison.OrdinalIgnoreCase) >= 0 - || g.IndexOf("SUPER", StringComparison.OrdinalIgnoreCase) >= 0); + CanCreateDatabase = IsAdmin || HasPrivilege(privileges, "CREATE"); + CanDropDatabase = IsAdmin || HasPrivilege(privileges, "DROP"); - CanCreateDatabase = IsAdmin || grants.Any(g => - g.IndexOf("ALL PRIVILEGES", StringComparison.OrdinalIgnoreCase) >= 0 - || g.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) >= 0); + // права пересчитаны - метабазу, если её уже собирали, пересоберём с новыми + metadata = null; + metadataMade = false; + serverLoggedIn = true; return new LoginToServerResponse { Success = true, - IsAdmin = IsAdmin, - NeedToUpdateLauncher = false, - CanCreateDatabase = CanCreateDatabase + NeedToUpdateLauncher = false }; } catch(MySqlException ex) { @@ -96,59 +294,61 @@ public LoginToServerResponse LoginToServer() { } } - public List GetUserDatabases(IApplicationInfo applicationInfo) { - var result = new List(); + private static bool HasPrivilege(ICollection privileges, string privilege) + => privileges.Contains(MySqlAccess.AllPrivileges) || privileges.Contains(privilege); - if(connection.State != ConnectionState.Open) - connection.Open(); + #region Управление базами - var databases = connection.Query("SHOW DATABASES").ToList(); - byte expectedProductCode = applicationInfo.ProductCode; - - foreach(var dbName in databases.Except(SystemDatabases, StringComparer.OrdinalIgnoreCase)) { - byte? productCode = null; - string version = null; - string title = null; - try { - var rows = connection.Query<(string name, string str_value)>( - $"SELECT name, str_value FROM `{dbName}`.base_parameters WHERE name IN ('ProductCode', 'version', 'BaseTitle')").ToList(); - foreach(var row in rows) { - if(string.Equals(row.name, "ProductCode", StringComparison.OrdinalIgnoreCase)) - productCode = Convert.ToByte(row.str_value); - else if(string.Equals(row.name, "version", StringComparison.OrdinalIgnoreCase)) - version = row.str_value; - else if(string.Equals(row.name, "BaseTitle", StringComparison.OrdinalIgnoreCase)) - title = row.str_value; - } - } - catch(MySqlException ex) { - logger.Debug(ex, "Не удалось прочитать base_parameters в базе {0}", dbName); - continue; - } + public List GetUserDatabases() { + return FromMetadataOrDirect( + metadata => metadata.Bases.GetBases().ToList(), + () => GetUserDatabasesDirect()); + } - if((expectedProductCode != null) - && (productCode != expectedProductCode)) - continue; + private const string VersionParameter = "version"; + private static readonly string[] DbInfoParameters = { "ProductCode", "BaseTitle", VersionParameter }; - result.Add(new DbInfo { - BaseName = dbName, - Title = title ?? dbName, - Version = version - }); - } + private List GetUserDatabasesDirect() { + var names = OnConnection(c => c.Query("SHOW DATABASES").ToList()) + .Except(MySqlSystemObjects.Databases, StringComparer.OrdinalIgnoreCase) + .ToList(); - return result; + // одним запросом по серверному соединению: подключение к каждой базе - это ещё один пул + var parameters = OnConnection(c => BaseParametersReader.ReadMany(c, names, DbInfoParameters)); + + return names + .Select(dbName => ToDbInfo(dbName, parameters)) + .Where(db => db != null) + .ToList(); + } + + /// null - база не от нашего продукта либо её параметры прочитать не вышло + private DbInfo ToDbInfo(string dbName, IReadOnlyDictionary> byDatabase) { + if(!byDatabase.TryGetValue(dbName, out var parameters)) + return null; + + if(!parameters.TryGetValue("ProductCode", out var code) || !byte.TryParse(code, out var baseProduct) + || baseProduct != ProductCode) + return null; + + return new DbInfo { + BaseName = dbName, + Title = parameters.TryGetValue("BaseTitle", out var title) ? title : dbName, + Version = parameters.TryGetValue(VersionParameter, out var version) ? version : null + }; } public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) { try { - connectionStringBuilder.Database = dbInfo.BaseName; + var toDatabase = new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString) { + Database = dbInfo.BaseName + }; return new LoginToDatabaseResponse { Success = true, - ConnectionString = connectionStringBuilder.ConnectionString, + ConnectionString = toDatabase.ConnectionString, Login = UserName, - Parameters = new Dictionary { + Parameters = new Dictionary(StringComparer.Ordinal) { { "BaseTitle", dbInfo.Title } } }; @@ -161,29 +361,501 @@ public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) { } } - public bool AddUser(string username, string password) { - string sql = $"CREATE USER IF NOT EXISTS '{username}' IDENTIFIED BY '{password}'"; - return connection.Execute(sql) != 0; + public bool CreateDatabase(DbCreationRequest request) { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + if(!PrepareEmptyDatabase(request)) + return false; + + var connectionStringBuilder = new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString) { + Database = request.DbName + }; + request.CreationResources.ConnectionString = connectionStringBuilder.ConnectionString; + request.CreationResources.JustCreated = true; + var creationModel = request.CreationFactory.Create(request.CreationResources); //? users теперь не ответственность модели наполнения + if(!creationModel.RunCreation(request.DbName, request.DbTitle)) + return false; + + RegisterInLauncherMetadata(request); + return true; } - public bool ChangePassword(string username, string oldPassword, string newPassword) { - string sql = $"ALTER USER '{username}'@'%' IDENTIFIED BY '{newPassword}'"; - return connection.Execute(sql) != 0; + /// + /// Готовит пустую базу под наполнение + /// false - пользователь отказался что-либо делать с существующей базой + /// + private bool PrepareEmptyDatabase(DbCreationRequest request) { + if(!DoesDatabaseExist(request.DbName)) { + CreateEmptyDatabase(request.DbName); + return true; + } + + switch(request.Interaction.AskDropExistingDatabase(request.DbName)) { + case ToDoWithExistingDatabase.Recreate: + if(!DropDatabase(new DbInfo { BaseName = request.DbName })) { + request.Interaction.ReportError("Не удалось удалить существующую базу: " + request.DbName, MessageTitle); + return false; + } + break; + case ToDoWithExistingDatabase.Rewrite: + // базу сносимp + // записи метабазы и выданные на базу права должны пережить перезапись + OnConnection(c => c.Execute($"DROP DATABASE IF EXISTS `{MySqlEscape.Identifier(request.DbName)}`")); + break; + default: // Nothing + return false; + } + + CreateEmptyDatabase(request.DbName); + return true; } - public bool CreateDatabase(string databaseName) { - string sql = $"CREATE DATABASE IF NOT EXISTS `{databaseName}`"; - return connection.Execute(sql) != 0; + private void CreateEmptyDatabase(string dbName) => + OnConnection(c => c.Execute($"CREATE DATABASE `{MySqlEscape.Identifier(dbName)}`")); + + private bool DoesDatabaseExist(string dbName) => + OnConnection(c => c.ExecuteScalar( + "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = @name;", + new { name = dbName })) > 0; + + public bool DropDatabase(DbInfo database) { + OnConnection(c => c.Execute($"DROP DATABASE IF EXISTS `{MySqlEscape.Identifier(database.BaseName)}`")); + CleanDatabasePrivileges(database.BaseName); + + ReflectInMetadata(m => m.Bases.SyncWithDelete(database), "удаление базы", database.BaseName); + + return true; } - public bool DropDatabase(string databaseName) { - string sql = $"DROP DATABASE IF EXISTS `{databaseName}`"; - return connection.Execute(sql) != 0; + private void CleanDatabasePrivileges(string dbName) { + var names = new[] { dbName, dbName.Replace("_", "\\_").Replace("%", "\\%") } + .Distinct(StringComparer.Ordinal).ToArray(); + try { + OnConnection(c => c.Execute( + "DELETE FROM mysql.db WHERE Db IN @names;" + + "DELETE FROM mysql.tables_priv WHERE Db IN @names;" + + "DELETE FROM mysql.columns_priv WHERE Db IN @names;" + + "DELETE FROM mysql.procs_priv WHERE Db IN @names;" + + "FLUSH PRIVILEGES;", + new { names })); + } + catch(MySqlException ex) { + // у текущего пользователя может не быть прав на mysql.* + logger.Warn(ex, "Не удалось вычистить права удалённой базы {0}.", dbName); + } } - public void Dispose() { - connection?.Dispose(); + /// + /// Резервное копирование базы в скрипт + /// Метод блокирующий - вызывать из фонового потока + /// + public void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation) { + new MariaDbExportService().Export(ConnectionStringBuilder, database.BaseName, filePath, progress, cancellation); } + #endregion + + #region Управление пользователями + + public DbUserFields SupportedUserFields => + DbUserFields.BaseReadOnly + | DbUserFields.Name | DbUserFields.Email | DbUserFields.Phone + | (CanManageUsers && SupportsAccountLock ? DbUserFields.Disabling : DbUserFields.None) + | (SupportsAdminFlag ? DbUserFields.AdminFlag : DbUserFields.None); + + public bool CanManageUsers => IsAdmin; + + private bool SupportsAdminFlag => CanManageUsers && CanManageBaseAccess; + + private static readonly string[] NewUserHosts = { AnyHost, LocalHost }; + + private readonly Dictionary> userHosts = new Dictionary>(StringComparer.Ordinal); + + private enum AccountLockStorage { + /// Сервер блокировку не поддерживает + Unsupported, + /// колонка mysql.user.account_locked + UserColumn, + /// JSON в mysql.global_priv, в mysql.user такой колонки нет + GlobalPriv + } + + private AccountLockStorage? accountLockStorage; + private AccountLockStorage AccountLocks { + get { + if(accountLockStorage == null) + accountLockStorage = DetectAccountLockStorage(); + return accountLockStorage.Value; + } + } + + private AccountLockStorage DetectAccountLockStorage() { + bool hasUserColumn = OnConnection(c => c.ExecuteScalar( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + + "WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME = 'account_locked'")) > 0; + if(hasUserColumn) + return AccountLockStorage.UserColumn; + + bool hasGlobalPriv = OnConnection(c => c.ExecuteScalar( + "SELECT COUNT(*) FROM information_schema.TABLES " + + "WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'global_priv'")) > 0; + + return hasGlobalPriv ? AccountLockStorage.GlobalPriv : AccountLockStorage.Unsupported; + } + + private bool SupportsAccountLock => AccountLocks != AccountLockStorage.Unsupported; + public bool CanChangeOwnPassword { get; } = true; + + public bool ChangeOwnPassword(string newPassword) { + if(string.IsNullOrEmpty(newPassword)) + throw new ArgumentException("Пароль не может быть пустым", nameof(newPassword)); + OnConnection(c => c.Execute($"SET PASSWORD = PASSWORD('{MySqlHelper.EscapeString(newPassword)}')")); + + ConnectionStringBuilder.Password = newPassword; + connection = new MySqlConnection(ConnectionStringBuilder.ConnectionString); + + return true; + } + + public List GetUsers() { + return FromMetadataOrDirect( + metadata => metadata.Users.GetUsers().Select(ToDbUserInfo).ToList(), + GetUsersDirect); + } + + private List ReadServerAccounts() + => OnConnection(c => c.Query(ServerAccountsQuery()).ToList()); + + // один логин заведён на сервере под несколькими хостами - админ он, только если админ везде + private static bool IsAccountAdmin(IEnumerable accounts) + => accounts.All(r => MySqlGrants.IsGlobalAdmin(IsYes(r.SuperPriv), IsYes(r.CreateUserPriv))); + + private List GetUsersDirect() { + lock(serverLock) { + var rows = ReadServerAccounts(); + + userHosts.Clear(); + var result = new List(); + foreach(var accounts in rows.Where(IsRealUser).GroupBy(r => r.Login, StringComparer.Ordinal)) { + userHosts[accounts.Key] = accounts.Select(r => string.IsNullOrEmpty(r.Host) ? AnyHost : r.Host).ToList(); + result.Add(new DbUserInfo { + Login = accounts.Key, + Disabled = accounts.All(r => IsYes(r.AccountLocked)), + IsAdmin = IsAccountAdmin(accounts), + IsCurrentUser = string.Equals(accounts.Key, UserName, StringComparison.OrdinalIgnoreCase) + }); + } + return result; + } + } + + /// + /// Учётки сервера с признаком блокировки. У MariaDB флаг лежит JSON-ом в mysql.global_priv + /// у MySQL - колонкой в mysql.user + /// в обоих случаях приводим к 'Y'/'N', как ждёт MySqlUserRow + /// + private string ServerAccountsQuery() { + string locked; + string join = string.Empty; + + switch(AccountLocks) { + case AccountLockStorage.UserColumn: + locked = "u.account_locked"; + break; + case AccountLockStorage.GlobalPriv: + locked = "IF(JSON_VALUE(p.Priv, '$.account_locked') = 1, 'Y', 'N')"; + join = "LEFT JOIN mysql.global_priv p ON p.User = u.User AND p.Host = u.Host"; + break; + default: + locked = "NULL"; + break; + } + + return $"SELECT u.User AS Login, u.Host, {locked} AS AccountLocked, " + + "u.Super_priv AS SuperPriv, u.Create_user_priv AS CreateUserPriv " + + $"FROM mysql.user u {join}"; + } + + // служебные учётки самого сервера пользователю приложения не показываем + private static bool IsRealUser(MySqlUserRow row) => + !string.IsNullOrEmpty(row.Login) + && !row.Login.StartsWith("mysql.", StringComparison.OrdinalIgnoreCase) + && !MySqlSystemObjects.Users.Contains(row.Login, StringComparer.OrdinalIgnoreCase); + + private static bool IsYes(string flag) => string.Equals(flag, "Y", StringComparison.OrdinalIgnoreCase); + + public bool CreateUser(DbUserInfo user, string password) { + ValidateLogin(user?.Login); + if(string.IsNullOrEmpty(password)) + throw new ArgumentException("Пароль не может быть пустым", nameof(password)); + + // без чтения метабазы новый пользователь не увидит список баз + var launcherAccess = new DbUserBaseAccess { + BaseName = LauncherMetadataManagement.LauncherBaseName, ReadOnly = true, HasAccess = true + }; + + var statements = NewUserHosts + .SelectMany(host => NewUserStatements(user, host, password, launcherAccess)) + .ToList(); + + OnConnection(c => c.Execute(string.Join(";", statements))); + lock(serverLock) + userHosts[user.Login] = NewUserHosts.ToList(); + + ReflectInMetadata(m => m.Users.CreateUser(ToLauncherUser(user)), "создание", user.Login); + return true; + } + + private IEnumerable NewUserStatements(DbUserInfo user, string host, string password, + DbUserBaseAccess launcherAccess) { + string account = MySqlAccess.UserOf(user.Login, host); + string lockOption = user.Disabled && SupportsAccountLock ? " ACCOUNT LOCK" : string.Empty; + + yield return $"CREATE USER {account} IDENTIFIED BY '{MySqlHelper.EscapeString(password)}'{lockOption}"; + + if(SupportsAdminFlag && user.IsAdmin) + yield return MySqlAccess.GrantAdmin(account); + + foreach(var statement in MySqlAccess.Statements(user.Login, host, null, launcherAccess)) + yield return statement; + } + + public bool UpdateUser(DbUserInfo user, string newPassword = null) { + ValidateLogin(user?.Login); + + var grantsByHost = RequireGrantsByHost(user.Login); + bool isAdminNow = SupportsAdminFlag + && grantsByHost.Values.Any(MySqlGrants.HasGlobalAdmin); + + var statements = HostsOf(user.Login) + .SelectMany(host => UserChangeStatements(user, host, newPassword, isAdminNow)) + .ToList(); + + if(statements.Any()) + OnConnection(c => c.Execute(string.Join(";", statements))); + + ReflectUserUpdateInMetadata(user); + ReflectProfileInBases(user); + return true; + } + + private void ReflectProfileInBases(DbUserInfo user) { + if(string.IsNullOrEmpty(user.Name) && string.IsNullOrEmpty(user.Email)) + return; + + BaseUsers.SyncProfile(GetUserDatabases().Select(db => db.BaseName), user.Login, user.Name, user.Email); + } + + private IEnumerable UserChangeStatements(DbUserInfo user, string host, string newPassword, bool isAdminNow) { + string account = MySqlAccess.UserOf(user.Login, host); + + string options = string.Join(" ", AlterUserOptions(user, newPassword)); + if(options.Length > 0) + yield return $"ALTER USER {account} {options}"; + + if(SupportsAdminFlag && user.IsAdmin != isAdminNow) + yield return user.IsAdmin ? MySqlAccess.GrantAdmin(account) : MySqlAccess.RevokeAdmin(account); + } + + // через ALTER USER меняются только пароль и блокировка учётки + private IEnumerable AlterUserOptions(DbUserInfo user, string newPassword) { + if(!string.IsNullOrEmpty(newPassword)) + yield return $"IDENTIFIED BY '{MySqlHelper.EscapeString(newPassword)}'"; + if(SupportsAccountLock) + yield return user.Disabled ? "ACCOUNT LOCK" : "ACCOUNT UNLOCK"; + } + + public bool DeleteUser(string login) { + ValidateLogin(login); + + // строку гасим во всех базах каталога, а не только там, где были гранты: удаляют + // и пропавшую с сервера учётку, у которой грантов уже нет + var userBases = GetUserDatabases().ConvertAll(db => db.BaseName); + + OnConnection(c => c.Execute(string.Join(";", HostsOf(login) + .Select(host => $"DROP USER IF EXISTS {MySqlAccess.UserOf(login, host)}")))); + lock(serverLock) + userHosts.Remove(login); + + ReflectInMetadata(m => { + var target = m.Users.GetUserByLogin(login); + if(target != null) + m.Users.DeleteUser(target); + }, "удаление", login); + + BaseUsers.SyncWithDeletingUser(login, userBases); + + return true; + } + + public List GetUserBaseAccess(string login) + { + var grants = RequireGrantsByHost(login).Values.SelectMany(g => g).ToList(); + bool globalAdmin = MySqlGrants.HasGlobalAdmin(grants); + + // каталог берём из метабазы одним запросом, а не читая параметры каждой базы + List result = GetUserDatabases() + .Select(db => globalAdmin ? MySqlAccess.FullAccessByGlobalGrant(db) : MySqlAccess.FromGrants(db, grants)) + .ToList(); + + FillUsersProfiles(result, login); + return result; + } + + private void FillUsersProfiles(IEnumerable accesses, string login) { + var withAccess = accesses.Where(a => a.HasAccess && !string.IsNullOrEmpty(a.BaseName)); + if(!withAccess.Any()) + return; + + // один запрос на все базы: поштучно это два запроса на каждую + var profiles = BaseUsers.TryGetProfiles(withAccess.Select(a => a.BaseName), login); + + foreach(var access in withAccess) { + if(!profiles.TryGetValue(access.BaseName, out var profile)) + continue; + access.Name = profile.Name; + access.Email = profile.Email; + } + } + + public bool SetUserBaseAccess(string login, DbUserBaseAccess access) { + ValidateLogin(login); + if(string.IsNullOrWhiteSpace(access?.BaseName)) + throw new ArgumentException("Не указано имя базы", nameof(access)); + + var grantsByHost = RequireGrantsByHost(login); + + if(MySqlGrants.HasGlobalAdmin(grantsByHost.Values.SelectMany(g => g))) + throw new InvalidOperationException($"У пользователя {login} глобальные права на весь сервер"); + + var statements = grantsByHost + .SelectMany(hostGrants => MySqlAccess.Statements(login, hostGrants.Key, hostGrants.Value, access)) + .ToList(); + if(statements.Any()) + OnConnection(c => c.Execute(string.Join(";", statements))); + + ReflectInMetadata(m => { + var target = m.Users.GetUserByLogin(login); + if(target == null) + return; + if(access.HasAccess) + m.Users.SetBaseUpdateRight(access.BaseName, target, !access.ReadOnly); + else + m.Users.RevokeBaseUpdateRight(access.BaseName, target); + }, "изменение права на обновление базы", login); + + BaseUsers.SyncWithUserTable(access.BaseName, new BaseUserRow { Admin = access.IsAdmin, Deactivated = !access.HasAccess, Email = access.Email, Login = login, Name = access.Name}, access.HasAccess); + + return true; + } + + private Dictionary> RequireGrantsByHost(string login) + { + var hosts = HostsOf(login); + var result = new Dictionary>(StringComparer.Ordinal); + string sql = string.Join(";", hosts + .Select(host => $"SHOW GRANTS FOR {MySqlAccess.UserOf(login, host)}")); + try + { + OnConnection(c => { + using(var multi = c.QueryMultiple(sql)) + { + foreach(var host in hosts) + result[host] = multi.Read(); + } + }); + } + catch(MySqlException ex) when(ex.Number == ER_NONEXISTING_GRANT) + { + logger.Debug(ex, "Не удалось получить гранты пользователя {0}", login); + } + + if(!result.Any()) + throw new OperationRefusedException($"Учётной записи {login} на сервере нет. " + + "Возможно, её удалили в обход лаунчера - обновите список пользователей."); + + return result; + } + + private sealed class MySqlUserRow { + public string Login { get; set; } + public string Host { get; set; } + public string AccountLocked { get; set; } + public string SuperPriv { get; set; } + public string CreateUserPriv { get; set; } + } + + private IReadOnlyList HostsOf(string login) + { + lock(serverLock) { + if(!userHosts.ContainsKey(login)) { + try { + GetUsersDirect(); + } + catch(MySqlException ex) { + logger.Debug(ex, "Не удалось прочитать хосты учётной записи {0}", login); + } + } + + return userHosts.TryGetValue(login, out var hosts) && hosts.Any() + ? (IReadOnlyList)hosts.ToList() + : NewUserHosts; + } + } + + private static void ValidateLogin(string login) + { + if(string.IsNullOrWhiteSpace(login)) + throw new ArgumentException("Логин пользователя не может быть пустым", nameof(login)); + if(login.Length > 80) + throw new ArgumentException("Логин пользователя длиннее 80 символов", nameof(login)); + } + + #endregion + + private readonly object serverLock = new object(); + + private T OnConnection(Func query) { + lock(serverLock) { + EnsureOpen(); + return query(connection); + } + } + + private void OnConnection(Action command) { + lock(serverLock) { + EnsureOpen(); + command(connection); + } + } + + private void EnsureOpen() + { + if(connection.State == ConnectionState.Broken) + connection.Close(); + + if(connection.State != ConnectionState.Open) + connection.Open(); + } + + private bool disposed; + + protected virtual void Dispose(bool disposing) + { + if(disposed) + return; + + if(disposing) + connection?.Dispose(); + + disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } } } diff --git a/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs b/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs index 960a29b4e..40e5f5cae 100644 --- a/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs +++ b/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs @@ -1,7 +1,8 @@ +using QS.DbManagement.Entities; +using QS.Utilities.Extensions; using System.Collections.Generic; using System.Linq; using System.Reflection; -using QS.Utilities.Extensions; namespace QS.DbManagement { @@ -20,7 +21,7 @@ public override bool CanConnect(IEnumerable parameters parameters.Any(p => p.Name == "Login" && !string.IsNullOrEmpty(p.Value)); } - public override IDbProvider CreateProvider(IList parameters, string password = null) - => new MariaDBProvider(parameters, password); + public override IDbProvider CreateProvider(IList parameters, byte productCode, string password = null) + => new MariaDBProvider(parameters, productCode, password); } } diff --git a/QS.DbManagement/MariaDb/MariaDbExportService.cs b/QS.DbManagement/MariaDb/MariaDbExportService.cs new file mode 100644 index 000000000..ce83a5261 --- /dev/null +++ b/QS.DbManagement/MariaDb/MariaDbExportService.cs @@ -0,0 +1,110 @@ +using MySqlConnector; +using QS.Dialog; +using System; +using System.IO; +using System.Threading; + +namespace QS.DbManagement { + public class MariaDbExportService { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + /// Выгружает базу в файл + public void Export( + MySqlConnectionStringBuilder connectionSettings, + string databaseName, + string filePath, + IProgressBarDisplayable progress, + CancellationToken cancellation) + { + if(connectionSettings == null) + throw new ArgumentNullException(nameof(connectionSettings)); + if(string.IsNullOrWhiteSpace(databaseName)) + throw new ArgumentException("Не указано имя базы", nameof(databaseName)); + if(string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("Не указан путь к файлу резервной копии.", nameof(filePath)); + + EnsureDirectory(filePath); + progress?.Update($"Создаём резервную копию базы {databaseName} в файл {filePath}"); + + var builder = new MySqlConnectionStringBuilder(connectionSettings.ConnectionString) + { + Database = databaseName + }; + + using(var connection = new MySqlConnection(builder.ConnectionString)) + { + connection.Open(); + using(var command = connection.CreateCommand()) + { + command.CommandTimeout = 0; + using(var backup = new MySqlBackup(command)) + { + var reporter = new ExportReporter(progress, cancellation); + backup.ExportProgressChanged += reporter.OnProgress; + backup.ExportToFile(filePath); + + if(reporter.Stopped || cancellation.IsCancellationRequested) + { + DeleteQuietly(filePath); + cancellation.ThrowIfCancellationRequested(); + } + } + } + } + } + + private static void EnsureDirectory(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + } + + private sealed class ExportReporter + { + private readonly IProgressBarDisplayable progress; + private readonly CancellationToken cancellation; + private bool started; + private string currentTable; + + public ExportReporter(IProgressBarDisplayable progress, CancellationToken cancellation) + { + this.progress = progress; + this.cancellation = cancellation; + } + + /// Выгрузку остановили по отмене - файл дописан не до конца + public bool Stopped { get; private set; } + + public void OnProgress(object sender, ExportProgressArgs e) + { + if(cancellation.IsCancellationRequested) { + Stopped = true; + ((MySqlBackup)sender).StopAllProcess(); + return; + } + + if(!started) { + progress?.Start(maxValue: e.TotalRowsInAllTables, text: "Создание резервной копии"); + started = true; + } + if(currentTable != e.CurrentTableName) { + currentTable = e.CurrentTableName; + progress?.Update($"Экспорт таблицы {currentTable}"); + } + progress?.Update(e.CurrentRowIndexInAllTables); + } + } + + private static void DeleteQuietly(string filePath) + { + try { + if(File.Exists(filePath)) + File.Delete(filePath); + } + catch(IOException ex) { + logger.Warn(ex, "Не удалось удалить незавершённую резервную копию {0}", filePath); + } + } + } +} diff --git a/QS.DbManagement/MariaDb/MySqlAccess.cs b/QS.DbManagement/MariaDb/MySqlAccess.cs new file mode 100644 index 000000000..e1f3c4960 --- /dev/null +++ b/QS.DbManagement/MariaDb/MySqlAccess.cs @@ -0,0 +1,113 @@ +using MySqlConnector; +using QS.DbManagement.Entities; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace QS.DbManagement.MariaDb { + /// Перевод между грантами сервера и правами доступа приложения + internal static class MySqlAccess { + public const string AllPrivileges = "ALL PRIVILEGES"; + private const string ReadOnlyPrivileges = "SELECT, LOCK TABLES, SHOW VIEW"; + + private const string UpdatePrivileges = + "SELECT, INSERT, UPDATE, DELETE, EXECUTE, CREATE TEMPORARY TABLES, LOCK TABLES, SHOW VIEW, " + + "ALTER, CREATE, DROP"; //Право накатывать на базу обновления + + /// переводит в вид в виде 'логин'@'хост' + public static string UserOf(string login, string host) + => $"'{MySqlHelper.EscapeString(login)}'@'{MySqlHelper.EscapeString(host)}'"; + + /// + /// Выдача глобального админа: права на весь сервер плюс право их раздавать. + /// + public static string GrantAdmin(string account) + => $"GRANT {AllPrivileges} ON *.* TO {account} WITH GRANT OPTION"; + + /// Снятие глобального админа + public static string RevokeAdmin(string account) + => $"REVOKE {AllPrivileges} ON *.* FROM {account}; REVOKE GRANT OPTION ON *.* FROM {account}"; + + public static DbUserBaseAccess FullAccessByGlobalGrant(DbInfo db) + => new DbUserBaseAccess { + BaseId = db.BaseId, + BaseName = db.BaseName, + Title = db.Title, + HasAccess = true, + IsAdmin = true, + CanEdit = false + }; + + public static DbUserBaseAccess FromGrants(DbInfo db, IEnumerable grants) + { + var access = new DbUserBaseAccess { BaseId = db.BaseId, BaseName = db.BaseName, Title = db.Title }; + + var privileges = grants + .Where(g => CoversDatabase(g, db.BaseName) && MySqlGrants.IsMeaningful(g)) + .SelectMany(MySqlGrants.Privileges) + .ToList(); + if(!privileges.Any()) + return access; + + access.HasAccess = true; + if(privileges.Contains(AllPrivileges)) + access.IsAdmin = true; + else if(privileges.All(IsReadOnlyPrivilege)) + access.ReadOnly = true; + return access; + } + + /// + /// SQL, чтобы у @ стал доступ + /// + public static IEnumerable Statements(string login, string host, IEnumerable grants, DbUserBaseAccess access) + { + string user = UserOf(login, host); + + foreach(var grant in (grants ?? Enumerable.Empty()).Where(g => GrantedOnDatabase(g, access.BaseName))) { + string pattern = $"`{MySqlEscape.Identifier(MySqlGrants.Scope(grant))}`.*"; + if(MySqlGrants.IsMeaningful(grant)) + yield return $"REVOKE {AllPrivileges} ON {pattern} FROM {user}"; + // ALL PRIVILEGES не включает право раздачи грантов + if(MySqlGrants.HasGrantOption(grant)) + yield return $"REVOKE GRANT OPTION ON {pattern} FROM {user}"; + } + + string privileges = PrivilegesFor(access); + if(privileges != null) + yield return $"GRANT {privileges} ON `{MySqlEscape.Pattern(access.BaseName)}`.* TO {user}"; + } + + /// null - доступа нет + private static string PrivilegesFor(DbUserBaseAccess access) + { + if(!access.HasAccess) + return null; + if(access.IsAdmin) + return AllPrivileges; + if(access.ReadOnly) + return ReadOnlyPrivileges; + return UpdatePrivileges; + } + + // грант на всём сервере *.* базу тоже покрывает + private static bool CoversDatabase(string grant, string baseName) + { + string scope = MySqlGrants.Scope(grant); + if(scope == null) + return false; + return scope == "*" || string.Equals(MySqlEscape.UnescapePattern(scope), baseName, StringComparison.OrdinalIgnoreCase); + } + + // отзывать по имени базы можно только грант, выданный именно на неё + private static bool GrantedOnDatabase(string grant, string baseName) + { + string scope = MySqlGrants.Scope(grant); + return scope != null && scope != "*" + && string.Equals(MySqlEscape.UnescapePattern(scope), baseName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsReadOnlyPrivilege(string privilege) + => privilege == "SELECT" || privilege == "LOCK TABLES" || privilege == "SHOW VIEW"; + } +} diff --git a/QS.DbManagement/MariaDb/MySqlEscape.cs b/QS.DbManagement/MariaDb/MySqlEscape.cs new file mode 100644 index 000000000..18eb80ce4 --- /dev/null +++ b/QS.DbManagement/MariaDb/MySqlEscape.cs @@ -0,0 +1,21 @@ +namespace QS.DbManagement.MariaDb { + internal static class MySqlEscape { + /// идентификатор в обратных кавычках + public static string Identifier(string value) + { + return value == null ? string.Empty : value.Replace("`", "``"); + } + + /// имя базы для GRANT + public static string Pattern(string dbName) + { + return Identifier(dbName).Replace("_", "\\_").Replace("%", "\\%"); + } + + /// имя базы, как его вернул SHOW GRANTS + public static string UnescapePattern(string pattern) + { + return pattern.Replace("\\_", "_").Replace("\\%", "%"); + } + } +} diff --git a/QS.DbManagement/MariaDb/MySqlGrants.cs b/QS.DbManagement/MariaDb/MySqlGrants.cs new file mode 100644 index 000000000..66e595e1b --- /dev/null +++ b/QS.DbManagement/MariaDb/MySqlGrants.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace QS.DbManagement.MariaDb { + /// + /// Разбор строк, которые отдаёт SHOW GRANTS + /// + internal static class MySqlGrants { + private const string AllPrivileges = "ALL PRIVILEGES"; + private const string GrantOption = "WITH GRANT OPTION"; + + // Скобки после привилегии + private static readonly Regex ColumnList = + new Regex(@"\([^)]*\)", RegexOptions.None, TimeSpan.FromSeconds(5)); + + public static bool HasGlobalAdmin(IEnumerable grants) + => grants.Any(g => Scope(g) == "*" && Privileges(g).Contains(AllPrivileges)); + + public static bool IsGlobalAdmin(bool superPriv, bool createUserPriv) + => superPriv && createUserPriv; + + public static bool HasGlobalGrantOption(IEnumerable grants) => + grants.Any(g => Scope(g) == "*" && HasGrantOption(g)); + + public static bool HasGrantOption(string grant) => + grant.IndexOf(GrantOption, StringComparison.OrdinalIgnoreCase) >= 0; + + public static string Scope(string grant) { + int on = grant.IndexOf(" ON ", StringComparison.OrdinalIgnoreCase); + int to = grant.IndexOf(" TO ", StringComparison.OrdinalIgnoreCase); + if(on < 0 || to < on) + return null; + + string target = grant.Substring(on + 4, to - on - 4).Trim(); //4 = длина " ON " + if(!target.EndsWith(".*", StringComparison.Ordinal)) + return null; + + string name = target.Substring(0, target.Length - 2).Trim(); + if(name == "*") + return name; + if(name.Length < 2 || !name.StartsWith("`", StringComparison.Ordinal) || !name.EndsWith("`", StringComparison.Ordinal)) + return null; + return name + .Substring(1, name.Length - 2) + .Replace("``", "`"); + } + + public static IEnumerable Privileges(string grant) { + int start = grant.IndexOf("GRANT ", StringComparison.OrdinalIgnoreCase); + int on = grant.IndexOf(" ON ", StringComparison.OrdinalIgnoreCase); + if(start < 0 || on <= start) + return Enumerable.Empty(); + + start += 6; //длина "GRANT " + return ColumnList + .Replace( + grant.Substring(start, on - start), string.Empty) + .Split(',') + .Select(p => + p.Trim() + .ToUpperInvariant()) + .Where(p => + p.Length > 0); + } + + public static bool IsMeaningful(string grant) + { + return Privileges(grant) + .Any(p => p != "USAGE"); + } + } +} diff --git a/QS.DbManagement/MariaDb/MySqlMultiBase.cs b/QS.DbManagement/MariaDb/MySqlMultiBase.cs new file mode 100644 index 000000000..cf9fcd5a7 --- /dev/null +++ b/QS.DbManagement/MariaDb/MySqlMultiBase.cs @@ -0,0 +1,64 @@ +using Dapper; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; + +namespace QS.DbManagement.MariaDb { + internal static class MySqlMultiBase { + /// + /// базы, где таблицы нет или куда нет доступа, в результат не попадают + /// + public static List DatabasesWithTable(IDbConnection connection, IEnumerable databases, string table) { + var wanted = Distinct(databases); + if(wanted.Count == 0) + return new List(); + + return connection.Query( + "SELECT TABLE_SCHEMA FROM information_schema.TABLES " + + "WHERE TABLE_NAME = @table AND TABLE_SCHEMA IN @databases ORDER BY TABLE_SCHEMA", + new { table, databases = wanted }).ToList(); + } + + /// Есть ли в базе такая таблица - и видна ли она текущему пользователю + public static bool HasTable(IDbConnection connection, string database, string table) + => DatabasesWithTable(connection, new[] { database }, table).Count > 0; + + /// + /// Число и порядок колонок в ветках обязаны совпадать + /// + /// база -> список колонок её ветки + /// base_parameters имя перменной с именем базы + /// условие ветки без слова WHERE; null - без условия + /// сюда добавляются метки баз + public static string UnionAll(IEnumerable> projections, + string table, string label, string where, DynamicParameters parameters) { + if(parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + string condition = string.IsNullOrEmpty(where) ? string.Empty : " WHERE " + where; + string tableName = $"`{MySqlEscape.Identifier(table)}`"; + + var selects = new List(); + foreach(var projection in projections) { + // имя базы уходит в запрос дважды: идентификатором в кавычках и меткой - параметром + string marker = "base" + selects.Count.ToString(); + parameters.Add(marker, projection.Key); + selects.Add($"SELECT @{marker} AS `{label}`, {projection.Value} " + + $"FROM `{MySqlEscape.Identifier(projection.Key)}`.{tableName}{condition}"); + } + return string.Join(" UNION ALL ", selects); + } + + /// Имена баз без повторов и пустых - в таком виде их принимают оба запроса + public static List Distinct(IEnumerable databases) { + if(databases == null) + throw new ArgumentNullException(nameof(databases)); + + return databases + .Where(db => !string.IsNullOrEmpty(db)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/QS.DbManagement/MariaDb/MySqlSystemObjects.cs b/QS.DbManagement/MariaDb/MySqlSystemObjects.cs new file mode 100644 index 000000000..70fe910df --- /dev/null +++ b/QS.DbManagement/MariaDb/MySqlSystemObjects.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace QS.DbManagement.MariaDb { + internal static class MySqlSystemObjects { + public static readonly IReadOnlyCollection Databases = + new[] { "information_schema", "mysql", "performance_schema", "sys" }; + + public static readonly IReadOnlyCollection Users = + new[] { "root", "mariadb.sys", "mysql", "PUBLIC" }; + } +} diff --git a/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs new file mode 100644 index 000000000..14968d2ed --- /dev/null +++ b/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs @@ -0,0 +1,141 @@ +using Dapper; +using MySqlConnector; +using QS.DbManagement.Entities; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace QS.DbManagement.MariaDb.QSLauncher { + internal class BaseUsersManagement { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private const string UsersTable = "users"; + + // незаполненное поле формы приходит null и через COALESCE не затирает то, + // что в базу вписало само приложение + private const string ProfileSet = "`name` = COALESCE(@name, `name`), `email` = COALESCE(@email, `email`)"; + private const string ProfileSelect = "`name` AS `Name`, `email` AS `Email`"; + + private readonly string connectionString; + + public BaseUsersManagement(MySqlConnectionStringBuilder connectionBuilder) { + connectionString = connectionBuilder.ConnectionString; + } + + /// Заводит, обновляет пользователя в базе при доступе, снимает доступ через deactivated + public void SyncWithUserTable(string baseName, BaseUserRow user, bool hasAccess) { + try { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + if(!MySqlMultiBase.HasTable(connection, baseName, UsersTable)) + return; // таблицы users в этой базе нет + + string table = Table(baseName); + if(!hasAccess) { + connection.Execute($"UPDATE {table} SET `deactivated` = TRUE WHERE `login` = @login", + new { login = user.Login }); + return; + } + + bool exists = connection.ExecuteScalar( + $"SELECT COUNT(*) > 0 FROM {table} WHERE `login` = @login", new { login = user.Login }); + + // колонка name в базах NOT NULL - без подстановки вставка упадёт + if(!exists && string.IsNullOrEmpty(user.Name)) + user.Name = user.Login; + + connection.Execute(exists + ? $"UPDATE {table} SET {ProfileSet}, `admin` = @admin, `deactivated` = FALSE WHERE `login` = @login" + : $"INSERT INTO {table} (`login`, `name`, `email`, `admin`, `deactivated`) " + + "VALUES (@login, @name, @email, @admin, FALSE)", + new { login = user.Login, name = Blank(user.Name), email = Blank(user.Email), admin = user.Admin }); + } + } + catch(MySqlException ex) { + logger.Warn(ex, "не удалось синхронизировать users в базе {0} для {1}", baseName, user.Login); + } + } + + public void SyncWithDeletingUser(string login, List baseNames) { + try { + UpdateInBases(baseNames, "`deactivated` = TRUE", new { login }); + } + catch(MySqlException ex) { + logger.Warn(ex, "не удалось синхронизировать отключение пользователя {0} из всех таблиц users", login); + } + } + + public void SyncProfile(IEnumerable baseNames, string login, string name, string email) { + if(string.IsNullOrEmpty(name) && string.IsNullOrEmpty(email)) + return; + + try { + UpdateInBases(baseNames, ProfileSet, new { login, name = Blank(name), email = Blank(email) }); + } + catch(MySqlException ex) { + logger.Warn(ex, "не удалось обновить профиль пользователя {0} в таблицах {1}", login, UsersTable); + } + } + + public Dictionary TryGetProfiles(IEnumerable baseNames, string login) { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var wanted = Wanted(baseNames); + if(wanted.Count == 0) + return result; + + try { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + var parameters = new DynamicParameters(); + parameters.Add("login", login); + + // набор колонок один на все базы, поэтому ветки UNION соединяются как есть + var projections = MySqlMultiBase.DatabasesWithTable(connection, wanted, UsersTable) + .Select(baseName => new KeyValuePair(baseName, ProfileSelect)); + string sql = MySqlMultiBase.UnionAll(projections, UsersTable, + nameof(BaseProfileRow.BaseName), "`login` = @login", parameters); + if(sql.Length == 0) + return result; + + foreach(BaseProfileRow row in connection.Query(sql, parameters)) + result[row.BaseName] = row; + } + } + catch(MySqlException ex) { + logger.Warn(ex, "Не удалось прочитать {0} по базам пользователя {1}", UsersTable, login); + } + return result; + } + + /// Все базы одним запросом: поштучно это два запроса на каждую + private void UpdateInBases(IEnumerable baseNames, string setClause, object parameters) { + var wanted = Wanted(baseNames); + if(wanted.Count == 0) + return; + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + var sqls = MySqlMultiBase.DatabasesWithTable(connection, wanted, UsersTable) + .Select(baseName => $"UPDATE {Table(baseName)} SET {setClause} WHERE `login` = @login") + .ToList(); + if(sqls.Count == 0) + return; + + connection.Execute(string.Join("; ", sqls), parameters); + } + } + + private static List Wanted(IEnumerable baseNames) + => MySqlMultiBase.Distinct(baseNames ?? Enumerable.Empty()); + + private static string Table(string baseName) + => $"`{MySqlEscape.Identifier(baseName)}`.`{UsersTable}`"; + + /// Пустую строку из формы кладём как NULL + private static string Blank(string value) => string.IsNullOrEmpty(value) ? null : value; + + private sealed class BaseProfileRow : BaseUserRow { + public string BaseName { get; set; } + } + } +} diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs new file mode 100644 index 000000000..0110f645c --- /dev/null +++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs @@ -0,0 +1,231 @@ +using Dapper; +using MySqlConnector; +using QS.DbManagement.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace QS.DbManagement.MariaDb.QSLauncher { + internal class LauncherBasesManagement { + private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private const string LauncherBaseName = LauncherMetadataManagement.LauncherBaseName; + private const string BasesTable = "bases"; + private static readonly string[] BaseDependencies = { "base_update_rights" }; + private static readonly string[] BaseColumns = + { "product_id", "base_name", "base_title", "version" }; + private static readonly string[] BaseUpdatableColumns = { "base_title", "version" }; + + private readonly bool canSync; + private readonly string connectionString; + private readonly byte productId; + + + /// + /// Требуется полный обзор сервера + /// + public LauncherBasesManagement(MySqlConnectionStringBuilder connectionBuilder, bool canSync, byte productId) { + var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) { + Database = LauncherBaseName, + AllowLoadLocalInfile = true + }; + connectionString = toLauncher.ConnectionString; + + this.canSync = canSync; + this.productId = productId; + } + + public int SyncBases() { + if(!canSync) + throw new UnauthorizedAccessException($"Синхронизировать {LauncherBaseName} может только пользователь с правами на весь сервер"); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + + var bases = connection.Query("SHOW DATABASES") + .Except(MySqlSystemObjects.Databases, StringComparer.OrdinalIgnoreCase) + .ToList(); + + //Базы нашего продукта, о которых есть что записать в метабазу + var parameters = BaseParametersReader.ReadMany(connection, bases, BaseMetaParameters); + var rows = new List(); + foreach(var dbName in bases) + { + BaseRow row = ToBaseMeta(dbName, parameters); + if(row == null || row.ProductId != productId) + continue; + + rows.Add(row); + } + + int written = rows.Count > 0 ? UpsertBases(connection, rows) : 0; + + // пропавшие с сервера базы помечаем disabled + MarkMissingBasesDisabled(connection, bases); + + return written; + } + } + + private static int UpsertBases(MySqlConnection connection, IList rows, MySqlTransaction tx = null) { + const int chunkSize = 500; + for(int offset = 0; offset < rows.Count; offset += chunkSize) { + var chunk = rows.Skip(offset).Take(chunkSize).ToList(); + string sql = BuildUpsert(chunk, out var parameters); + connection.Execute(sql, parameters, tx); + } + + return rows.Count; + } + + /// Один INSERT со всеми строками пачки ON DUPLICATE KEY UPDATE + private static string BuildUpsert(IReadOnlyList chunk, out DynamicParameters parameters) { + var sql = new StringBuilder($"INSERT INTO `{BasesTable}` (") + .Append(string.Join(", ", BaseColumns.Select(c => $"`{c}`"))) + .Append(") VALUES "); + parameters = new DynamicParameters(); + + for(int row = 0; row < chunk.Count; row++) { + if(row > 0) + sql.Append(','); + sql.Append('(') + .Append(string.Join(",", BaseColumns.Select((c, i) => "@" + ParameterName(row, i)))) + .Append(')'); + + var values = RowValues(chunk[row]); + for(int i = 0; i < values.Length; i++) + parameters.Add(ParameterName(row, i), values[i]); + } + + sql.Append(" ON DUPLICATE KEY UPDATE ") + .Append(string.Join(", ", BaseUpdatableColumns.Select(c => $"`{c}` = VALUES(`{c}`)"))); + + return sql.ToString(); + } + + /// Порядок обязан совпадать с + private static object[] RowValues(BaseRow row) => new object[] { + row.ProductId, row.BaseName, row.BaseTitle, row.Version + }; + + private static string ParameterName(int row, int column) => $"p{row}_{column}"; + + private void MarkMissingBasesDisabled(MySqlConnection connection, IReadOnlyCollection presentDatabases) + { + if(presentDatabases.Count == 0) { + connection.Execute( + $"UPDATE `{BasesTable}` SET disabled = TRUE WHERE product_id = @pid;", + new { pid = productId }); + return; + } + // пропавшие -> disabled + // вернувшиеся -> снимаем флаг + connection.Execute( + $"UPDATE `{BasesTable}` SET disabled = (base_name NOT IN @present) WHERE product_id = @pid;", + new { present = presentDatabases, pid = productId }); + } + + public IEnumerable GetBases() + { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + + var visible = new HashSet(connection.Query("SHOW DATABASES"), + StringComparer.OrdinalIgnoreCase); + + return connection.Query( + "SELECT `id` AS BaseId, COALESCE(base_title, base_name) AS Title, " + + " `base_name` AS BaseName, `version` AS Version " + + $"FROM `{BasesTable}` WHERE product_id = @productId " + + " AND base_name IN @visible;", + new { productId, visible }) + .ToList(); + } + } + + /// Идентификатор базы продукта в метабазе, 0 - её там нет + public int FindBaseId(MySqlConnection connection, string baseName, MySqlTransaction transaction = null) => + connection.ExecuteScalar( + $"SELECT id FROM `{BasesTable}` WHERE base_name = @name AND product_id = @pid;", + new { name = baseName, pid = productId }, transaction) ?? 0; + + public int UpsertBase(MySqlConnection connection, MySqlTransaction transaction, DbInfo dbInfo) + { + UpsertBases(connection, new[] { ToBaseRow(dbInfo) }, transaction); + + return FindBaseId(connection, dbInfo.BaseName, transaction); + } + + private BaseRow ToBaseRow(DbInfo dbInfo) => new BaseRow { + ProductId = productId, + BaseName = dbInfo.BaseName, + BaseTitle = dbInfo.Title, + Version = dbInfo.Version + }; + + public bool SyncWithDelete(DbInfo dbInfo) + { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + using(var transaction = connection.BeginTransaction()) { + bool deleted = SyncWithDelete(dbInfo, transaction); + transaction.Commit(); + return deleted; + } + } + } + + /// Транзакцию коммитит вызывающий + public bool SyncWithDelete(DbInfo dbInfo, MySqlTransaction transaction) + { + // в удаление приходит и база, созданная только что при пересоздании: у неё известно лишь имя + int baseId = dbInfo.BaseId > 0 + ? dbInfo.BaseId + : FindBaseId(transaction.Connection, dbInfo.BaseName, transaction); + + if(baseId <= 0) { + logger.Debug("База {0} в метабазе не значится, удалять нечего", dbInfo.BaseName); + return false; + } + + foreach(var dependency in BaseDependencies) + transaction.Connection.Execute($"DELETE FROM `{dependency}` WHERE base_id = @id;", + new { id = baseId }, transaction); + transaction.Connection.Execute($"DELETE FROM `{BasesTable}` WHERE id = @id;", new { id = baseId }, transaction); + + logger.Info("Удалена база {0} продукта {1}", dbInfo.BaseName, productId); + return true; + } + + private static readonly string[] BaseMetaParameters = { "ProductCode", "version", "BaseTitle" }; + + /// null - параметров базы нет либо в них нет кода продукта + private static BaseRow ToBaseMeta(string dbName, IReadOnlyDictionary> byDatabase) { + if(!byDatabase.TryGetValue(dbName, out var parameters)) + return null; + + if(!parameters.TryGetValue("ProductCode", out var code) || !byte.TryParse(code, out var productCode)) + return null; + + return new BaseRow + { + ProductId = productCode, + BaseName = dbName, + Version = Parameter(parameters, "version"), + BaseTitle = Parameter(parameters, "BaseTitle") + }; + } + + private static string Parameter(IReadOnlyDictionary parameters, string name) + => parameters.TryGetValue(name, out var value) ? value : null; + + /// Строка таблицы bases, как её пишет синхронизация + private sealed class BaseRow { + public byte ProductId { get; set; } + public string BaseName { get; set; } + public string BaseTitle { get; set; } + public string Version { get; set; } + } + } +} diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs new file mode 100644 index 000000000..9404f4aca --- /dev/null +++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs @@ -0,0 +1,47 @@ +using Dapper; +using MySqlConnector; +using QS.DbManagement.Entities; + +namespace QS.DbManagement.MariaDb.QSLauncher { + internal class LauncherMetadataManagement { + public const string LauncherBaseName = "QSLauncher"; + + private readonly string connectionString; + public LauncherBasesManagement Bases { get; } + public LauncherUsersManagement Users { get; } + + public LauncherMetadataManagement(MySqlConnectionStringBuilder connectionBuilder, bool isServerAdmin, + string login, byte productId) + { + Bases = new LauncherBasesManagement(connectionBuilder, isServerAdmin, productId); + Users = new LauncherUsersManagement(connectionBuilder, login, isServerAdmin, Bases); + + // строку правим на копии: builder принадлежит вызывающему + var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) { + Database = LauncherBaseName + }; + connectionString = toLauncher.ConnectionString; + + EnsureAvailable(); + } + + private void EnsureAvailable() { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + connection.ExecuteScalar("SELECT 1 FROM `bases` LIMIT 1;"); + } + } + + public int CreateBaseWithCreatorAccess(DbInfo dbInfo) { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + using(var transaction = connection.BeginTransaction()) { + int baseId = Bases.UpsertBase(connection, transaction, dbInfo); + Users.GrantCreatorUpdateRight(connection, transaction, baseId); + transaction.Commit(); + return baseId; + } + } + } + } +} diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs new file mode 100644 index 000000000..aea4d2c0a --- /dev/null +++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs @@ -0,0 +1,276 @@ +using Dapper; +using MySqlConnector; +using QS.DbManagement.Entities; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace QS.DbManagement.MariaDb.QSLauncher { + internal class LauncherUsersManagement + { + private const string LauncherBaseName = LauncherMetadataManagement.LauncherBaseName; + private const string UsersTable = "server_users"; + private const string UpdateRightsTable = "base_update_rights"; + private const int ER_DUP_ENTRY = 1062; + + private const string UserNotFound = "Пользователь с указанным именем не найден"; + private const string LoginTaken = "Такое имя пользователя уже занято"; + private const string BaseNotFound = "База не найдена"; + + private readonly string connectionString; + private readonly string login; + private readonly bool isAdmin; + private readonly LauncherBasesManagement bases; + + public LauncherUsersManagement(MySqlConnectionStringBuilder connectionBuilder, string login, bool isAdmin, + LauncherBasesManagement bases) { + // строку правим на копии: builder принадлежит вызывающему + var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) { + Database = LauncherBaseName, + AllowLoadLocalInfile = true + }; + connectionString = toLauncher.ConnectionString; + this.login = login ?? throw new ArgumentNullException(nameof(login)); + this.isAdmin = isAdmin; + this.bases = bases ?? throw new ArgumentNullException(nameof(bases)); + } + + public IEnumerable GetUsers() { + RequireAdminFor("просмотра пользователей"); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + return connection.Query( + $"SELECT {UserSelect} FROM `{UsersTable}` ORDER BY login;").ToList(); + } + } + + public LauncherUserInfo GetUserByLogin(string login) { + RequireAdminFor("просмотра пользователя"); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + return connection.QueryFirstOrDefault( + $"SELECT {UserSelect} FROM `{UsersTable}` WHERE login = @login;", + new { login }); + } + } + + public bool CreateUser(LauncherUserInfo user) { + RequireAdminFor("управления пользователями"); + + using(var connection = new MySqlConnection(connectionString)) { + try { + connection.Open(); + + int taken = connection.ExecuteScalar( + $"SELECT COUNT(*) FROM `{UsersTable}` WHERE login = @login;", + new { user.Login }); + if(taken != 0) + throw new ArgumentException(LoginTaken, nameof(user)); + + DynamicParameters parameters = new DynamicParameters(); + FillCreateUserCollumnsParams(user, parameters); + + string query = $"INSERT INTO `{UsersTable}` ({GetLabelsUserStr(UserCreateColumns)}) " + + $"VALUES ({GetValuesUserStr(UserCreateColumns)});"; + return connection.Execute(query, parameters) > 0; + } + catch(MySqlException ex) when(ex.Number == ER_DUP_ENTRY) { //логин заняли между проверкой и вставкой + throw new ArgumentException(LoginTaken, nameof(user), ex); + } + } + } + + /// Пустую строку из формы кладём как NULL + private static string Blank(string value) => string.IsNullOrEmpty(value) ? null : value; + + public bool UpdateUser(LauncherUserInfo user) { + RequireAdminFor("управления пользователями"); + if(user.Id <= 0) + throw new ArgumentException(UserNotFound, nameof(user)); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + + DynamicParameters parameters = new DynamicParameters(); + FillUpdateUserCollumnsParams(user, parameters); + parameters.Add("id", user.Id); + + connection.Execute($"UPDATE `{UsersTable}` SET {GetSetUserStr(UserUpdateColumns)} WHERE id = @id;", parameters); + } + return true; + } + + public bool DeleteUser(LauncherUserInfo user) { + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + using(var transaction = connection.BeginTransaction()) { + bool deleted = DeleteUser(user, transaction); + transaction.Commit(); + return deleted; + } + } + } + + /// Транзакцию коммитит вызывающий + public bool DeleteUser(LauncherUserInfo user, MySqlTransaction transaction) { + RequireAdminFor("управления пользователями"); + if(user.Id <= 0) + throw new ArgumentException(UserNotFound, nameof(user)); + + transaction.Connection.Execute($"DELETE FROM `{UpdateRightsTable}` WHERE `user_id` = @userId", new { userId = user.Id }, transaction); + int rowsAffected = transaction.Connection.Execute($"DELETE FROM `{UsersTable}` WHERE `id` = @userId", new { userId = user.Id }, transaction); + + return rowsAffected > 0; + } + + /// число учёток сервера, сопоставленных с метабазой + public int SyncUsers(IEnumerable serverUsers) { + RequireAdminFor("синхронизации пользователей"); + + var present = (serverUsers ?? Enumerable.Empty()) + .Where(u => !string.IsNullOrEmpty(u?.Login)) + .ToList(); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + DisableMissingUsers(connection, present.ConvertAll(u => u.Login)); + InsertMissingUsers(connection, present); + return present.Count; + } + } + private void DisableMissingUsers(MySqlConnection connection, List presentLogins) { + var keep = presentLogins.Concat(MySqlSystemObjects.Users).ToList(); + + connection.Execute( + $"UPDATE `{UsersTable}` SET disabled = TRUE WHERE login NOT IN @keep;", + new { keep }); + } + + private void InsertMissingUsers(MySqlConnection connection, List serverUsers) + { + int userCount = serverUsers.Count; + if(userCount <= 0) + return; + + List values = new List(userCount); + DynamicParameters parameters = new DynamicParameters(); + for(int i = 0; i < userCount; i++) + { + values.Add($"({GetValuesUserStr(UserCreateColumns, i)})"); + FillCreateUserCollumnsParams(serverUsers[i], parameters, i); + } + + connection.Execute( + $"INSERT INTO `{UsersTable}` ({GetLabelsUserStr(UserCreateColumns)})" + + " VALUES " + + string.Join(", ", values) + + " ON DUPLICATE KEY UPDATE `login` = `login`;" + , parameters); + } + + + private static readonly string[] UserKeyColumns = { "login" }; + private static readonly string[] UserUpdateColumns = { "name", "email", "phone", "is_admin", "disabled" }; + private static readonly string[] UserCreateColumns = UserKeyColumns.Concat(UserUpdateColumns).ToArray(); + private static void FillCreateUserCollumnsParams(LauncherUserInfo user, DynamicParameters parameters, int i = 0) + { + parameters.Add($"login{i}", user.Login); + FillUpdateUserCollumnsParams(user, parameters, i); + } + private static void FillUpdateUserCollumnsParams(LauncherUserInfo user, DynamicParameters parameters, int i = 0) + { + parameters.Add($"name{i}", Blank(user.Name)); + parameters.Add($"email{i}", Blank(user.Email)); + parameters.Add($"phone{i}", Blank(user.Phone)); + parameters.Add($"is_admin{i}", user.IsAdmin); + parameters.Add($"disabled{i}", user.Disabled); + } + + private static string GetLabelsUserStr(string[] columns) + { + return string.Join(", ", columns.Select(c => "`" + c + "`")); + } + private static string GetValuesUserStr(string[] columns, int i = 0) + { + return string.Join(", ", columns.Select(c => "@" + c + i.ToString())); + } + private static string GetSetUserStr(string[] columns, int i = 0) + { + return string.Join(", ", columns.Select(c => $"`{c}` = @{c}{i}")); + } + private const string UserSelect = + "`id` AS Id, `login` AS Login, `name` AS Name, " + + "`email` AS Email, `phone` AS Phone, `is_admin` AS IsAdmin, `disabled` AS Disabled"; + + private int EnsureOwnRow(MySqlConnection connection, MySqlTransaction transaction) + { + connection.Execute( + $"INSERT INTO `{UsersTable}` (`login`, `is_admin`) VALUES (@login, @is_admin) " + + "ON DUPLICATE KEY UPDATE `login` = `login`;", + new { login, is_admin = isAdmin }, transaction); + + // логин уникален на всю метабазу + return connection.ExecuteScalar( + $"SELECT `id` FROM `{UsersTable}` WHERE `login` = @login;", new { login }, transaction); + } + + #region Право на обновление базы + + public bool SetBaseUpdateRight(string baseName, LauncherUserInfo user, bool canUpdate) { + RequireAdminFor("изменения прав на обновление баз"); + if(user.Id <= 0) + throw new ArgumentException(UserNotFound, nameof(user)); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + int baseId = RequireBaseId(connection, baseName); + + connection.Execute( + $"INSERT INTO `{UpdateRightsTable}` (user_id, base_id, can_update) VALUES (@uid, @bid, @canUpdate) " + + "ON DUPLICATE KEY UPDATE can_update = VALUES(can_update);", + new { uid = user.Id, bid = baseId, canUpdate }); + } + return true; + } + + public bool RevokeBaseUpdateRight(string baseName, LauncherUserInfo user) { + RequireAdminFor("изменения прав на обновление баз"); + if(user.Id <= 0) + throw new ArgumentException(UserNotFound, nameof(user)); + + using(var connection = new MySqlConnection(connectionString)) { + connection.Open(); + int baseId = RequireBaseId(connection, baseName); + + connection.Execute($"DELETE FROM `{UpdateRightsTable}` WHERE user_id = @uid AND base_id = @bid;", + new { uid = user.Id, bid = baseId }); + } + return true; + } + + /// Создавший базу может её обновлять + public void GrantCreatorUpdateRight(MySqlConnection connection, MySqlTransaction transaction, int baseId) { + connection.Execute( + $"INSERT INTO `{UpdateRightsTable}` (user_id, base_id, can_update) VALUES (@user_id, @base_id, 1) " + + "ON DUPLICATE KEY UPDATE can_update = VALUES(can_update);", + new { user_id = EnsureOwnRow(connection, transaction), base_id = baseId }, transaction); + } + + private int RequireBaseId(MySqlConnection connection, string baseName) { + int baseId = bases.FindBaseId(connection, baseName); + if(baseId <= 0) + throw new ArgumentException(BaseNotFound, nameof(baseName)); + return baseId; + } + + #endregion + + private void RequireAdminFor(string action) { + // право на весь сервер; своей записи в метабазе при этом может и не быть + if(!isAdmin) + throw new UnauthorizedAccessException($"Недостаточно прав для {action}"); + } + } +} diff --git a/QS.DbManagement/QS.DbManagement.csproj b/QS.DbManagement/QS.DbManagement.csproj index 60196acee..fd48be251 100644 --- a/QS.DbManagement/QS.DbManagement.csproj +++ b/QS.DbManagement/QS.DbManagement.csproj @@ -14,6 +14,7 @@ + @@ -25,7 +26,10 @@ + + + diff --git a/QS.DbManagement/SqlDumpFileValidator.cs b/QS.DbManagement/SqlDumpFileValidator.cs new file mode 100644 index 000000000..ac8ccd771 --- /dev/null +++ b/QS.DbManagement/SqlDumpFileValidator.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +namespace QS.DbManagement { + public static class SqlDumpFileValidator { + private const int InspectBytes = 8 * 1024; + + private static readonly string[] SqlStartTokens = { + "--", "/*", "#", "CREATE", "INSERT", "REPLACE", "DROP", "ALTER", + "SET ", "USE ", "LOCK", "DELIMITER", "START TRANSACTION" + }; + + public static string Validate(string filePath) { + try { + EnsureLooksLikeSqlDump(filePath); + return null; + } + catch(Exception ex) when(ex is IOException || ex is InvalidDataException || ex is ArgumentException) { + return ex.Message; + } + } + + /// + /// Бросает исключение, если файл отсутствует, пуст, бинарный или не начинается с SQL синтаксиса + /// + public static void EnsureLooksLikeSqlDump(string filePath) { + if(string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("Не указан путь к файлу дампа", nameof(filePath)); + if(!File.Exists(filePath)) + throw new FileNotFoundException("Файл дампа не найден", filePath); + if(new FileInfo(filePath).Length == 0) + throw new InvalidDataException("Файл дампа пуст"); + + string head = ReadHead(filePath, InspectBytes); + + // Бинарный файл содержит нулевые байты + if(head.IndexOf('\0') >= 0) + throw new InvalidDataException( + "Файл не похож на SQL-дамп: это бинарный файл, а не текстовый SQL-скрипт"); + + string trimmed = head.TrimStart('', ' ', '\t', '\r', '\n'); + bool looksLikeSql = SqlStartTokens.Any( + token => trimmed.StartsWith(token, StringComparison.OrdinalIgnoreCase)); + if(!looksLikeSql) + throw new InvalidDataException( + "Файл не похож на SQL-дамп: в начале файла нет SQL-инструкций"); + } + + private static string ReadHead(string filePath, int maxBytes) { + using(var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { + int toRead = (int)Math.Min(maxBytes, stream.Length); + var buffer = new byte[toRead]; + int read = stream.Read(buffer, 0, toRead); + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + } +} diff --git a/QS.Launcher.Avalonia/DependencyInjection.cs b/QS.Launcher.Avalonia/DependencyInjection.cs index 6af20d57e..0fd566736 100644 --- a/QS.Launcher.Avalonia/DependencyInjection.cs +++ b/QS.Launcher.Avalonia/DependencyInjection.cs @@ -1,18 +1,16 @@ using Avalonia.Controls; using Microsoft.Extensions.DependencyInjection; +using QS.Dialog; using QS.Launcher.Views; using QS.Launcher.Views.Pages; +using QS.Launcher.Views.Pages.Database; namespace QS.Launcher; public static partial class DependencyInjection { public static IServiceCollection AddPages(this IServiceCollection services) { return services .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } - - } diff --git a/QS.Launcher.Avalonia/LauncherApp.axaml b/QS.Launcher.Avalonia/LauncherApp.axaml index e0f3cd31e..b850d3a74 100644 --- a/QS.Launcher.Avalonia/LauncherApp.axaml +++ b/QS.Launcher.Avalonia/LauncherApp.axaml @@ -2,6 +2,10 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters1="clr-namespace:QS.Project.Converters;assembly=QS.Project.Avalonia" + xmlns:vm="clr-namespace:QS.Launcher.ViewModels.PageViewModels;assembly=QS.Launcher" + xmlns:vmDb="clr-namespace:QS.Launcher.ViewModels.PageViewModels.Database;assembly=QS.Launcher" + xmlns:views="using:QS.Launcher.Views.Pages" + xmlns:viewsDb="using:QS.Launcher.Views.Pages.Database" x:CompileBindings="False" RequestedThemeVariant="Light"> diff --git a/QS.Launcher.Avalonia/LauncherApp.axaml.cs b/QS.Launcher.Avalonia/LauncherApp.axaml.cs index 1a7d531c5..4436804f2 100644 --- a/QS.Launcher.Avalonia/LauncherApp.axaml.cs +++ b/QS.Launcher.Avalonia/LauncherApp.axaml.cs @@ -1,6 +1,8 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using QS.Dialog; +using QS.ErrorReporting; using QS.Launcher.Views; using System; @@ -10,11 +12,17 @@ public partial class LauncherApp() : Application { public Func MainWindowGetter { get; set; } + /// Разбор ошибок для глобального перехватчика. Не задан - сообщение показывается без разбора + public IErrorHandlingService ErrorHandling { get; set; } + public override void Initialize() { AvaloniaXamlLoader.Load(this); } public override void OnFrameworkInitializationCompleted() { + RxAppExceptionHandler.Install(ErrorHandling); + DispatcherExceptionHandler.Install(ErrorHandling); + if (MainWindowGetter is null) throw new ArgumentNullException(nameof(MainWindowGetter)); diff --git a/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj b/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj index 0573db99d..8daf828ad 100644 --- a/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj +++ b/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj @@ -47,14 +47,29 @@ LauncherApp.axaml - - BaseManagementView.axaml - LoginView.axaml - - DataBasesView.axaml + + DatabasesView.axaml + + + CreateDbSettingsView.axaml + + + ImportDbSettingsView.axaml + + + BackupDbSettingsView.axaml + + + CreateDatabaseProgressView.axaml + + + ChangePasswordView.axaml + + + UsersView.axaml UserManagementView.axaml diff --git a/QS.Launcher.Avalonia/Views/MainWindow.axaml b/QS.Launcher.Avalonia/Views/MainWindow.axaml index b678546ae..f14ae2f03 100644 --- a/QS.Launcher.Avalonia/Views/MainWindow.axaml +++ b/QS.Launcher.Avalonia/Views/MainWindow.axaml @@ -3,7 +3,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:views="clr-namespace:QS.Launcher.Views" xmlns:vm="clr-namespace:QS.Launcher.ViewModels;assembly=QS.Launcher" Title="QS.Лаунчер" Width="450" @@ -15,13 +14,20 @@ x:DataType="vm:MainWindowVM" WindowStartupLocation="CenterScreen" mc:Ignorable="d"> - - - - - - - - - + + + + + + + + + + + diff --git a/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs b/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs index d17804705..56f391fff 100644 --- a/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs +++ b/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs @@ -1,22 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; using Avalonia.Controls; using Avalonia.Media.Imaging; using QS.Launcher.ViewModels; -using System.Collections.Generic; +using QS.Launcher.ViewModels.PageViewModels; + namespace QS.Launcher.Views; -public partial class MainWindow : Window { - public MainWindow(MainWindowVM vm, IEnumerable pages, LauncherOptions options) { +public partial class MainWindow : Window +{ + private readonly PageViewLocator viewLocator; + + public MainWindow(MainWindowVM vm, PageViewLocator viewLocator, LauncherOptions options) { InitializeComponent(); + this.viewLocator = viewLocator ?? throw new ArgumentNullException(nameof(viewLocator)); + Icon = new WindowIcon(new Bitmap(new System.IO.MemoryStream(options.LogoIcon))); Title = options.AppTitle; Closing += (_, _) => vm.SaveConnections(); - foreach(var page in pages) - carousel.Items.Add(page); - vm.PagesCount = carousel.ItemCount; + // корневые страницы: + foreach(var page in vm.Navigation.Pages) + carousel.Items.Add(viewLocator.Resolve(page)); + + vm.Navigation.Pages.CollectionChanged += OnPagesChanged; DataContext = vm; } + + /// + /// Поддерживает carousel.Items в соответствии со стеком + /// + private void OnPagesChanged(object? sender, NotifyCollectionChangedEventArgs e) { + switch(e.Action) { + case NotifyCollectionChangedAction.Add: + for(int i = 0; i < e.NewItems!.Count; i++) + carousel.Items.Insert(e.NewStartingIndex + i, + viewLocator.Resolve((CarouselPageVM)e.NewItems[i]!)); + break; + + case NotifyCollectionChangedAction.Remove: + for(int i = 0; i < e.OldItems!.Count; i++) + carousel.Items.RemoveAt(e.OldStartingIndex); + break; + + case NotifyCollectionChangedAction.Reset: + carousel.Items.Clear(); + foreach(var page in (IEnumerable)sender!) + carousel.Items.Add(viewLocator.Resolve(page)); + break; + } + } } diff --git a/QS.Launcher.Avalonia/Views/PageViewLocator.cs b/QS.Launcher.Avalonia/Views/PageViewLocator.cs new file mode 100644 index 000000000..962cb9ae0 --- /dev/null +++ b/QS.Launcher.Avalonia/Views/PageViewLocator.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using QS.Launcher.ViewModels.PageViewModels; +using QS.Launcher.ViewModels.PageViewModels.Database; +using QS.Launcher.Views.Pages; +using QS.Launcher.Views.Pages.Database; + +namespace QS.Launcher.Views; + +/// +/// Сопоставляет VM страницы её View, +/// чтобы добавить новый тип страницы, достаточно зарегистрировать здесь пару (VM, View) +/// +public class PageViewLocator { + private readonly Dictionary> factories; + + public PageViewLocator() { + factories = new Dictionary> { + [typeof(LoginVM)] = vm => new LoginView((LoginVM)vm), + [typeof(DatabasesVM)] = vm => new DatabasesView((DatabasesVM)vm), + [typeof(UsersVM)] = vm => new UsersView((UsersVM)vm), + [typeof(ChangePasswordVM)] = vm => new ChangePasswordView((ChangePasswordVM)vm), + [typeof(UserManagementVM)] = vm => new UserManagementView((UserManagementVM)vm), + [typeof(CreateDbSettingsVM)] = vm => new CreateDbSettingsView((CreateDbSettingsVM)vm), + [typeof(ImportDbSettingsVM)] = vm => new ImportDbSettingsView((ImportDbSettingsVM)vm), + [typeof(BackupDbSettingsVM)] = vm => new BackupDbSettingsView((BackupDbSettingsVM)vm), + [typeof(CreateDatabaseProgressVM)] = vm => new CreateDatabaseProgressView((CreateDatabaseProgressVM)vm), + }; + } + + public UserControl Resolve(CarouselPageVM page) { + if(page == null) + throw new ArgumentNullException(nameof(page)); + if(factories.TryGetValue(page.GetType(), out var factory)) + return factory(page); + throw new InvalidOperationException($"Не зарегистрирован View для страницы {page.GetType().Name}."); + } +} diff --git a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml b/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml deleted file mode 100644 index b779d92a1..000000000 --- a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml new file mode 100644 index 000000000..ae24ade2d --- /dev/null +++ b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml @@ -0,0 +1,27 @@ + + + + + + + + +