Files
raven/server/AyaNova/Startup.cs
2019-10-28 21:21:42 +00:00

509 lines
22 KiB
C#

using System.IO;
using System.Reflection;
using System.Linq;
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
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.Web;
using NLog.Targets;
using NLog.Config;
using NLog.Extensions.Logging;
namespace AyaNova
{
public class Startup
{
/////////////////////////////////////////////////////////////
//
public Startup(Microsoft.AspNetCore.Hosting.IWebHostEnvironment hostingEnvironment)
{//ILogger<Startup> logger, ILoggerFactory logFactory,
// Get the factory for ILogger instances.
var nlogLoggerProvider = new NLogLoggerProvider();
// Create an ILogger.
_newLog = nlogLoggerProvider.CreateLogger(typeof(Startup).FullName);
//x_log = logger;
_hostingEnvironment = hostingEnvironment;
//AyaNova.Util.ApplicationLogging.LoggerFactory = logFactory;
//AyaNova.Util.ApplicationLogging.theLogger = _newLog;
AyaNova.Util.ApplicationLogging.LoggerProvider = nlogLoggerProvider;
//this must be set here
ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH = hostingEnvironment.ContentRootPath;
}
private readonly ILogger _newLog;
// private readonly ILogger<Startup>x_log;
private string _connectionString = "";
private readonly Microsoft.AspNetCore.Hosting.IWebHostEnvironment _hostingEnvironment;
////////////////////////////////////////////////////////////
// This method gets called by the runtime. Use this method to add services to the container.
//
public void ConfigureServices(IServiceCollection services)
{
_newLog.LogDebug("BOOT: initializing services...");
//Server state service for shutting people out of api
_newLog.LogDebug("BOOT: init ApiServerState service");
services.AddSingleton(new AyaNova.Api.ControllerHelpers.ApiServerState());
//Init controllers
_newLog.LogDebug("BOOT: init 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("BOOT: init JSON");
MvcBuilder.AddNewtonsoftJson(options =>
{
options.SerializerSettings.DateTimeZoneHandling = Newtonsoft.Json.DateTimeZoneHandling.Utc;
});
//2019-10-17 METRICS will not work just yet with .netcore 3.1 see here https://github.com/AppMetrics/AppMetrics/issues/480
//awaiting a new release from them
_newLog.LogDebug("BOOT: init Metrics service");
services.AddMetrics();
_newLog.LogDebug("BOOT: ensuring user and backup folders exist and are separate locations...");
FileUtil.EnsureUserAndUtilityFoldersExistAndAreNotIdentical(_hostingEnvironment.ContentRootPath);
#region DATABASE
_connectionString = ServerBootConfig.AYANOVA_DB_CONNECTION;
//Check DB server exists and can be connected to
_newLog.LogDebug("BOOT: Testing database server connection...");
//parse the connection string properly
DbUtil.ParseConnectionString(_newLog, _connectionString);
//Probe for database server
//Will retry every 10 seconds for up to 5 minutes before bailing
if (!DbUtil.DatabaseServerExists(_newLog, "BOOT: waiting for db server "))
{
var err = $"BOOT: 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("BOOT: 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 = true;
#endif
_newLog.LogDebug("BOOT: init EF service");
services.AddEntityFrameworkNpgsql().AddDbContext<AyContext>(
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("BOOT: init CORS service");
services.AddCors(options =>
{
options.AddPolicy("CorsPolicy",
builder => builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
//.AllowCredentials()
);
});
#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<IConfigureOptions<SwaggerGenOptions>, 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<string>()
}
});
}
);
#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("BOOT: init Authorization service");
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("BOOT: init Generator service");
services.AddSingleton<IHostedService, GeneratorService>();
}
////////////////////////////////////////////////////////////
// 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("BOOT: 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
});
#region STATIC FILES
_newLog.LogDebug("BOOT: pipeline - 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 == "index.html")
// {
// context.Context.Response.Headers.Add("Cache-Control", "no-cache, no-store");
// context.Context.Response.Headers.Add("Expires", "-1");
// }
// }
// });
#endregion
_newLog.LogDebug("BOOT: pipeline - ROUTING");
app.UseRouting();//this wasn't here for 2.2 but added for 3.0, needs to come before the stuff after
_newLog.LogDebug("BOOT: pipeline - CORS");
app.UseCors("CorsPolicy");
#region AUTH / ROLES
_newLog.LogDebug("BOOT: pipeline - authentication");
//Use authentication middleware
app.UseAuthentication();
_newLog.LogDebug("BOOT: pipeline - authorization");
app.UseAuthorization();
//Custom middleware 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)
{
context.Request.HttpContext.Items["AY_ROLES"] = 0;
}
else
{
//Get user ID from claims
long userId = Convert.ToInt64(context.User.FindFirst(c => c.Type == "id").Value);
//Get the database context
var ct = context.RequestServices.GetService<AyContext>();
//get the user record
var u = ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, id = m.Id, localeId = m.LocaleId }).First();
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_LOCALE_ID"] = u.localeId;
}
await next.Invoke();
});
#endregion
_newLog.LogDebug("BOOT: pipeline - ENDPOINTS");
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
#region SWAGGER
_newLog.LogDebug("BOOT: pipeline - api explorer");
// 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.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
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)
{
_newLog.LogWarning("BOOT: AYANOVA_PERMANENTLY_ERASE_DATABASE is true, dropping and recreating database");
Util.DbUtil.DropAndRecreateDb(_newLog);
AySchema.CheckAndUpdate(dbContext, _newLog);
}
//Check schema
_newLog.LogDebug("BOOT: db schema check");
AySchema.CheckAndUpdate(dbContext, _newLog);
//Check database integrity
_newLog.LogDebug("BOOT: db integrity check");
DbUtil.CheckFingerPrint(AySchema.EXPECTED_COLUMN_COUNT, AySchema.EXPECTED_INDEX_COUNT, _newLog);
//Initialize license
AyaNova.Core.License.Initialize(apiServerState, dbContext, _newLog);
//Ensure locales are present, not missing any keys and that there is a server default locale that exists
LocaleBiz lb = new LocaleBiz(dbContext, 1, ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID, AuthorizationRoles.OpsAdminFull);
lb.ValidateLocales();
#if (DEBUG)
//TESTING
if (TESTING_REFRESH_DB)
{
AyaNova.Core.License.Fetch(apiServerState, dbContext, _newLog);
Util.Seeder.SeedDatabase(Util.Seeder.SeedLevel.SmallOneManShopTrialDataSet, -7);//#############################################################################################
}
//TESTING
#endif
//AUTOID VALUES INITIALIZATION
ServerBootConfig.SetMostRecentAutoIdValuesFromDatabase(dbContext);
//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 != "/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($"BOOT: Active techs - {UserBiz.ActiveCount}");
//Log the license info so it's on the record
_newLog.LogInformation($"BOOT: License -\r\n=-=-=-=-=-=-=-=-=-=-\r\n{AyaNova.Core.License.LicenseInfo}=-=-=-=-=-=-=-=-=-=-");
//Open up the server for visitors
apiServerState.SetOpen();
//final startup log
_newLog.LogInformation("BOOT: COMPLETED - SERVER IS NOW OPEN");
}
#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
}
}