diff --git a/backend/FwHeadless/CrdtSyncService.cs b/backend/FwHeadless/CrdtSyncService.cs new file mode 100644 index 000000000..473227e11 --- /dev/null +++ b/backend/FwHeadless/CrdtSyncService.cs @@ -0,0 +1,27 @@ +using LcmCrdt; +using LcmCrdt.RemoteSync; +using SIL.Harmony; + +namespace FwHeadless; + +public class CrdtSyncService( + CrdtHttpSyncService httpSyncService, + IHttpClientFactory httpClientFactory, + CurrentProjectService currentProjectService, + DataModel dataModel, + ILogger logger) +{ + public async Task Sync() + { + var lexboxRemoteServer = await httpSyncService.CreateProjectSyncable( + currentProjectService.ProjectData, + httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName) + ); + var syncResults = await dataModel.SyncWith(lexboxRemoteServer); + if (!syncResults.IsSynced) throw new InvalidOperationException("Sync failed"); + logger.LogInformation( + "Synced with Lexbox, Downloaded changes: {MissingFromLocal}, Uploaded changes: {MissingFromRemote}", + syncResults.MissingFromLocal.Length, + syncResults.MissingFromRemote.Length); + } +} diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 9a0d42b95..5021e57e5 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -1,11 +1,13 @@ using FwDataMiniLcmBridge; using FwLiteProjectSync; using LcmCrdt; +using Microsoft.Extensions.Options; namespace FwHeadless; public static class FwHeadlessKernel { + public const string LexboxHttpClientName = "LexboxHttpClient"; public static void AddFwHeadless(this IServiceCollection services) { services @@ -20,5 +22,12 @@ public static void AddFwHeadless(this IServiceCollection services) .AddLcmCrdtClient() .AddFwDataBridge() .AddFwLiteProjectSync(); + services.AddScoped(); + services.AddTransient(); + services.AddHttpClient(LexboxHttpClientName, + (provider, client) => + { + client.BaseAddress = new Uri(provider.GetRequiredService>().Value.LexboxUrl); + }).AddHttpMessageHandler(); } -}; +} diff --git a/backend/FwHeadless/HttpClientAuthHandler.cs b/backend/FwHeadless/HttpClientAuthHandler.cs new file mode 100644 index 000000000..cc3efa799 --- /dev/null +++ b/backend/FwHeadless/HttpClientAuthHandler.cs @@ -0,0 +1,73 @@ +using System.Net; +using LexCore; +using LexCore.Auth; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace FwHeadless; + +public class HttpClientAuthHandler(IOptions config, IMemoryCache cache, ILogger logger) : DelegatingHandler +{ + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new NotSupportedException("use async apis"); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var lexboxUrl = new Uri(config.Value.LexboxUrl); + if (request.RequestUri?.Authority != lexboxUrl.Authority) + { + return await base.SendAsync(request, cancellationToken); + } + try + { + await SetAuthHeader(request, cancellationToken, lexboxUrl); + } + catch (Exception e) + { + throw new InvalidOperationException("Unable to set auth header", e); + } + return await base.SendAsync(request, cancellationToken); + } + + private async Task SetAuthHeader(HttpRequestMessage request, CancellationToken cancellationToken, Uri lexboxUrl) + { + var cookieContainer = new CookieContainer(); + cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Authority)); + request.Headers.Add("Cookie", cookieContainer.GetCookieHeader(lexboxUrl)); + } + + private async ValueTask GetToken(CancellationToken cancellationToken) + { + try + { + return await cache.GetOrCreateAsync("LexboxAuthToken", + async entry => + { + if (InnerHandler is null) throw new InvalidOperationException("InnerHandler is null"); + logger.LogInformation("Getting auth token"); + var client = new HttpClient(InnerHandler); + client.BaseAddress = new Uri(config.Value.LexboxUrl); + var response = await client.PostAsJsonAsync("/api/login", + new LoginRequest(config.Value.LexboxPassword, config.Value.LexboxUsername), + cancellationToken); + response.EnsureSuccessStatusCode(); + var cookies = response.Headers.GetValues("Set-Cookie"); + var cookieContainer = new CookieContainer(); + cookieContainer.SetCookies(response.RequestMessage!.RequestUri!, cookies.Single()); + var authCookie = cookieContainer.GetAllCookies() + .FirstOrDefault(c => c.Name == LexAuthConstants.AuthCookieName); + if (authCookie is null) throw new InvalidOperationException("Auth cookie not found"); + entry.SetValue(authCookie.Value); + entry.AbsoluteExpiration = authCookie.Expires; + logger.LogInformation("Got auth token: {AuthToken}", authCookie.Value); + return authCookie.Value; + }) ?? throw new NullReferenceException("unable to get the login token"); + } + catch (Exception e) + { + throw new InvalidOperationException("Unable to get auth token", e); + } + } +} diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index 261dedd9b..c34848d5b 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -1,7 +1,9 @@ using FwHeadless; using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Api; using FwLiteProjectSync; using LcmCrdt; +using LcmCrdt.RemoteSync; using LexData; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Options; @@ -41,7 +43,7 @@ app.Run(); -static async Task, NotFound>> ExecuteMergeRequest( +static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest( ILogger logger, IServiceProvider services, SendReceiveService srService, @@ -50,6 +52,8 @@ ProjectsService projectsService, ProjectLookupService projectLookupService, CrdtFwdataProjectSyncService syncService, + CrdtHttpSyncService crdtHttpSyncService, + IHttpClientFactory httpClientFactory, Guid projectId, bool dryRun = false) { @@ -67,6 +71,11 @@ return TypedResults.NotFound(); } logger.LogInformation("Project code is {projectCode}", projectCode); + //if we can't sync with lexbox fail fast + if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName))) + { + return TypedResults.Problem("Unable to authenticate with Lexbox"); + } var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}"); if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder); @@ -77,6 +86,29 @@ logger.LogDebug("crdtFile: {crdtFile}", crdtFile); logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath); + var fwdataApi = SetupFwData(fwDataProject, srService, projectCode, logger, fwDataFactory); + var crdtProject = await SetupCrdtProject(crdtFile, projectLookupService, projectId, projectsService, projectFolder, fwdataApi.ProjectId, config.Value.LexboxUrl); + + var miniLcmApi = await services.OpenCrdtProject(crdtProject); + var crdtSyncService = services.GetRequiredService(); + await crdtSyncService.Sync(); + + + var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun); + logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges); + + await crdtSyncService.Sync(); + var srResult2 = srService.SendReceive(fwDataProject, projectCode); + logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output); + return TypedResults.Ok(result); +} + +static FwDataMiniLcmApi SetupFwData(FwDataProject fwDataProject, + SendReceiveService srService, + string projectCode, + ILogger logger, + FwDataFactory fwDataFactory) +{ if (File.Exists(fwDataProject.FilePath)) { var srResult = srService.SendReceive(fwDataProject, projectCode); @@ -87,15 +119,37 @@ var srResult = srService.Clone(fwDataProject, projectCode); logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); } + var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true); - var crdtProject = File.Exists(crdtFile) ? - new CrdtProject("crdt", crdtFile) : - await projectsService.CreateProject(new("crdt", SeedNewProjectData: false, Path: projectFolder, FwProjectId: fwdataApi.ProjectId)); - var miniLcmApi = await services.OpenCrdtProject(crdtProject); - var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun); - logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges); - var srResult2 = srService.SendReceive(fwDataProject, projectCode); - logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output); - return TypedResults.Ok(result); + return fwdataApi; +} + +static async Task SetupCrdtProject(string crdtFile, + ProjectLookupService projectLookupService, + Guid projectId, + ProjectsService projectsService, + string projectFolder, + Guid fwProjectId, + string lexboxUrl) +{ + if (File.Exists(crdtFile)) + { + return new CrdtProject("crdt", crdtFile); + } + else + { + if (await projectLookupService.IsCrdtProject(projectId)) + { + //todo determine what to do in this case, maybe we just download the project? + throw new InvalidOperationException("Project already exists, not sure why it's not on the server"); + } + return await projectsService.CreateProject(new("crdt", + SeedNewProjectData: false, + Id: projectId, + Path: projectFolder, + FwProjectId: fwProjectId, + Domain: new Uri(lexboxUrl))); + } + } diff --git a/backend/FwHeadless/ProjectLookupService.cs b/backend/FwHeadless/ProjectLookupService.cs index 9cb8cb971..ad3c6fa20 100644 --- a/backend/FwHeadless/ProjectLookupService.cs +++ b/backend/FwHeadless/ProjectLookupService.cs @@ -1,5 +1,6 @@ using LexData; using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Core; namespace FwHeadless; @@ -13,4 +14,9 @@ public class ProjectLookupService(LexBoxDbContext dbContext) .FirstOrDefaultAsync(); return projectCode; } + + public async Task IsCrdtProject(Guid projectId) + { + return await dbContext.Set().AnyAsync(c => c.ProjectId == projectId); + } } diff --git a/backend/FwHeadless/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json index 22a96c2a5..c39164b02 100644 --- a/backend/FwHeadless/appsettings.Development.json +++ b/backend/FwHeadless/appsettings.Development.json @@ -12,7 +12,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } } } diff --git a/backend/FwHeadless/appsettings.json b/backend/FwHeadless/appsettings.json index d56001975..cbe77f391 100644 --- a/backend/FwHeadless/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -5,7 +5,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } }, "AllowedHosts": "*" diff --git a/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs index d3c22087a..38028bc03 100644 --- a/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs +++ b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs @@ -20,6 +20,10 @@ public async ValueTask ShouldSync(ISyncHttp syncHttp) { var responseMessage = await syncHttp.HealthCheck(); _isHealthy = responseMessage.IsSuccessStatusCode; + if (!_isHealthy.Value) + { + logger.LogWarning("Health check failed, response status code {StatusCode}", responseMessage.StatusCode); + } _lastHealthCheck = responseMessage.Headers.Date ?? DateTimeOffset.UtcNow; } catch (HttpRequestException e) @@ -50,6 +54,13 @@ public async ValueTask CreateProjectSyncable(ProjectData project, Htt { return new CrdtProjectSync(RestService.For(client, refitSettings), project.Id, project.ClientId, this); } + + public async ValueTask TestAuth(HttpClient client) + { + logger.LogInformation("Testing auth, client base url: {ClientBaseUrl}", client.BaseAddress); + var syncable = await CreateProjectSyncable(new ProjectData("test", Guid.Empty, null, Guid.Empty), client); + return await syncable.ShouldSync(); + } } internal class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, CrdtHttpSyncService httpSyncService) : ISyncable diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 4cdd1193d..957c1a028 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -21,7 +21,7 @@ public static class AuthKernel { public const string DefaultScheme = "JwtOrCookie"; public const string JwtOverBasicAuthUsername = "bearer"; - public const string AuthCookieName = ".LexBoxAuth"; + public const string AuthCookieName = LexAuthConstants.AuthCookieName; public static void AddLexBoxAuth(IServiceCollection services, IConfigurationRoot configuration, diff --git a/backend/LexCore/Auth/LexAuthConstants.cs b/backend/LexCore/Auth/LexAuthConstants.cs index c5c4e398a..8150042e9 100644 --- a/backend/LexCore/Auth/LexAuthConstants.cs +++ b/backend/LexCore/Auth/LexAuthConstants.cs @@ -2,6 +2,7 @@ namespace LexCore.Auth; public static class LexAuthConstants { + public const string AuthCookieName = ".LexBoxAuth"; public const string RoleClaimType = "role"; public const string EmailClaimType = "email"; public const string UsernameClaimType = "user";