371 lines
14 KiB
C#
371 lines
14 KiB
C#
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 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;
|
|
using MudBlazor.Services;
|
|
|
|
// 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<AuthenticationSchemeOptions, QuantAdminAuthHandler>("QuantAdminScheme", _ => { });
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddScoped<LocalStorageService>();
|
|
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
|
builder.Services.AddAuthorizationCore();
|
|
|
|
builder.Services.AddMudServices();
|
|
|
|
// PostgreSQL Dapper Setup
|
|
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
|
var fallbackConnectionString = "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=CHANGE_ME;Search Path=quantengine;";
|
|
var connectionString = string.IsNullOrWhiteSpace(configuredConnectionString) || configuredConnectionString.Contains("Password=;", StringComparison.OrdinalIgnoreCase)
|
|
? fallbackConnectionString
|
|
: configuredConnectionString;
|
|
var configuredDatabase = new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database;
|
|
if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database.");
|
|
}
|
|
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
|
builder.Services.AddSingleton<DbMigrator>();
|
|
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
|
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
|
builder.Services.AddScoped<HistoryIngestionService>();
|
|
|
|
// Collection Pipeline Services (PostgreSQL-backed implementations)
|
|
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
|
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
|
builder.Services.AddScoped<IKisApiClient, KisApiClient>();
|
|
builder.Services.AddScoped<DataCollectionService>();
|
|
|
|
// HTTP Client & API Services
|
|
builder.Services.AddHttpClient<ApiClient>();
|
|
builder.Services.AddScoped<ApiClient>();
|
|
|
|
var app = builder.Build();
|
|
var adminSettings = app.Configuration.GetSection("AdminSettings");
|
|
var adminUsername = adminSettings["Username"] ?? "admin";
|
|
var adminPassword = adminSettings["Password"] ?? string.Empty;
|
|
|
|
// Initialize database tables (PostgreSQL-backed repositories)
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
|
|
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
|
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
|
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
|
|
|
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();
|
|
|
|
app.MapGet("/", () => Results.Redirect("/login"));
|
|
|
|
// Collection API Endpoints (must be before MapRazorComponents)
|
|
app.MapCollectionEndpoints();
|
|
|
|
// Login API (API-First for Blazor WASM client authentication)
|
|
app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
|
{
|
|
static string? ReadString(JsonElement root, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
|
{
|
|
return property.GetString();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
var username = ReadString(payload, "Username", "username");
|
|
var password = ReadString(payload, "Password", "password");
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
return Results.BadRequest(new { success = false, error = "missing_credentials" });
|
|
}
|
|
|
|
var account = await workspaceRepo.GetAccountByUsernameAsync(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(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")
|
|
});
|
|
}).DisableAntiforgery();
|
|
|
|
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 });
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapPost("/api/auth/admin/reset-password", async (HttpContext context, JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
|
{
|
|
static string? ReadString(JsonElement root, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
|
|
{
|
|
return property.GetString();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
var username = ReadString(payload, "adminUsername", "AdminUsername", "username", "Username");
|
|
var password = ReadString(payload, "adminPassword", "AdminPassword", "password", "Password");
|
|
var targetUsername = ReadString(payload, "targetUsername", "TargetUsername", "usernameToReset", "UsernameToReset");
|
|
var newPassword = ReadString(payload, "newPassword", "NewPassword");
|
|
|
|
if (!string.Equals(username, adminUsername, StringComparison.Ordinal) || !string.Equals(password, adminPassword, StringComparison.Ordinal))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(targetUsername) || string.IsNullOrWhiteSpace(newPassword))
|
|
{
|
|
return Results.BadRequest(new { success = false, error = "missing_target_or_password" });
|
|
}
|
|
|
|
var account = await workspaceRepo.GetAccountByUsernameAsync(targetUsername.Trim());
|
|
if (account is null)
|
|
{
|
|
return Results.NotFound(new { success = false, error = "account_not_found" });
|
|
}
|
|
|
|
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(newPassword)));
|
|
account.PasswordHash = passwordHash;
|
|
account.UpdatedAt = DateTimeOffset.UtcNow.ToString("O");
|
|
var updated = await workspaceRepo.UpsertAccountAsync(account);
|
|
if (!updated)
|
|
{
|
|
return Results.StatusCode(500);
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
success = true,
|
|
username = account.Username,
|
|
updatedAt = account.UpdatedAt
|
|
});
|
|
}).DisableAntiforgery();
|
|
|
|
// 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<Dictionary<string, object?>>(payload.GetRawText())
|
|
?? new Dictionary<string, object?>();
|
|
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<App>()
|
|
.AddInteractiveWebAssemblyRenderMode()
|
|
.AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly);
|
|
|
|
app.Run();
|
|
|
|
internal sealed class QuantAdminAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
|
{
|
|
public QuantAdminAuthHandler(
|
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder)
|
|
: base(options, logger, encoder)
|
|
{
|
|
}
|
|
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
return Task.FromResult(AuthenticateResult.NoResult());
|
|
}
|
|
|
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
|
{
|
|
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|