namespace MyOffice.Web; using System.IO; using System.Linq; using System.Collections.Concurrent; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Logging; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Console; using System.Text.Json.Serialization; using Identity; using Identity.Domain; using Identity.ExternalProviders; using Identity.Repositories; using Auth; using Core.Identity; using Core.Extensions; using Data.Repositories.Account; using Data.Repositories.Currency; using Data.Repositories.Item; using Data.Repositories.Users; using DbContext; using Services.Users; using Shared; using Infrastructure; using Models.Account; using MyOffice.Services.Currency; using Services.Account; using Services.Item; using MyOffice.Services.Dashboard; using MyOffice.Services.Identity; using MyOffice.Services.Account.Domain; using MyOffice.Web.Infrastructure.Attributes; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using MyOffice.Web.Infrastructure.Filters; //TODO: Data.Model only Repository and Service, response <-> mapper <-> web <-> mapper <-> service <-> repository //TODO: Project management //TODO: Model names. Controller = xxxRequest/xxxResponse. Service xxxInput/xxxOutput. //TODO: Validate string as Guid when id //TODO: Test back //TODO: Test front //TODO: Test DB ??? //TODO: Email confirmation //TODO: Password recovery //TODO: Account sharing //TODO: /dashboard/dashboard -> /dashboard/main or /dashboard //TODO: SPA load categories twice menu + setting (cache) //TODO: SPA update account category -> update menu //TODO: Response model from base type //TODO: XXXResult -> XXXStatus //TODO: SPA isAllowDelete -> allowDelete (remove is) //TODO: Repository auto registration //TODO: Services auto registration //TODO: openid-configuration failed - lock login //TODO: BUG some times after login redirect to dashboard but exists return url //TODO: automapper -> Extension ToModel() ToDbo() FromModel() FromDbo() //TODO: SPA all http requests -> services //TODO: Items, select category -> save url to allow refresh //TODO: Accounts, select category -> save url to allow refresh public partial class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); AddServices(builder, args); var app = builder.Build(); Configure(app); app.Run(); } private static void AddServices(WebApplicationBuilder builder, string[] args) { #region Loging builder.Services.AddLogging(logging => logging.AddSimpleConsole(options => { //options.SingleLine = true; options.TimestampFormat = "[HH:mm:ss] "; options.ColorBehavior = LoggerColorBehavior.Enabled; }) ); //File Logger builder.Logging.AddFile(builder.Configuration.GetSection("Logging")); #endregion Loging builder.Services.Configure(options => { options.AddFilter("OpenIddict", LogLevel.Warning); }); // Shared JSON after appsettings.*, then restore higher-priority sources builder.Configuration.AddSharedAppSettings(builder.Environment.EnvironmentName); builder.Configuration.AddEnvironmentVariables(); builder.Configuration.AddCommandLine(args); #region Database var databaseProvider = builder.Configuration["DatabaseProvider"]; if (databaseProvider == null) throw new NullReferenceException($"Configuration DatabaseProvider {databaseProvider}"); var connectionString = builder.Configuration.GetConnectionString(databaseProvider); if (connectionString == null) throw new NullReferenceException($"Configuration ConnectionString {databaseProvider}"); var connectionConfiguration = new ConnectionConfiguration(databaseProvider, connectionString); RepositoryInitializer.Initialize(builder.Services, connectionConfiguration); // Before OpenIddictSeeder so the schema exists when clients/scopes are registered. builder.Services.AddHostedService(); #endregion Database // Configurations builder.Services.Configure( builder.Configuration.GetSection("ExternalProviders") ); InitializeGlobalSettings(builder); AddIdentityServices(builder); builder.Services.AddScoped(); AddAutoMapper(builder); AddRepositories(builder); AddBusinessServices(builder); builder.Services .AddControllers() .AddJsonOptions(options => { options.AllowInputFormatterExceptionMessages = builder.Environment.IsDevelopment(); options.JsonSerializerOptions.MaxDepth = 0; options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; }); builder.Services.AddCors(); builder.Services.AddControllersWithViews(options => { options.Conventions.Add(new DefaultFromBodyBindingConvention()); options.Filters.Add(typeof(GlobalModelStateValidatorAttribute)); options.Filters.Add(typeof(ResponseFilter)); }); builder.Services.Configure(options => { }); builder.Services.Configure(x => { }); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); builder.Services.AddSingleton(); } private static void InitializeGlobalSettings(WebApplicationBuilder builder) { var frontEndHost = builder.Configuration.GetValue("FrontEnd:Host"); if (frontEndHost.IsMissing()) { frontEndHost = "http://localhost:4300"; if (!builder.Environment.IsDevelopment()) { throw new InvalidOperationException( "FrontEnd:Host must be set outside Development (do not derive it from Referer)."); } } frontEndHost = frontEndHost!.TrimEnd('/'); builder.Services.Configure(options => options.Host = frontEndHost); builder.Services.AddSingleton(resolver => resolver.GetRequiredService>().Value); } private static void AddIdentityServices(WebApplicationBuilder builder) { builder.Services.AddHttpContextAccessor(); // add identity builder.Services .AddIdentity, ApplicationRole>() .AddUserStore() .AddRoleStore() .AddUserManager() .AddSignInManager>>() .AddDefaultTokenProviders(); builder.Services.AddScoped>, PasswordHasher>(); // Identity Services builder.Services.AddScoped>, UserStore>(); builder.Services.AddScoped, RoleStore>(); // External providers builder.Services.AddScoped(); builder.Services.AddScoped(); // Configure Identity options and password complexity here builder.Services.Configure(options => { // User settings options.User.RequireUniqueEmail = true; // Email+password accounts can sign in immediately after register options.SignIn.RequireConfirmedAccount = false; options.SignIn.RequireConfirmedEmail = false; // Password settings options.Password.RequireDigit = true; options.Password.RequiredLength = 8; options.Password.RequireNonAlphanumeric = true; options.Password.RequireUppercase = true; options.Password.RequireLowercase = true; // Lockout settings options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); options.Lockout.MaxFailedAccessAttempts = 10; }); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; // Trust nginx in Docker; headers are only present behind the proxy. options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); builder.Services.AddMyOfficeOpenIddict(builder.Configuration, builder.Environment); } private static void AddAutoMapper(WebApplicationBuilder builder) { // AutoMapper 15+ dual license: runs without a key (warning logs only). // Set AutoMapper:LicenseKey (or AUTOMAPPER_LICENSE_KEY) from https://automapper.io — free Community tier if under $5M revenue. var licenseKey = builder.Configuration["AutoMapper:LicenseKey"]; builder.Services.AddAutoMapper( c => { if (!string.IsNullOrWhiteSpace(licenseKey)) { c.LicenseKey = licenseKey; } c.AllowNullCollections = true; c.AllowNullDestinationValues = true; }, typeof(AccountDto).Assembly, typeof(AccountViewModel).Assembly ); } private static void AddRepositories(WebApplicationBuilder builder) { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } private static void AddBusinessServices(WebApplicationBuilder builder) { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } private static readonly ConcurrentDictionary _staticFilesCache = new(); private static void Configure(WebApplication app) { // nginx (prod) terminates TLS and forwards X-Forwarded-Proto/Host app.UseForwardedHeaders(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); IdentityModelEventSource.ShowPII = true; } else { app.UseExceptionHandler(); app.UseStatusCodePages(); } // Explicit routing so CORS runs before endpoint matching (preflight / OPTIONS). app.UseRouting(); ConfigureCors(app); app.UseDefaultFiles(); app.UseStaticFiles(); var logger = app.Logger; var globalSettings = app.Services.GetRequiredService(); logger.LogInformation("FrontEnd host: {Host}", globalSettings.Host); // send index.html / static files for non-API routes var coreRoutes = new[] { // API routes new PathString("/api"), // Identity server routes new PathString("/.well-known"), new PathString("/connect"), new PathString("/silent-refresh.html") }; var webRootPath = app.Configuration.GetValue(WebHostDefaults.ContentRootKey); var wwwrootPath = Path.Combine(webRootPath!, "wwwroot"); app.Use(async (context, next) => { var path = context.Request.Path; if (path.Value == null) { await next(); return; } if (coreRoutes.Any(x => path.StartsWithSegments(x, StringComparison.OrdinalIgnoreCase))) { await next(); return; } if (!_staticFilesCache.TryGetValue(path.Value, out var physicalFileInfo)) { var segments = path.Value.Split('/', '\\'); var fileName = segments.LastOrDefault(); var fileInfo = string.IsNullOrEmpty(fileName) ? null : new FileInfo(Path.Combine(wwwrootPath, fileName)); if (fileInfo is null || !fileInfo.Exists) { // SPA deep link fallback var indexInfo = new FileInfo(Path.Combine(wwwrootPath, "index.html")); if (!indexInfo.Exists) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } physicalFileInfo = new PhysicalFileInfo(indexInfo); } else { physicalFileInfo = new PhysicalFileInfo(fileInfo); _staticFilesCache.TryAdd(path.Value, physicalFileInfo); } } await context.Response.SendFileAsync(physicalFileInfo); }); app.UseAuthentication(); app.UseAuthorization(); MapRoutes(app); app.MapControllers(); } private static void ConfigureCors(WebApplication app) { var allowedOrigins = (app.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim().TrimEnd('/')) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var frontEndHost = app.Configuration.GetValue("FrontEnd:Host")?.Trim().TrimEnd('/'); if (!string.IsNullOrWhiteSpace(frontEndHost) && !allowedOrigins.Contains(frontEndHost, StringComparer.OrdinalIgnoreCase)) { allowedOrigins.Add(frontEndHost); } var isDocker = string.Equals( app.Environment.EnvironmentName, "Docker", StringComparison.OrdinalIgnoreCase); app.Logger.LogInformation( "CORS origins: {Origins}", allowedOrigins.Count > 0 ? string.Join(", ", allowedOrigins) : "(none)"); app.UseCors(policy => { if (allowedOrigins.Count > 0) { policy.WithOrigins(allowedOrigins.ToArray()) .AllowAnyHeader() .AllowAnyMethod(); } else if (app.Environment.IsDevelopment() || isDocker) { // Demo / local: SPA and API are on different host ports. policy.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); } }); } }