using QuantEngine.Web.Components; using QuantEngine.Infrastructure.Data; using Microsoft.AspNetCore.Components.Authorization; using QuantEngine.Web.Infrastructure; using QuantEngine.Infrastructure.Repositories; using QuantEngine.Infrastructure.Services; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Services; using System.Text.Json; using static QuantEngine.Application.Services.DataCollectionService; using Microsoft.FluentUI.AspNetCore.Components; using Serilog; using QuantEngine.Web.Client.Infrastructure; using QuantEngine.Web.Client.Services; using QuantEngine.Web.Endpoints; using System.Security.Cryptography; using System.Text; using QuantEngine.Core.Models; using Microsoft.AspNetCore.Authentication; using System.Text.Encodings.Web; using Microsoft.Extensions.Options; // Serilog Configuration with Telegram Sink Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.Sink(new TelegramSink("8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-h1ZdV0", "-5460205872")) .CreateLogger(); var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); // Authentication and Custom State Provider (Shared client components) builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthentication("QuantAdminScheme") .AddScheme("QuantAdminScheme", _ => { }); builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); // Fluent UI Services builder.Services.AddFluentUIComponents(); // PostgreSQL Dapper Setup var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;"; builder.Services.AddSingleton(new DbConnectionFactory(connectionString)); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Collection Pipeline Services (PostgreSQL-backed implementations) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // HTTP Client & API Services builder.Services.AddHttpClient(); builder.Services.AddScoped(); var app = builder.Build(); // Initialize database tables (PostgreSQL-backed repositories) using (var scope = app.Services.CreateScope()) { var migrator = scope.ServiceProvider.GetRequiredService(); var tokenCache = scope.ServiceProvider.GetRequiredService(); var collectionRepo = scope.ServiceProvider.GetRequiredService(); var workspaceRepo = scope.ServiceProvider.GetRequiredService(); try { migrator.Migrate(); // Ensure tables exist on startup await tokenCache.GetCachedTokenAsync("_init_test_"); await collectionRepo.GetDashboardStateAsync(); await workspaceRepo.GetAccountsAsync(); Log.Information("Database tables initialized successfully"); } catch (Exception ex) { Log.Warning($"Database initialization warning: {ex.Message}"); } } // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } // Redirect status code pages only for non-API routes app.UseStatusCodePages(async ctx => { if (!ctx.HttpContext.Request.Path.StartsWithSegments("/api")) ctx.HttpContext.Response.Redirect("/not-found"); }); app.UseHttpsRedirection(); app.UseAntiforgery(); app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); // Collection API Endpoints (must be before MapRazorComponents) app.MapCollectionEndpoints(); // Login API (API-First for Blazor WASM client authentication) app.MapPost("/api/auth/login", async (LoginRequest request, IWorkspaceRepository workspaceRepo) => { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { return Results.BadRequest(new { success = false, error = "missing_credentials" }); } var account = await workspaceRepo.GetAccountByUsernameAsync(request.Username.Trim()); if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase)) { return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401); } var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Password))); if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase)) { return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401); } var rawToken = Guid.NewGuid().ToString("N"); var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))); var now = DateTimeOffset.UtcNow; var expiresAt = now.AddDays(7); await workspaceRepo.UpsertSessionAsync(new WorkspaceSession { SessionTokenHash = tokenHash, Username = account.Username, Role = account.Role, CreatedAt = now.ToString("O"), ExpiresAt = expiresAt.ToString("O"), RevokedAt = null }); return Results.Ok(new { success = true, username = account.Username, role = account.Role, accessToken = rawToken, expiresAt = expiresAt.ToString("O") }); }); app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) => { var authHeader = context.Request.Headers.Authorization.ToString(); if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { return Results.Unauthorized(); } var token = authHeader["Bearer ".Length..].Trim(); if (string.IsNullOrWhiteSpace(token)) { return Results.Unauthorized(); } var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); var session = await workspaceRepo.GetSessionByTokenHashAsync(tokenHash); if (session is null || !string.IsNullOrWhiteSpace(session.RevokedAt) || DateTimeOffset.TryParse(session.ExpiresAt, out var expiresAt) && expiresAt <= DateTimeOffset.UtcNow) { return Results.Unauthorized(); } return Results.Ok(new { authenticated = true, username = session.Username, role = session.Role }); }); app.MapPost("/api/auth/logout", async (HttpContext context, IWorkspaceRepository workspaceRepo) => { var authHeader = context.Request.Headers.Authorization.ToString(); if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { return Results.Unauthorized(); } var token = authHeader["Bearer ".Length..].Trim(); if (string.IsNullOrWhiteSpace(token)) { return Results.Unauthorized(); } var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O")); return Results.Ok(new { success = true }); }); // Operational Report serving API (WASM safe file loading substitute) app.MapGet("/api/operational-report", async (IWebHostEnvironment env) => { var path = Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json")); if (!File.Exists(path)) { return Results.NotFound(new { gate = "FAIL", error = "operational_report_missing" }); } var json = await File.ReadAllTextAsync(path); using var doc = JsonDocument.Parse(json); return Results.Ok(doc.RootElement); }); app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) => { var rows = await reader.ReadAsync(domain, limit ?? 500); return Results.Ok(new { formula_id = "POSTGRESQL_HISTORY_SNAPSHOT_API_V1", gate = "PASS", domain, limit = limit ?? 500, rows }); }); app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload, HistoryIngestionService ingestor) => { if (payload.ValueKind != JsonValueKind.Object) { return Results.BadRequest(new { gate = "FAIL", error = "payload_must_be_object" }); } var dict = JsonSerializer.Deserialize>(payload.GetRawText()) ?? new Dictionary(); var affected = domain switch { "decision_result_history" => await ingestor.AppendDecisionAsync(dict), "factor_output_history" => await ingestor.AppendFactorOutputAsync(dict), "market_raw_history" => await ingestor.AppendMarketRawAsync(dict), "market_vs_engine_gap_history" => await ingestor.AppendGapAsync(dict), _ => -1 }; if (affected < 0) { return Results.BadRequest(new { gate = "FAIL", error = "unsupported_domain" }); } return Results.Ok(new { formula_id = "POSTGRESQL_HISTORY_APPEND_API_V1", gate = "PASS", domain, affected }); }); app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly); app.Run(); public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } internal sealed class QuantAdminAuthHandler : AuthenticationHandler { public QuantAdminAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task HandleAuthenticateAsync() { return Task.FromResult(AuthenticateResult.NoResult()); } protected override Task HandleChallengeAsync(AuthenticationProperties properties) { Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } }