using Audit.WebApi; using Betalgo.Ranul.OpenAI.Extensions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Newtonsoft.Json; using RMuseum.DbContext; using RMuseum.Models.Auth.Memory; using RMuseum.Services; using RMuseum.Services.Implementation; using RMuseum.Services.Implementationa; using RMuseum.Utils.SemanticSearch; using RSecurityBackend.Authorization; using RSecurityBackend.DbContext; using RSecurityBackend.Models.Auth.Db; using RSecurityBackend.Models.Auth.Memory; using RSecurityBackend.Models.Mail; using RSecurityBackend.Services; using RSecurityBackend.Services.Implementation; using RSecurityBackend.Utilities; using Swashbuckle.AspNetCore.Filters; using System; using System.Linq; using System.IO; using System.Reflection; using System.Text; using System.Threading.Tasks; namespace RMuseum { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } private string[] AllowedOrigins => (Configuration["Cors:AllowedOrigins"] ?? "") .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(o => o.TrimEnd('/')).ToArray(); // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { // Add service and create Policy with options services.AddCors(options => { options.AddPolicy("DiwanCorsPolicy", // diwan: Cors:AllowedOrigins (comma separated, e.g. https://diwan.example) restricts browsers; // unset = any origin (local development) builder => builder.SetIsOriginAllowed(origin => AllowedOrigins.Length == 0 || AllowedOrigins.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)) .AllowAnyMethod() .AllowAnyHeader() .WithExposedHeaders("paging-headers", "audio-upload-enabled", "items-count") .AllowCredentials() ); }); services.AddDbContextPool( options => options.UseSqlServer( Configuration.GetConnectionString("DefaultConnection"), providerOptions => { providerOptions.EnableRetryOnFailure(); providerOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); } ).ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)) ); //Audit.Core.Configuration.JsonSettings.ContractResolver = AuditNetEnvironmentSkippingContractResolver.Instance; Audit.Core.Configuration.DataProvider = new RAuditDataProvider(Configuration.GetConnectionString("DefaultConnection")); Audit.Core.Configuration.AuditDisabled = bool.Parse(Configuration["AuditNetEnabled"]) == false; services.AddIdentityCore( options => { // Password settings. options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 6; options.Password.RequiredUniqueChars = 1; // Lockout settings. options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; // User settings. options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; options.User.RequireUniqueEmail = false; } ).AddErrorDescriber(); new IdentityBuilder(typeof(RAppUser), typeof(RAppRole), services) .AddRoleManager>() .AddSignInManager>() .AddEntityFrameworkStores() .AddErrorDescriber(); services.AddMvc(mvc => mvc.AddAuditFilter(config => config .LogRequestIf(r => r.Method != "GET") .WithEventType("{controller}/{action} ({verb})") .IncludeHeaders(ctx => !ctx.ModelState.IsValid) .IncludeRequestBody() .IncludeModelState() )); services.AddMemoryCache(); services.AddHttpClient(); services.Configure(x => { x.ValueLengthLimit = int.MaxValue; x.MultipartBodyLengthLimit = int.MaxValue; // In case of multipart }); services.AddAuthentication(options => { options.DefaultScheme = "bearer"; }).AddJwtBearer("bearer", options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = false, ValidAudience = "Everyone", ValidateIssuer = true, ValidIssuer = Configuration.GetSection("RSecurityBackend")["ApplicationName"] ?? "Diwan", // diwan: issuer follows ApplicationName (the token issuer) ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes($"{Configuration.GetSection("Security")["Secret"]}")), ValidateLifetime = true, //validate the expiration and not before values in the token ClockSkew = TimeSpan.Zero }; options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) { context.Response.Headers.Append("Token-Expired", "true"); } return Task.CompletedTask; } }; }); services.AddAuthorization(options => { //this is the default policy to make sure the use session has not yet been deleted by him/her from another client //or by an admin (Authorize with no policy should fail on deleted sessions) var defPolicy = new AuthorizationPolicyBuilder(); defPolicy.Requirements.Add(new UserGroupPermissionRequirement("null", "null")); options.DefaultPolicy = defPolicy.Build(); foreach (SecurableItem Item in RMuseumSecurableItem.Items) { foreach (SecurableItemOperation Operation in Item.Operations) { options.AddPolicy($"{Item.ShortName}:{Operation.ShortName}", policy => policy.Requirements.Add(new UserGroupPermissionRequirement(Item.ShortName, Operation.ShortName))); } } }); // Register the Swagger generator, defining 1 or more Swagger documents services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "RMuseum API", Version = "v1", Description = "RMuseum API", TermsOfService = new Uri("https://ganjoor.net/contact"), Contact = new OpenApiContact { Name = "Diwan", Email = "diwan@ganjoor.net", Url = new Uri("https://ganjoor.net") } } ); c.EnableAnnotations(); var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "RSecurityBackend.xml")); c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme() { Description = "format: \"bearer {token}\"", In = ParameterLocation.Header, Name = "Authorization", Type = SecuritySchemeType.ApiKey }); c.OperationFilter(); c.OperationFilter(); // Adds "(Auth)" to the summary so that you can see which endpoints have Authorization // or use the generic method, e.g. c.OperationFilter>(); }); //IHttpContextAccessor services.TryAddSingleton(); //authorization handler services.AddScoped(); //security context maps to main db context services.AddTransient, RMuseumDbContext>(); //captcha service services.AddTransient(); //generic image file service services.AddTransient(); //app user services services.AddTransient(); //user groups services services.AddTransient(); //audit service services.AddTransient(); //user permission checker services.AddTransient(); //secret generator services.AddTransient(); // email service services.AddTransient(); services.Configure(Configuration); //picture file service services.AddTransient(); //messaging service services.AddTransient(); //artifact service services.AddTransient(); //audio service services.AddTransient(); //diwan service services.AddTransient(); //music catalogue service //long running job service services.AddTransient(); //generic options service services.AddTransient(); //site banner service services.AddTransient(); //donation service services.AddTransient(); //translation service services.AddTransient(); //numbering service services.AddTransient(); //geo location service services.AddTransient(); //related people (family tree / person tagging) service services.AddTransient(); //tracking service services.AddTransient(); //poet photo suggestion service services.AddTransient(); //faq service services.AddTransient(); //workspace service services.AddTransient(); //workspace role service services.AddTransient(); //Queued FTP Upload Service services.AddTransient(); //Contributions stats service services.AddTransient(); services.AddHostedService(); services.AddSingleton(); services.AddOpenAIService(); // See LazySemanticSearchResources.cs: this is deliberately NOT // services.AddSingleton(sp => EmbeddingIndex.Load(...)) anymore. That // eager, throwing factory is what caused a production 503 on the whole /api/diwan // surface when the configured paths were wrong — DiwanController's constructor // (via ISemanticSearchService) couldn't be built, so nothing under that route could // run. LazySemanticSearchResources defers the actual load to first real use and // never throws; a failure there disables semantic search only. services.AddSingleton(); // Separate lazy singleton from LazySemanticSearchResources - poet/category name // detection ("در کدام شعر حافظ") is an independent concern with its own independent // failure mode; a bug in one must not be able to disable the other. services.AddSingleton(); services.AddSingleton(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // diwan: never run outside Development with a missing JWT signing secret (upstream shipped a public default) if (!env.IsDevelopment() && string.IsNullOrWhiteSpace(Configuration["Security:Secret"])) throw new InvalidOperationException("Security:Secret is not set (env Security__Secret). Refusing to start."); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { // RMuseum is API-only (see UseEndpoints below - MapControllers only), so there is no // Razor "/Error" page or MVC "Error" action for the old app.UseExceptionHandler("/Error") // to redirect to - that redirect just 404s, and (especially when hosted behind IIS/ANCM, // as in production here) a 404 with no body of its own can get replaced by IIS's own // generic HTML error page instead. Either way, callers - including DiwanRazor's own // server-side page handlers, which otherwise assume every error body is a JSON-encoded // string - got back unreadable HTML instead of the real exception, visible only by // digging through the Windows Event Log. Handling the exception directly here instead // of redirecting anywhere guarantees a small JSON-string body with the real exception // message, in the exact same shape a normal RServiceResult.ExceptionString error already // comes back as (see e.g. DiwanController's "return BadRequest(res.ExceptionString)"), // so every existing client-side error handler keeps working unchanged. app.UseExceptionHandler(errApp => { errApp.Run(async context => { context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/json; charset=utf-8"; var exceptionFeature = context.Features.Get(); var message = exceptionFeature?.Error?.ToString() ?? "خطای غیرمنتظره‌ای در سرور رخ داد."; await context.Response.WriteAsync(JsonConvert.SerializeObject(message)); }); }); } app.UseStaticFiles(); // Enable middleware to serve generated Swagger as a JSON endpoint. app.UseSwagger(); // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), // specifying the Swagger JSON endpoint. app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "RMuseum API V1"); c.RoutePrefix = string.Empty; }); app.UseAuthentication(); // global policy - assign here or on each controller app.UseCors("DiwanCorsPolicy"); app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } } }