using System.Text.Json; using System.Text.Json.Serialization; using Journal.Core; using Microsoft.Extensions.FileProviders; var gatewayJsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; var repoRoot = ResolveRepoRoot(); Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot); var webDistPath = ResolveWebDist(repoRoot); var builder = WebApplication.CreateBuilder(args); builder.Services.AddFragmentServices(); builder.Services.AddSingleton(); builder.Services.AddSingleton(new SidecarRootState(repoRoot)); builder.Services.AddSingleton(new WebUiState(webDistPath)); builder.Services.AddCors(options => { options.AddPolicy("GatewayCors", policy => { policy.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); }); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNameCaseInsensitive = true; }); var app = builder.Build(); app.UseCors("GatewayCors"); app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "Journal.WebGateway" })); app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new { distPath = webUiState.DistPath, exists = webUiState.Exists })); app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) => { if (command is null || string.IsNullOrWhiteSpace(command.Action)) { return Results.Content(ErrorResponse("Missing action"), "application/json"); } var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions); var responseJson = await entry.HandleCommandAsync(inputJson); return Results.Content(responseJson, "application/json"); }); app.MapGet("/api/sidecar/root", (SidecarRootState rootState) => { var snapshot = rootState.Get(); return Results.Ok(new { root = snapshot.Root, isCustom = snapshot.IsCustom }); }); app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) => { var path = request?.Path ?? ""; if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path)) { return Results.BadRequest($"Directory '{path}' does not exist."); } rootState.Set(path); var snapshot = rootState.Get(); Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", snapshot.Root); return Results.Ok(new { root = snapshot.Root, isCustom = snapshot.IsCustom }); }); if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html"))) { var fileProvider = new PhysicalFileProvider(webDistPath); var indexPath = Path.Combine(webDistPath, "index.html"); app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" }); app.UseStaticFiles(new StaticFileOptions { FileProvider = fileProvider, RequestPath = "" }); app.MapGet("/", async context => { context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.SendFileAsync(indexPath); }); app.MapFallback(async 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); }); } else { 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 })); } app.Run(); string ResolveRepoRoot() { var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) { return Path.GetFullPath(fromEnv); } foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { var resolved = FindRepoRoot(start); if (resolved is not null) { return resolved; } } return Path.GetFullPath(Directory.GetCurrentDirectory()); } string? FindRepoRoot(string start) { var cursor = Path.GetFullPath(start); while (!string.IsNullOrWhiteSpace(cursor)) { if (File.Exists(Path.Combine(cursor, "Journal.slnx")) || Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) || Directory.Exists(Path.Combine(cursor, "Journal.Core"))) { return cursor; } var parent = Directory.GetParent(cursor); if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase)) { return null; } cursor = parent.FullName; } return null; } string ResolveWebDist(string repoRootPath) { var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST"); if (!string.IsNullOrWhiteSpace(fromEnv)) { return Path.GetFullPath(fromEnv); } var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); if (Directory.Exists(packagedWwwRoot)) { return packagedWwwRoot; } return Path.Combine(repoRootPath, "Journal.App", "build"); } string ErrorResponse(string message) => JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions); sealed class WebUiState { public WebUiState(string distPath) { DistPath = distPath; } public string DistPath { get; } public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html")); } sealed class SidecarRootState { private readonly object _sync = new(); private readonly string _autoRoot; private string _currentRoot; private bool _isCustom; public SidecarRootState(string autoRoot) { _autoRoot = autoRoot; _currentRoot = autoRoot; _isCustom = false; } public (string Root, bool IsCustom) Get() { lock (_sync) { return (_currentRoot, _isCustom); } } public void Set(string? path) { lock (_sync) { if (string.IsNullOrWhiteSpace(path)) { _currentRoot = _autoRoot; _isCustom = false; return; } _currentRoot = Path.GetFullPath(path.Trim()); _isCustom = true; } } } sealed class SetSidecarRootRequest { public string? Path { get; set; } } sealed class CommandEnvelope { public string Action { get; set; } = ""; public string? CorrelationId { get; set; } public string? Id { get; set; } public string? Type { get; set; } public string? Tag { get; set; } public JsonElement? Payload { get; set; } }