using System.Text.Json; using Journal.Core; using Journal.Core.Services.Config; using Journal.Core.Services.Database; using Journal.WebGateway.Infrastructure; using Journal.WebGateway.Security; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.FileProviders; namespace Journal.WebGateway.Extensions; public static class EndpointExtensions { public static void MapGatewayEndpoints(this IEndpointRouteBuilder app, string webDistPath) { MapAuthEndpoints(app); MapApiEndpoints(app); MapStaticFiles(app, webDistPath); } private static void MapAuthEndpoints(IEndpointRouteBuilder app) { app.MapGet("/gateway/login", [AllowAnonymous] async (HttpContext context) => { context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.WriteAsync(LoginPage.GetHtml()); }); app.MapPost("/gateway/login", [AllowAnonymous] async (HttpContext context, IConfiguration config, ILogger logger) => { var form = await context.Request.ReadFormAsync(); var password = form["password"].ToString(); var configuredHash = config.GetValue("Security:AccessPasswordHash"); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); if (string.IsNullOrWhiteSpace(configuredHash)) { logger.LogError("[{Timestamp}] Gateway login rejected because Security:AccessPasswordHash is not configured.", timestamp); return Results.Redirect("/gateway/login?error=config"); } if (GatewayPasswordHasher.VerifyPassword(password, configuredHash)) { logger.LogInformation("[{Timestamp}] Audit: Successful login from {IP}", timestamp, ip); var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "WebUser") }; var identity = new System.Security.Claims.ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new System.Security.Claims.ClaimsPrincipal(identity); await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); return Results.Redirect("/"); } logger.LogWarning("[{Timestamp}] Audit: FAILED login attempt from {IP}", timestamp, ip); return Results.Redirect("/gateway/login?error=invalid"); }); app.MapGet("/gateway/logout", async (HttpContext context) => { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Redirect("/gateway/login"); }); } private static void MapApiEndpoints(IEndpointRouteBuilder app) { var group = app.MapGroup("/api"); group.MapGet("/health", [AllowAnonymous] () => Results.Ok(new { ok = true, service = "Journal.WebGateway" })); group.MapGet("/web/status", (WebUiState webUiState) => Results.Ok(new { distPath = webUiState.DistPath, exists = webUiState.Exists })); group.MapPost("/command", async (CommandEnvelope? command, Entry entry, ILogger logger) => { if (command is null || string.IsNullOrWhiteSpace(command.Action)) { return Results.Json(new { ok = false, error = "Missing action" }, statusCode: 400); } var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var inputJson = JsonSerializer.Serialize(command, options); logger.LogInformation("Executing command: {Action}", command.Action); var responseJson = await entry.HandleCommandAsync(inputJson); return Results.Content(responseJson, "application/json"); }); group.MapGet("/sidecar/root", (SidecarRootState rootState) => { var (root, isCustom) = rootState.Get(); return Results.Ok(new { root, isCustom }); }); group.MapPost("/sidecar/root", () => { return Results.BadRequest(new { ok = false, error = "WebGateway root is startup-only. Configure GatewaySettings:RepoRoot or JOURNAL_PROJECT_ROOT before launch." }); }); group.MapGet("/runtime/diagnostics", ( SidecarRootState rootState, IJournalConfigService configService, IJournalDatabaseService databaseService, HttpContext context) => { var (root, isCustom) = rootState.Get(); var config = configService.Current; var gatewayUrl = $"{context.Request.Scheme}://{context.Request.Host}"; return Results.Ok(new { backendMode = "webgateway", root, isCustomRoot = isCustom, vaultDirectory = config.VaultDirectory, databasePath = databaseService.GetDatabasePath(), sidecarPath = "Not used by WebGateway", gatewayPath = Environment.ProcessPath ?? AppContext.BaseDirectory, gatewayUrl }); }); } private static void MapStaticFiles(IEndpointRouteBuilder app, string webDistPath) { if (!Directory.Exists(webDistPath) || !File.Exists(Path.Combine(webDistPath, "index.html"))) { app.MapGet("/", () => Results.Ok(new { name = "Journal.WebGateway", status = "ok", uiAvailable = false, message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.", expectedDist = webDistPath })); return; } var fileProvider = new PhysicalFileProvider(webDistPath); var indexPath = Path.Combine(webDistPath, "index.html"); // Note: Generic static file middleware should be in Program.cs // but the specific root/fallback routes can be here. app.MapGet("/", async (HttpContext context) => { context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.SendFileAsync(indexPath); }); app.MapFallback(async (HttpContext context) => { if (context.Request.Path.StartsWithSegments("/api")) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.SendFileAsync(indexPath); }); } }