- 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
249 lines
8.2 KiB
C#
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);
|
|
}
|
|
}
|