stan44 7562cf6fad Add gateway root adoption and mobile polish
- Add Tauri commands to inspect and adopt the gateway repo root
- Retry locked vault commands by prompting for unlock
- Improve mobile layout, editor mode toggles, and settings UI
2026-03-30 00:00:25 -05:00

249 lines
8.2 KiB
C#

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<int?>("PORT")
?? builder.Configuration.GetValue<int?>("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<Entry>();
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<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("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<string[]>() ?? Array.Empty<string>();
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<SidecarRootState>().SetResolved(repoRootResolution.Root, repoRootResolution.IsCustom);
if (string.IsNullOrWhiteSpace(builder.Configuration.GetValue<string>("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<ILogger<Program>>();
var feature = context.Features.Get<IExceptionHandlerPathFeature>();
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<bool>("EnableAuditLogging"))
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
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<char>();
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);
}
}