using Asp.Versioning; using Cronos; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Mirea.Api.Dto.Common; using Mirea.Api.Dto.Requests; using Mirea.Api.Dto.Requests.Configuration; using Mirea.Api.Dto.Responses; using Mirea.Api.Dto.Responses.Configuration; using Mirea.Api.Endpoint.Common.Attributes; using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Interfaces; using Mirea.Api.Endpoint.Common.MapperDto; using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; using Mirea.Api.Endpoint.Configuration.Validation.Validators; using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Services; using MySqlConnector; using Npgsql; using Serilog; using StackExchange.Redis; using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Net.Mail; using System.Runtime.InteropServices; using System.Security; using System.Security.Cryptography; using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy; namespace Mirea.Api.Endpoint.Controllers.Configuration; [ApiVersion("1.0")] [MaintenanceModeIgnore] [ApiExplorerSettings(IgnoreApi = true)] public class SetupController( ISetupToken setupToken, IMaintenanceModeNotConfigureService notConfigureService, IMemoryCache cache, PasswordHashService passwordHashService, IOptionsSnapshot user) : BaseController { private const string CacheGeneralKey = "config_general"; private const string CacheAdminKey = "config_admin"; private GeneralConfig GeneralConfig { get => cache.Get(CacheGeneralKey) ?? new GeneralConfig(); set => cache.Set(CacheGeneralKey, value); } [HttpGet("GenerateToken")] [Localhost] public ActionResult GenerateToken() { if (!notConfigureService.IsMaintenanceMode) throw new ControllerArgumentException( "The token cannot be generated because the server has been configured. " + $"If you need to restart the configuration, then delete the \"{GeneralConfig.FilePath}\" file and restart the application."); var token = new byte[32]; RandomNumberGenerator.Create().GetBytes(token); setupToken.SetToken(token); return Ok(Convert.ToBase64String(token)); } [HttpGet("IsConfigured")] public ActionResult IsConfigured() => !notConfigureService.IsMaintenanceMode; [HttpGet("CheckToken")] public ActionResult CheckToken([FromQuery] string token) { byte[] tokenBase64; try { tokenBase64 = Convert.FromBase64String(token); } catch (FormatException) { throw new ControllerArgumentException("A token of the wrong format."); } if (!setupToken.MatchToken(tokenBase64)) throw new SecurityException("The token is not valid"); Response.Cookies.Append(TokenAuthenticationAttribute.AuthToken, token, new CookieOptions { Path = UrlHelper.GetSubPathWithoutFirstApiName + "api", Domain = HttpContext.GetCurrentDomain(), HttpOnly = true, #if !DEBUG Secure = true #endif }); return Ok(true); } [HttpGet("IsConfiguredToken")] [TokenAuthentication] public ActionResult IsConfiguredToken() => Ok(true); private void SetDatabase(string connectionString, DatabaseType databaseType) where TConnection : class, IDbConnection, new() where TException : Exception { try { using (var connection = new TConnection()) { connection.ConnectionString = connectionString; connection.Open(); connection.Close(); if (connection is SqliteConnection) SqliteConnection.ClearAllPools(); } var general = GeneralConfig; general.DbSettings = new DbSettings { ConnectionStringSql = connectionString, TypeDatabase = databaseType }; GeneralConfig = general; } catch (TException ex) { throw new ControllerArgumentException($"Error when connecting: {ex.Message}"); } } [HttpPost("SetPsql")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetPsql([FromBody] DatabaseRequest request) { string connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}"; if (request.Password != null) connectionString += $";Password={request.Password}"; if (request.Ssl) connectionString += ";SSL Mode=Require;"; SetDatabase(connectionString, DatabaseType.PostgresSql); cache.Set("database", new DatabaseResponse { Type = DatabaseType.PostgresSql, Database = request.Database, Password = request.Password, Ssl = request.Ssl, Port = request.Port, Server = request.Server, User = request.User }); return Ok(true); } [HttpPost("SetMysql")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetMysql([FromBody] DatabaseRequest request) { string connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};"; if (request.Password != null) connectionString += $"Pwd={request.Password};"; if (request.Ssl) connectionString += "SslMode=Require;"; SetDatabase(connectionString, DatabaseType.Mysql); cache.Set("database", new DatabaseResponse { Type = DatabaseType.Mysql, Database = request.Database, Password = request.Password, Ssl = request.Ssl, Port = request.Port, Server = request.Server, User = request.User }); return Ok(true); } [HttpPost("SetSqlite")] [TokenAuthentication] public ActionResult SetSqlite([FromQuery] string? path) { if (string.IsNullOrEmpty(path)) path = "database"; path = PathBuilder.Combine(path); if (!Directory.Exists(path)) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Directory.CreateDirectory(path); else Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } else if (Directory.GetDirectories(path).Length != 0 || !Directory.GetFiles(path).Select(x => string.Equals(Path.GetFileName(x), "database.db3")).All(x => x)) { throw new ControllerArgumentException("Such a folder exists. Enter a different name"); } var filePath = Path.Combine(path, "database.db3"); var connectionString = $"Data Source={filePath}"; SetDatabase(connectionString, DatabaseType.Sqlite); foreach (var file in Directory.GetFiles(path)) System.IO.File.Delete(file); cache.Set("database", new DatabaseResponse { Type = DatabaseType.Sqlite, PathToDatabase = path }); return Ok(true); } [HttpGet("DatabaseConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DatabaseConfiguration() => cache.TryGetValue("database", out var response) ? Ok(response) : NoContent(); [HttpPost("SetRedis")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetRedis([FromBody] CacheRequest request) { string connectionString = $"{request.Server}:{request.Port},ssl=false"; if (request.Password != null) connectionString += $",password={request.Password}"; try { var redis = ConnectionMultiplexer.Connect(connectionString); redis.Close(); var general = GeneralConfig; general.CacheSettings = new CacheSettings { ConnectionString = connectionString, TypeDatabase = CacheType.Redis }; GeneralConfig = general; cache.Set("cache", new CacheResponse { Type = CacheType.Redis, Server = request.Server, Password = request.Password, Port = request.Port }); return Ok(true); } catch (Exception ex) { throw new ControllerArgumentException("Error when connecting to Redis: " + ex.Message); } } [HttpPost("SetMemcached")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetMemcached() { var general = GeneralConfig; general.CacheSettings = new CacheSettings { ConnectionString = null, TypeDatabase = CacheType.Memcached }; GeneralConfig = general; cache.Set("cache", new CacheResponse { Type = CacheType.Memcached }); return Ok(true); } [HttpGet("CacheConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CacheConfiguration() => cache.TryGetValue("cache", out var response) ? Ok(response) : NoContent(); [HttpPost("CreateAdmin")] [TokenAuthentication] [BadRequestResponse] public ActionResult CreateAdmin([FromBody] CreateUserRequest userRequest) { new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password); if (!MailAddress.TryCreate(userRequest.Email, out _)) throw new ControllerArgumentException("The email address is incorrect."); var (salt, hash) = passwordHashService.HashPassword(userRequest.Password); var admin = new Admin { Username = userRequest.Username, Email = userRequest.Email, PasswordHash = hash, Salt = salt }; cache.Set(CacheAdminKey, admin); return Ok(true); } [HttpGet("UpdateAdminConfiguration")] [TokenAuthentication] public ActionResult UpdateAdminConfiguration() { if (string.IsNullOrEmpty(user.Value.Email)) return Ok(); if (!cache.TryGetValue(CacheAdminKey, out var admin)) { admin = user.Value; cache.Set(CacheAdminKey, admin); return Ok(); } admin!.OAuthProviders = user.Value.OAuthProviders; if (string.IsNullOrEmpty(admin.Email)) admin.Email = user.Value.Email; if (string.IsNullOrEmpty(admin.Username)) admin.Username = user.Value.Username; cache.Set(CacheAdminKey, admin); return Ok(); } [HttpGet("AdminConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult AdminConfiguration() => cache.TryGetValue(CacheAdminKey, out var admin) ? Ok(new UserResponse() { Email = admin!.Email, Username = admin.Username, TwoFactorAuthenticatorEnabled = admin.TwoFactorAuthenticator != TwoFactorAuthenticator.None, UsedOAuthProviders = admin.OAuthProviders == null ? [] : admin.OAuthProviders.Keys.Select(x => x.ConvertToDto()) }) : NoContent(); [HttpGet("GenerateTotpKey")] [TokenAuthentication] public ActionResult GenerateTotpKey() { if (cache.TryGetValue("totpSecret", out var secret)) return secret!; secret = GeneratorKey.GenerateAlphaNumericBase32Compatible(16); cache.Set("totpSecret", secret); return secret; } [HttpGet("VerifyTotp")] [TokenAuthentication] public ActionResult VerifyTotp([FromQuery] string code) { var isCorrect = cache.TryGetValue("totpSecret", out var secret) && new TotpService(secret!).VerifyToken(code); if (!isCorrect || !cache.TryGetValue(CacheAdminKey, out var admin)) return false; admin!.Secret = secret; admin.TwoFactorAuthenticator = TwoFactorAuthenticator.Totp; cache.Set(CacheAdminKey, admin); return true; } [HttpPost("SetLogging")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetLogging([FromBody] LoggingRequest? request = null) { var settings = (request == null) switch { true => new LogSettings { EnableLogToFile = true }, false => new LogSettings { EnableLogToFile = request.EnableLogToFile, LogFileName = request.LogFileName, LogFilePath = request.LogFilePath } }; if (!string.IsNullOrEmpty(request?.ApiServerSeq)) { settings.ApiServerSeq = request.ApiServerSeq; settings.ApiKeySeq = request.ApiKeySeq; try { Log.Logger = new LoggerConfiguration() .WriteTo.Seq(settings.ApiServerSeq, apiKey: settings.ApiKeySeq) .CreateLogger(); Log.Warning("Testing configuration Seq."); } catch { // ignoring } finally { Log.CloseAndFlush(); } } if (settings.EnableLogToFile) { if (string.IsNullOrEmpty(settings.LogFileName)) settings.LogFileName = "log-"; if (string.IsNullOrEmpty(settings.LogFilePath)) settings.LogFilePath = OperatingSystem.IsWindows() || PathBuilder.IsDefaultPath ? PathBuilder.Combine("logs") : "/var/log/mirea"; } var general = GeneralConfig; general.LogSettings = settings; GeneralConfig = general; cache.Set("logging", new LoggingRequest { EnableLogToFile = settings.EnableLogToFile, LogFileName = settings.LogFileName, LogFilePath = settings.LogFilePath, ApiKeySeq = settings.ApiKeySeq, ApiServerSeq = settings.ApiServerSeq }); return true; } [HttpGet("LoggingConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult LoggingConfiguration() => cache.TryGetValue("logging", out var data) ? Ok(data) : NoContent(); [HttpPost("SetEmail")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetEmail([FromBody] EmailRequest? request = null) { var settings = (request == null) switch { true => new EmailSettings(), false => new EmailSettings { Server = request.Server, From = request.From, Password = request.Password, Port = request.Port, Ssl = request.Ssl, User = request.User } }; var general = GeneralConfig; general.EmailSettings = settings; GeneralConfig = general; cache.Set("email", settings); return true; } [HttpGet("EmailConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult EmailConfiguration() => cache.TryGetValue("email", out var data) ? Ok(data) : NoContent(); [HttpPost("SetSchedule")] [TokenAuthentication] [BadRequestResponse] public ActionResult SetSchedule([FromBody] ScheduleConfigurationRequest request) { var general = GeneralConfig; general.ScheduleSettings = new ScheduleSettings { // every 6 hours CronUpdateSchedule = request.CronUpdateSchedule ?? "0 */6 * * *", StartTerm = request.StartTerm, PairPeriod = new Dictionary { {1, new ScheduleSettings.PairPeriodTime(new TimeOnly(9, 0, 0), new TimeOnly(10, 30, 0))}, {2, new ScheduleSettings.PairPeriodTime(new TimeOnly(10, 40, 0), new TimeOnly(12, 10, 0))}, {3, new ScheduleSettings.PairPeriodTime(new TimeOnly(12, 40, 0), new TimeOnly(14, 10, 0))}, {4, new ScheduleSettings.PairPeriodTime(new TimeOnly(14, 20, 0), new TimeOnly(15, 50, 0))}, {5, new ScheduleSettings.PairPeriodTime(new TimeOnly(16, 20, 0), new TimeOnly(17, 50, 0))}, {6, new ScheduleSettings.PairPeriodTime(new TimeOnly(18, 0, 0), new TimeOnly(19, 30, 0))}, {7, new ScheduleSettings.PairPeriodTime(new TimeOnly(19, 40, 0), new TimeOnly(21, 10, 0))}, } }; if (!CronExpression.TryParse(general.ScheduleSettings.CronUpdateSchedule, CronFormat.Standard, out _)) throw new ControllerArgumentException("The Cron task could not be parsed. Check the format of the entered data."); GeneralConfig = general; cache.Set("schedule", new ScheduleConfigurationRequest() { StartTerm = general.ScheduleSettings.StartTerm, CronUpdateSchedule = general.ScheduleSettings.CronUpdateSchedule }); return true; } [HttpGet("ScheduleConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult ScheduleConfiguration() => cache.TryGetValue("schedule", out var data) ? Ok(data) : NoContent(); [HttpPost("SetPasswordPolicy")] [TokenAuthentication] public ActionResult SetPasswordPolicy([FromBody] PasswordPolicy? policy = null) { GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Domain.PasswordPolicy(); cache.Set("password", true); return true; } [HttpGet("PasswordPolicyConfiguration")] [TokenAuthentication] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PasswordPolicyConfiguration() => cache.TryGetValue("password", out _) ? Ok(GeneralConfig.PasswordPolicy) : NoContent(); [HttpPost("Submit")] [TokenAuthentication] [BadRequestResponse] public ActionResult Submit() { if (!new SettingsRequiredValidator(GeneralConfig).AreSettingsValid()) throw new ControllerArgumentException("The necessary data has not been configured."); if (!cache.TryGetValue(CacheAdminKey, out Admin? admin) || admin == null) throw new ControllerArgumentException("The administrator's data was not set."); admin.SaveSetting(); GeneralConfig.SaveSetting(); return true; } }