journal/Journal.WebGateway/Extensions/EndpointExtensions.cs
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

179 lines
6.8 KiB
C#

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<Program> logger) =>
{
var form = await context.Request.ReadFormAsync();
var password = form["password"].ToString();
var configuredHash = config.GetValue<string>("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<Program> 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);
});
}
}