using System.Text.Json; using System.Text.Json.Serialization; using Journal.AI; using Journal.Core; using Journal.WebGateway.Extensions; using Journal.WebGateway.Infrastructure; using Journal.WebGateway.Security; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.Extensions.FileProviders; if (TryHandlePasswordHashCommand(args)) return; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = AppContext.BaseDirectory }); // Configuration var securitySettings = builder.Configuration.GetSection("Security"); var repoRootResolution = PathResolver.ResolveRepoRoot(builder.Configuration); var repoRoot = repoRootResolution.Root; var webDistPath = PathResolver.ResolveWebDist(repoRoot, builder.Configuration); var port = builder.Configuration.GetValue("PORT") ?? builder.Configuration.GetValue("GatewaySettings:Port") ?? 5002; Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot); builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(port)); // Services builder.Services.AddFragmentServices(); builder.Services.AddLlamaSharpServices(); builder.Services.AddSingleton(); builder.Services.AddSingleton(new SidecarRootState(repoRootResolution.Root)); builder.Services.AddSingleton(new WebUiState(webDistPath)); builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.LoginPath = "/gateway/login"; options.LogoutPath = "/gateway/logout"; options.Cookie.Name = "Journal.Gateway.Session"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }) .AddScheme("ApiKey", null); builder.Services.AddAuthorization(options => { options.FallbackPolicy = new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, "ApiKey") .RequireAuthenticatedUser() .Build(); }); builder.Services.AddCors(options => { options.AddPolicy("GatewayCors", policy => { var allowedOrigins = securitySettings.GetSection("AllowedOrigins").Get() ?? Array.Empty(); if (allowedOrigins.Length > 0) { policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); } else { policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); } }); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); var app = builder.Build(); app.Services.GetRequiredService().SetResolved(repoRootResolution.Root, repoRootResolution.IsCustom); if (string.IsNullOrWhiteSpace(builder.Configuration.GetValue("Security:AccessPasswordHash"))) { app.Logger.LogWarning("Security:AccessPasswordHash is not configured. Browser login is disabled until a password hash is set."); } app.Logger.LogInformation("Journal.WebGateway starting..."); app.Logger.LogInformation("Content Root: {Path}", app.Environment.ContentRootPath); app.Logger.LogInformation("Web UI Dist: {Path}", webDistPath); app.UseExceptionHandler(exceptionApp => { exceptionApp.Run(async context => { var logger = context.RequestServices.GetRequiredService>(); var feature = context.Features.Get(); logger.LogError(feature?.Error, "Unhandled exception occurred."); context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { ok = false, error = "An internal error occurred." }); }); }); app.UseCors("GatewayCors"); app.UseAuthentication(); app.UseAuthorization(); app.Use(async (context, next) => { var path = context.Request.Path; var isGatewayAuthPath = path.StartsWithSegments("/gateway/login") || path.StartsWithSegments("/gateway/logout"); var isAnonymousApiPath = path.StartsWithSegments("/api/health") || path.StartsWithSegments("/api/web/status"); var isProtectedUiPath = !path.StartsWithSegments("/api") && !isGatewayAuthPath; if (isProtectedUiPath && context.User.Identity?.IsAuthenticated != true) { await context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme); return; } if (isAnonymousApiPath) { await next(); return; } await next(); }); // Better handling for 401 challenges on browser requests app.Use(async (context, next) => { await next(); if (context.Response.StatusCode == 401 && !context.Response.HasStarted && context.Request.Path != "/gateway/login" && context.Request.Headers.Accept.ToString().Contains("text/html")) { context.Response.Redirect("/gateway/login"); } }); app.Use(async (context, next) => { if (securitySettings.GetValue("EnableAuditLogging")) { var logger = context.RequestServices.GetRequiredService>(); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var userAgent = context.Request.Headers["User-Agent"].ToString(); var user = context.User.Identity?.Name ?? "Anonymous"; var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); logger.LogInformation("[{Timestamp}] Audit: [{IP}] {Method} {Path} accessed by {User} ({UA})", timestamp, ip, context.Request.Method, context.Request.Path, user, userAgent); } await next(); }); // Static Files Internal Middleware if (Directory.Exists(webDistPath)) { app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" }); app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" }); } // Map specialized endpoints app.MapGatewayEndpoints(webDistPath); app.Run(); static bool TryHandlePasswordHashCommand(string[] args) { if (args.Length == 0 || !string.Equals(args[0], "hash-password", StringComparison.OrdinalIgnoreCase)) return false; string password; if (args.Length > 1) { password = args[1]; } else { password = PromptForPassword("New gateway password: "); var confirm = PromptForPassword("Confirm gateway password: "); if (!string.Equals(password, confirm, StringComparison.Ordinal)) { Console.Error.WriteLine("Passwords did not match."); Environment.ExitCode = 1; return true; } } if (string.IsNullOrWhiteSpace(password)) { Console.Error.WriteLine("Password cannot be empty."); Environment.ExitCode = 1; return true; } Console.WriteLine(GatewayPasswordHasher.HashPassword(password)); return true; } static string PromptForPassword(string prompt) { Console.Write(prompt); var buffer = new List(); while (true) { var key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Enter) { Console.WriteLine(); return new string([.. buffer]); } if (key.Key == ConsoleKey.Backspace) { if (buffer.Count > 0) buffer.RemoveAt(buffer.Count - 1); continue; } if (!char.IsControl(key.KeyChar)) buffer.Add(key.KeyChar); } }