- 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
179 lines
6.8 KiB
C#
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);
|
|
});
|
|
}
|
|
}
|