using System.IO; using System.Reflection; using System.Linq; using System; using System.Collections.Generic; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.OpenApi.Models; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.SwaggerGen; using AyaNova.Models; using AyaNova.Util; using AyaNova.Generator; using AyaNova.Biz; using NLog.Extensions.Logging; using StackExchange.Profiling; namespace AyaNova { public class Startup { ///////////////////////////////////////////////////////////// // public Startup(Microsoft.AspNetCore.Hosting.IWebHostEnvironment hostingEnvironment) { var nlogLoggerProvider = new NLogLoggerProvider(); _newLog = nlogLoggerProvider.CreateLogger("SERVER"); _hostingEnvironment = hostingEnvironment; AyaNova.Util.ApplicationLogging.LoggerProvider = nlogLoggerProvider; ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH = hostingEnvironment.ContentRootPath; ServerBootConfig.SEEDING = false; } private readonly ILogger _newLog; private string _connectionString = ""; private readonly Microsoft.AspNetCore.Hosting.IWebHostEnvironment _hostingEnvironment; //////////////////////////////////////////////////////////// // // public void ConfigureServices(IServiceCollection services) { _newLog.LogDebug("Initializing services..."); _newLog.LogDebug("Health"); services.AddHealthChecks().AddDbContextCheck(); ; _newLog.LogDebug("Profiler"); //https://dotnetthoughts.net/using-miniprofiler-in-aspnetcore-webapi/ services.AddMemoryCache(); services.AddMiniProfiler(options => { options.RouteBasePath = "/profiler"; //in testing only ignorepaths was reliable and worked and docs say it prevents any profiling at all options.IgnorePath("/auth").IgnorePath("/license").IgnorePath("/user").IgnorePath("/docs"); options.ResultsAuthorize = request => { if (request.HttpContext.Items["AY_PROFILER_ALLOWED"] != null) return true; return false; }; }).AddEntityFramework(); //Server state service for shutting people out of api _newLog.LogDebug("ServerState service"); services.AddSingleton(new AyaNova.Api.ControllerHelpers.ApiServerState()); _newLog.LogDebug("Mail service"); services.AddSingleton(); //Init controllers _newLog.LogDebug("Controllers"); var MvcBuilder = services.AddControllers(config => { // config.Filters.Add(new AyaNova.Api.ControllerHelpers.ApiCustomExceptionFilter(AyaNova.Util.ApplicationLogging.LoggerFactory)); config.Filters.Add(new AyaNova.Api.ControllerHelpers.ApiCustomExceptionFilter(_newLog)); }); //Prevent default model binding automatic 400 page so we can consistently show *our* error to our specs //https://docs.microsoft.com/en-us/aspnet/core/web-api/index?view=aspnetcore-3.1#automatic-http-400-responses MvcBuilder.ConfigureApiBehaviorOptions(options => { options.SuppressModelStateInvalidFilter = true; }); _newLog.LogDebug("JSON"); MvcBuilder.AddNewtonsoftJson(options => { options.SerializerSettings.Formatting = Newtonsoft.Json.Formatting.None; options.SerializerSettings.DateTimeZoneHandling = Newtonsoft.Json.DateTimeZoneHandling.Utc; }); //HTTP CLIENT FACTORY USED BY LICENSE.CS //https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-3.1 _newLog.LogDebug("HTTPClientFactory"); services.AddHttpClient(); _newLog.LogDebug("Ensuring user and backup folders exist and are separate locations..."); FileUtil.EnsureUserAndUtilityFoldersExistAndAreNotIdentical(_hostingEnvironment.ContentRootPath); //TODO: ENsure report files here #region DATABASE _connectionString = ServerBootConfig.AYANOVA_DB_CONNECTION; //Check DB server exists and can be connected to _newLog.LogDebug("Testing database server connection..."); //parse the connection string properly DbUtil.ParseConnectionString(_newLog, _connectionString); //Probe for database server //Will retry every 5 seconds for up to 5 minutes before bailing if (!DbUtil.DatabaseServerExists(_newLog, "Waiting for db server ")) { var err = $"E1000 - AyaNova can't connect to the database server after trying for 5 minutes (connection string is:\"{DbUtil.DisplayableConnectionString}\")"; _newLog.LogCritical(err); throw new System.ApplicationException(err); } _newLog.LogInformation("Connected to database server - {0}", DbUtil.DisplayableConnectionString); //ensure database is ready and present DbUtil.EnsureDatabaseExists(_newLog); bool LOG_SENSITIVE_DATA = false; #if (DEBUG) LOG_SENSITIVE_DATA = false;//############################################################################ #endif _newLog.LogDebug("EF Core"); //change to resolve error: //2020-12-28 09:20:14.3545|WARN|Microsoft.EntityFrameworkCore.Infrastructure|'AddEntityFramework*' was called on the service provider, but 'UseInternalServiceProvider' wasn't called in the DbContext options configuration. Consider removing the 'AddEntityFramework*' call, as in most cases it's not needed and may cause conflicts with other products and services registered in the same service provider. //https://stackoverflow.com/questions/62917136/addentityframework-was-called-on-the-service-provider-but-useinternalservic // services.AddEntityFrameworkNpgsql().AddDbContext( // options => options.UseNpgsql(_connectionString // //,opt => opt.EnableRetryOnFailure()//REMOVED THIS BECAUSE IT WAS INTEFERING WITH TRANSACTIONS BUT THEN DIDN'T USE THE TRANSACTION BUT IT SEEMS FASTER WITHOUT IT AS WELL SO...?? // )//http://www.npgsql.org/efcore/misc.html?q=execution%20strategy#execution-strategy // .ConfigureWarnings(warnings => //https://livebook.manning.com/#!/book/entity-framework-core-in-action/chapter-12/v-10/85 // warnings.Throw( //Throw an exception on client eval, not necessarily an error but a smell // // Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.QueryClientEvaluationWarning // )) // .EnableSensitiveDataLogging(LOG_SENSITIVE_DATA) // ); services.AddDbContext( options => options.UseNpgsql(_connectionString //,opt => opt.EnableRetryOnFailure()//REMOVED THIS BECAUSE IT WAS INTEFERING WITH TRANSACTIONS BUT THEN DIDN'T USE THE TRANSACTION BUT IT SEEMS FASTER WITHOUT IT AS WELL SO...?? )//http://www.npgsql.org/efcore/misc.html?q=execution%20strategy#execution-strategy .ConfigureWarnings(warnings => //https://livebook.manning.com/#!/book/entity-framework-core-in-action/chapter-12/v-10/85 warnings.Throw( //Throw an exception on client eval, not necessarily an error but a smell // Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.QueryClientEvaluationWarning )) .EnableSensitiveDataLogging(LOG_SENSITIVE_DATA) ); #endregion // Add service and create Policy with options _newLog.LogDebug("CORS"); services.AddCors(options => { options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader().SetPreflightMaxAge(TimeSpan.FromSeconds(600)) ); }); #region Swagger services .AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = Microsoft.AspNetCore.Mvc.ApiVersion.Parse("8.0"); options.ReportApiVersions = true; }); services.AddVersionedApiExplorer(options => { // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service // note: the specified format code will format the version as "'v'major[.minor][-status]" options.GroupNameFormat = "'v'VVV"; // note: this option is only necessary when versioning by url segment. the SubstitutionFormat // can also be used to control the format of the API version in route templates //THIS IS WHAT ADDS THE API version PARAMETER AUTOMATICALLY so you don't need to type an 8 in every swagger-ui route test options.SubstituteApiVersionInUrl = true; }); services.AddTransient, ConfigureSwaggerOptions>(); services.AddSwaggerGen( c => { // integrate xml comments c.IncludeXmlComments(XmlCommentsFilePath); //https://stackoverflow.com/questions/56234504/migrating-to-swashbuckle-aspnetcore-version-5 //First we define the security scheme c.AddSecurityDefinition("Bearer", //Name the security scheme new OpenApiSecurityScheme { Description = "JWT Authorization header using the Bearer scheme.", Type = SecuritySchemeType.Http, //We set the scheme type to http since we're using bearer authentication Scheme = "bearer" //The name of the HTTP Authorization scheme to be used in the Authorization header. In this case "bearer". }); c.AddSecurityRequirement(new OpenApiSecurityRequirement{ { new OpenApiSecurityScheme{ Reference = new OpenApiReference{ Id = "Bearer", //The name of the previously defined security scheme. Type = ReferenceType.SecurityScheme } },new List() } }); } ); #endregion #region JWT AUTHENTICATION //get the key if specified var secretKey = ServerBootConfig.AYANOVA_JWT_SECRET; //If no key specified make a unique one //This means the jwt creds won't survive a server reboot //so in that case users need to specify an AyaNova_JWT_SECRET environment variable if (string.IsNullOrWhiteSpace(secretKey)) { secretKey = Util.Hasher.GenerateSalt(); } //WAS "UNLICENSED5G*QQJ8#bQ7$Xr_@sXfHq4" //If secretKey is less than 32 characters, pad it if (secretKey.Length < 32) { secretKey = secretKey.PadRight(32, '-'); } ServerBootConfig.AYANOVA_JWT_SECRET = secretKey; var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET)); _newLog.LogDebug("Authorization"); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(options => { // options.AutomaticAuthenticate = true; // options.AutomaticChallenge = true; options.TokenValidationParameters = new TokenValidationParameters { // Token signature will be verified using a private key. ValidateIssuerSigningKey = true, RequireSignedTokens = true, IssuerSigningKey = signingKey, ValidateIssuer = true, ValidIssuer = "ayanova.com", ValidateAudience = false, //ValidAudience = "http://localhost:7575/" // Token will only be valid if not expired yet, with 5 minutes clock skew. ValidateLifetime = true, RequireExpirationTime = true, ClockSkew = new TimeSpan(0, 5, 0), }; }); #endregion _newLog.LogDebug("Generator"); services.AddSingleton(); } //////////////////////////////////////////////////////////// // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IWebHostEnvironment env, AyContext dbContext, IApiVersionDescriptionProvider provider, AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, IServiceProvider serviceProvider) { _newLog.LogDebug("Configuring request pipeline..."); //this *may* be useful in the event of an issue so uncomment if necessary but errors during dev are handled equally by the logging, I think // if (env.IsDevelopment()) // { // app.UseDeveloperExceptionPage(); // } //Store a reference to the dependency injection service for static classes ServiceProviderProvider.Provider = app.ApplicationServices; //Enable ability to handle reverse proxy app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto }); //If want to add a header to all (or filtered in here) requests // app.Use(async (context, next) => // { // context.Response.Headers.Add("X-Developed-By", "Ground Zero Tech-Works inc."); // await next.Invoke(); // }); #region STATIC FILES _newLog.LogDebug("Static files"); app.UseDefaultFiles(); app.UseStaticFiles(); //Might need the following if the page doesn't update in the client properly //however the vue build process will automatically uniquify each build file names so maybe not required // app.UseStaticFiles(new StaticFileOptions // { // OnPrepareResponse = context => // { // if (context.File.Name == "kindex.html") // { // context.Context.Response.Headers.Add("Cache-Control", "no-cache, no-store"); // context.Context.Response.Headers.Add("Expires", "-1"); // } // } // }); #endregion _newLog.LogDebug("Routing pipeline"); app.UseRouting();//this wasn't here for 2.2 but added for 3.0, needs to come before the stuff after _newLog.LogDebug("CORS pipeline"); app.UseCors("CorsPolicy"); #region AUTH / ROLES CUSTOM MIDDLEWARE _newLog.LogDebug("Authentication pipeline"); //Use authentication middleware app.UseAuthentication(); _newLog.LogDebug("Authorization pipeline"); app.UseAuthorization(); //Custom middleware to ensure token still valid and to //get user roles and put them into the request so //they can be authorized in routes. app.Use(async (context, next) => { if (!context.User.Identity.IsAuthenticated) { #region Profiler workaround //Is this a profiler route? If so we're going to use the dl token to authorize if (context.Request.Path.Value.StartsWith("/profiler/results")) { //someone is requesting the profiler //check for a dl token "t" and rehydrate user if found //Note that the profiler UI triggers it's own requests to to get the token //we need to check the referer which was the first page of profile string token = string.Empty; //is the token in the request? if (context.Request.Query.ContainsKey("t")) { token = context.Request.Query["t"].ToString(); } else if (context.Request.Headers["Referer"].Count > 0) {//Maybe it's in the referer //try to split it on the ? string[] stuff = context.Request.Headers["Referer"].ToString().Split('?'); if (stuff.Count() > 1) { var q = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(stuff[1]); if (q.ContainsKey("t")) { token = q["t"].ToString(); } } } if (!string.IsNullOrWhiteSpace(token)) { using (AyContext ct = ServiceProviderProvider.DBContext) { var u = ct.User.AsNoTracking().SingleOrDefault(z => z.DlKey == token.ToString() && z.Active == true); if (u != null) { //this is necessary because they might have an expired JWT but this would just keep on working without a date check //the default is the same timespan as the jwt so it's all good var utcNow = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero); if (u.DlKeyExpire > utcNow.DateTime) { if (AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(u.Roles, AyaType.ServerMetrics)) context.Request.HttpContext.Items["AY_PROFILER_ALLOWED"] = true; } } } } } #endregion profiler workaround context.Request.HttpContext.Items["AY_ROLES"] = 0; await next.Invoke(); } else { //Get user ID from claims long userId = Convert.ToInt64(context.User.FindFirst(c => c.Type == "id").Value); //Get JWT string JWT = string.Empty; var AuthHeaders = context.Request.Headers[Microsoft.Net.Http.Headers.HeaderNames.Authorization]; foreach (String s in AuthHeaders) { if (s.ToLowerInvariant().Contains("bearer")) { JWT = s.Split(' ')[1]; break; } } //Get the database context var ct = context.RequestServices.GetService(); //get the user record var u = await ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync(); context.Request.HttpContext.Items["AY_ROLES"] = u.roles; context.Request.HttpContext.Items["AY_USERNAME"] = u.name; context.Request.HttpContext.Items["AY_USER_ID"] = u.id; context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = u.translationId; //turned out didn't need this for v8 migrate so far, but keeping in case it turns out to be handy down the road // //Is import mode header set? // if (context.Request.Headers.ContainsKey("X-AY-Import-Mode")) // context.Request.HttpContext.Items["AY_IMPORT_MODE"] = true; //CHECK JWT if ( !context.Request.Path.Value.EndsWith("/auth") && !context.Request.Path.Value.EndsWith("notify/hello") && u.currentAuthToken != JWT )//except "/api/v8/auth" and prelogin notify/hello routes so user can login { context.Response.StatusCode = 401; context.Response.Headers.Add("X-AyaNova-Authorization-Error", "E2006 - Authorization token replaced by more recent login"); await context.Response.WriteAsync("E2006 - Authorization token replaced by more recent login"); } else { await next.Invoke(); } } }); #endregion _newLog.LogDebug("Profiler"); app.UseMiniProfiler(); _newLog.LogDebug("Endpoints pipeline"); app.UseEndpoints(endpoints => { endpoints.MapHealthChecks("/health"); endpoints.MapControllers(); }); #region SWAGGER _newLog.LogDebug("API explorer pipeline"); // Enable middleware to serve generated Swagger as a JSON endpoint. app.UseSwagger(); app.UseSwaggerUI( options => { foreach (var description in provider.ApiVersionDescriptions) { options.SwaggerEndpoint( $"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant()); } options.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.None); options.DefaultModelsExpandDepth(-1);//This is meant to hide the Models section that would appear at the bottom of the swagger ui showing *all* models from the api options.DocumentTitle = "AyaNova API explorer"; options.RoutePrefix = "api-docs"; }); #endregion swagger // ****************************************************************** // ******************** TESTING WIPE DB ***************************** // //Set this to true to wipe the db and reinstall a trial license and re-seed the data //DEPRECATED, now use boot setting AYANOVA_SERVER_TEST_MODE // var TESTING_REFRESH_DB = true;//####################################################################################### // #if (DEBUG) // //TESTING // if (TESTING_REFRESH_DB) // ServerBootConfig.AYANOVA_PERMANENTLY_ERASE_DATABASE = TESTING_REFRESH_DB; // //TESTING // #endif if (ServerBootConfig.AYANOVA_PERMANENTLY_ERASE_DATABASE || ServerBootConfig.AYANOVA_SERVER_TEST_MODE) { if (ServerBootConfig.AYANOVA_SERVER_TEST_MODE) { _newLog.LogWarning("AYANOVA_SERVER_TEST_MODE, dropping and recreating database"); } else { _newLog.LogWarning("AYANOVA_PERMANENTLY_ERASE_DATABASE, dropping and recreating database"); } Util.DbUtil.DropAndRecreateDbAsync(_newLog).Wait(); AySchema.CheckAndUpdateAsync(dbContext, _newLog).Wait(); } //Check schema _newLog.LogDebug("DB schema check"); AySchema.CheckAndUpdateAsync(dbContext, _newLog).Wait(); //Check database integrity _newLog.LogDebug("DB integrity check"); DbUtil.CheckFingerPrintAsync(AySchema.EXPECTED_COLUMN_COUNT, AySchema.EXPECTED_INDEX_COUNT, _newLog).Wait(); //Initialize license AyaNova.Core.License.InitializeAsync(apiServerState, dbContext, _newLog).Wait(); //Set static global biz settings _newLog.LogDebug("Global settings"); ServerGlobalBizSettings.Initialize(null, dbContext); _newLog.LogDebug("Ops settings"); ServerGlobalOpsSettingsCache.Initialize(dbContext); //Ensure translations are present, not missing any keys and that there is a server default translation that exists TranslationBiz lb = new TranslationBiz(dbContext, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.OpsAdminFull); lb.ValidateTranslationsAsync().Wait(); //TESTING if (ServerBootConfig.AYANOVA_SERVER_TEST_MODE) { _newLog.LogInformation($"Server test mode seeding, level is {ServerBootConfig.AYANOVA_SERVER_TEST_MODE_SEEDLEVEL}, tz offset is {ServerBootConfig.AYANOVA_SERVER_TEST_MODE_TZ_OFFSET}"); #if (DEBUG) AyaNova.Core.License.FetchKeyAsync(apiServerState, dbContext, _newLog, true, true).Wait(); #else AyaNova.Core.License.FetchKeyAsync(apiServerState, dbContext, _newLog, true).Wait(); #endif //NOTE: For unit testing make sure the time zone is same as tester to ensure list filter by date tests will work because server is on same page as user in terms of time var seed = new Util.Seeder(); seed.SeedDatabaseAsync(Seeder.Level.StringToSeedLevel(ServerBootConfig.AYANOVA_SERVER_TEST_MODE_SEEDLEVEL), ServerBootConfig.AYANOVA_SERVER_TEST_MODE_TZ_OFFSET).Wait(); } //TESTING //SPA FALLBACK ROUTE app.Use(async (context, next) => { //to support html5 pushstate routing in spa //this ensures that a refresh at the client will not 404 but rather force back to the index.html app page and then handled internally by the client await next(); if (!context.Response.HasStarted && !context.Request.Path.Value.StartsWith("/api") && context.Request.Path.Value != "/docs" && context.Response.StatusCode == 404 && !Path.HasExtension(context.Request.Path.Value)) { context.Request.Path = "/index.html"; context.Response.StatusCode = 200; context.Response.ContentType = "text/html"; await context.Response.SendFileAsync(Path.Combine(env.WebRootPath, "index.html")); } }); //Log the active user count so it's in the log record _newLog.LogInformation($"Active techs - {UserBiz.ActiveCountAsync().Result}"); //Log the license info so it's on the record _newLog.LogInformation($"License - [{AyaNova.Core.License.LicenseInfoLogFormat}]"); //Check for SuperUser password override if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_SET_SUPERUSER_PW)) { _newLog.LogWarning($"### AYANOVA_SET_SUPERUSER_PW IS PRESENT - RESETTING SUPERUSER PASSWORD NOW... ###"); AyaNova.Biz.UserBiz.ResetSuperUserPassword(); _newLog.LogWarning($"### AYANOVA_SET_SUPERUSER_PW HAS BEEN USED TO RESET SUPER USER PASSWORD YOU CAN REMOVE THIS SETTING NOW ###"); } //Boot lock for generator ServerGlobalOpsSettingsCache.BOOTING = false; //Open up the server for visitors _newLog.LogDebug("Setting server state open"); apiServerState.SetOpen(); //final startup log _newLog.LogInformation("Boot complete - server open"); //flag in console that server is open Console.WriteLine("BOOT: COMPLETED - SERVER IS NOW OPEN"); Console.WriteLine("(ctrl-c here will force stop AyaNova server. Controlled shut down via AyaNova APP Operations ServerState page)"); } #region Swagger and API Versioning utilities static string XmlCommentsFilePath { get { var basePath = AppContext.BaseDirectory; var fileName = typeof(Startup).GetTypeInfo().Assembly.GetName().Name + ".xml"; return Path.Combine(basePath, fileName); } } #endregion } }