diff --git a/LexBox.sln b/LexBox.sln index 612c0e95e..cfa667dcd 100644 --- a/LexBox.sln +++ b/LexBox.sln @@ -21,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testing", "backend\Testing\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FixFwData", "backend\FixFwData\FixFwData.csproj", "{D7FC8B93-15A1-4D0B-9EAB-45596DB147F4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LfNext", "LfNext", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}" + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FwLite", "FwLite", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LfClassicData", "backend\LfClassicData\LfClassicData.csproj", "{E8BB768B-C3DC-4BE6-9B9F-82319E05AF86}" EndProject @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\Fw EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwHeadless", "backend\FwHeadless\FwHeadless.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwLiteShared", "backend\FwLite\FwLiteShared\FwLiteShared.csproj", "{73DC604C-C501-410D-B56B-0544AD6EF1C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -145,6 +147,10 @@ Global {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.Build.0 = Release|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -165,6 +171,7 @@ Global {5A9011D8-6EC1-4550-BDD7-AFF00DB2B921} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {00AE5440-0E36-4488-935B-5B11301BA57D} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {ECBA46AB-AF87-4D4D-9716-FD77264B817F} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} + {73DC604C-C501-410D-B56B-0544AD6EF1C2} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {440AE83C-6DB0-4F18-B2C1-BCD33F0645B6} diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index 95e175463..620635504 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -58,7 +58,7 @@ static async Task, NotFound, ProblemHttpResult>> ExecuteM SendReceiveService srService, IOptions config, FwDataFactory fwDataFactory, - ProjectsService projectsService, + CrdtProjectsService projectsService, ProjectLookupService projectLookupService, CrdtFwdataProjectSyncService syncService, CrdtHttpSyncService crdtHttpSyncService, @@ -137,7 +137,7 @@ static async Task SetupFwData(FwDataProject fwDataProject, static async Task SetupCrdtProject(string crdtFile, ProjectLookupService projectLookupService, Guid projectId, - ProjectsService projectsService, + CrdtProjectsService projectsService, string projectFolder, Guid fwProjectId, string lexboxUrl) diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj index 71ce7fca5..14017906e 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj @@ -80,7 +80,6 @@ - diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index 0d7727ee2..ece707252 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -1,7 +1,7 @@ using Windows.ApplicationModel; using FwLiteDesktop.ServerBridge; +using FwLiteShared.Auth; using LcmCrdt; -using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs index 2dfbdb6fd..ef91d179f 100644 --- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs +++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs @@ -1,7 +1,6 @@ using FwLiteDesktop.ServerBridge; using LcmCrdt; using LocalWebApp; -using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Maui.LifecycleEvents; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs index 3bb84e1ca..cc3551181 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs @@ -54,7 +54,7 @@ public async Task InitializeAsync() File.Move(Path.Combine(fwDataProjectPath, "sena-3.fwdata"), fwDataProject.FilePath); var fwDataMiniLcmApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwDataProject, false); - var crdtProject = await services.GetRequiredService() + var crdtProject = await services.GetRequiredService() .CreateProject(new(projectName, FwProjectId: fwDataMiniLcmApi.ProjectId, SeedNewProjectData: false)); var crdtMiniLcmApi = (CrdtMiniLcmApi)await services.OpenCrdtProject(crdtProject); return (crdtMiniLcmApi, fwDataMiniLcmApi, services, cleanup); diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs index 3cae9d60a..149adf76a 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs @@ -53,7 +53,7 @@ public async Task InitializeAsync() _services.ServiceProvider.GetRequiredService>().Value.ProjectPath; if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true); Directory.CreateDirectory(crdtProjectsFolder); - var crdtProject = await _services.ServiceProvider.GetRequiredService() + var crdtProject = await _services.ServiceProvider.GetRequiredService() .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: false)); CrdtApi = (CrdtMiniLcmApi) await _services.ServiceProvider.OpenCrdtProject(crdtProject); } diff --git a/backend/FwLite/FwLiteProjectSync/Program.cs b/backend/FwLite/FwLiteProjectSync/Program.cs index e59799d28..d587f5fb0 100644 --- a/backend/FwLite/FwLiteProjectSync/Program.cs +++ b/backend/FwLite/FwLiteProjectSync/Program.cs @@ -52,7 +52,7 @@ public static Task Main(string[] args) var services = scope.ServiceProvider; var logger = services.GetRequiredService>(); var fwdataApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwProjectName, true); - var projectsService = services.GetRequiredService(); + var projectsService = services.GetRequiredService(); var crdtProject = projectsService.GetProject(crdtProjectName); if (crdtProject is null) { diff --git a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs similarity index 97% rename from backend/FwLite/LocalWebApp/Auth/AuthConfig.cs rename to backend/FwLite/FwLiteShared/Auth/AuthConfig.cs index 542aae2db..788316c6e 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs +++ b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using LcmCrdt; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; public class AuthConfig { diff --git a/backend/FwLite/FwLiteShared/Auth/AuthService.cs b/backend/FwLite/FwLiteShared/Auth/AuthService.cs new file mode 100644 index 000000000..ec7621a87 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/AuthService.cs @@ -0,0 +1,44 @@ +using FwLiteShared.Projects; +using Microsoft.Extensions.Options; + +namespace FwLiteShared.Auth; + +public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, string? Authority); +public class AuthService(LexboxProjectService lexboxProjectService, OAuthClientFactory clientFactory, IOptions options) +{ + public IAsyncEnumerable Servers() + { + return lexboxProjectService.Servers().ToAsyncEnumerable().SelectAwait(async s => + { + var currentName = await clientFactory.GetClient(s).GetCurrentName(); + return new ServerStatus(s.DisplayName, + !string.IsNullOrEmpty(currentName), + currentName, + s.Authority.Authority); + }); + } + + public async Task SignInWebView(LexboxServer server) + { + var result = await clientFactory.GetClient(server).SignIn(string.Empty);//does nothing here + if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view"); + } + + public async Task SignInWebApp(LexboxServer server, string returnUrl) + { + var result = await clientFactory.GetClient(server).SignIn(returnUrl); + if (result.HandledBySystemWebView) throw new InvalidOperationException("Sign in handled by system web view"); + if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null"); + return result.AuthUri.ToString(); + } + + public async Task Logout(LexboxServer server) + { + await clientFactory.GetClient(server).Logout(); + } + + public async Task GetLoggedInName(LexboxServer server) + { + return await clientFactory.GetClient(server).GetCurrentName(); + } +} diff --git a/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs new file mode 100644 index 000000000..4c2b2541b --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs @@ -0,0 +1,7 @@ +namespace FwLiteShared.Auth; + +public interface IRedirectUrlProvider +{ + string? GetRedirectUrl(); + bool ShouldRecreateAuthHelper(string? redirectUrl); +} diff --git a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs similarity index 88% rename from backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs rename to backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs index ea75bdd5c..dab270f29 100644 --- a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs +++ b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs @@ -1,6 +1,7 @@ -using Microsoft.IdentityModel.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Abstractions; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; public class LoggerAdapter(ILogger logger): IIdentityLogger { diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs similarity index 84% rename from backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs rename to backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index b33738f06..b9affb158 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -1,65 +1,56 @@ using System.Net.Http.Headers; -using System.Security.Cryptography; -using LocalWebApp.Routes; -using LocalWebApp.Services; +using FwLiteShared.Projects; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; /// -/// when injected directly it will use the authority of the current project, to get a different authority use +/// when injected directly it will use the authority of the current project, to get a different authority use /// helper class for using MSAL.net /// docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/overview /// -public class AuthHelpers +public class OAuthClient { public static IReadOnlyCollection DefaultScopes { get; } = ["profile", "openid"]; public const string AuthHttpClientName = "AuthHttpClient"; - private readonly HostString _redirectHost; - private readonly bool _isRedirectHostGuess; + public string? RedirectUrl { get; } private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; private readonly OAuthService _oAuthService; - private readonly UrlContext _urlContext; private readonly LexboxServer _lexboxServer; private readonly LexboxProjectService _lexboxProjectService; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IPublicClientApplication _application; AuthenticationResult? _authResult; - public AuthHelpers(LoggerAdapter loggerAdapter, + public OAuthClient(LoggerAdapter loggerAdapter, IHttpMessageHandlerFactory httpMessageHandlerFactory, IOptions options, - LinkGenerator linkGenerator, + IRedirectUrlProvider? redirectUrlProvider, OAuthService oAuthService, - UrlContext urlContext, LexboxServer lexboxServer, LexboxProjectService lexboxProjectService, - ILogger logger, + ILogger logger, IHostEnvironment hostEnvironment) { _httpMessageHandlerFactory = httpMessageHandlerFactory; _oAuthService = oAuthService; - _urlContext = urlContext; _lexboxServer = lexboxServer; _lexboxProjectService = lexboxProjectService; _logger = logger; - (var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl(); - _redirectHost = HostString.FromUriComponent(hostUrl); - var redirectUri = options.Value.SystemWebViewLogin + RedirectUrl = options.Value.SystemWebViewLogin ? "http://localhost" //system web view will always have no path, changing this will not do anything in that case - : linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, - new RouteValueDictionary(), - hostUrl.Scheme, - _redirectHost); + : redirectUrlProvider?.GetRedirectUrl() ?? throw new InvalidOperationException("No IRedirectUrlProvider configured, required for non-system web view login"); //todo configure token cache as seen here //https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache _application = PublicClientApplicationBuilder.Create(options.Value.ClientId) .WithExperimentalFeatures() .WithLogging(loggerAdapter, hostEnvironment.IsDevelopment()) .WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory)) - .WithRedirectUri(redirectUri) + .WithRedirectUri(RedirectUrl) .WithOidcAuthority(lexboxServer.Authority.ToString()) .Build(); _ = MsalCacheHelper.CreateAsync(BuildCacheProperties(options.Value.CacheFileName)).ContinueWith( @@ -99,11 +90,6 @@ private static StorageCreationProperties BuildCacheProperties(string cacheFileNa return propertiesBuilder.Build(); } - public bool IsHostUrlValid() - { - return !_isRedirectHostGuess || _redirectHost == HostString.FromUriComponent(_urlContext.GetUrl().host); - } - private class HttpClientFactoryAdapter(IHttpMessageHandlerFactory httpMessageHandlerFactory) : IMsalHttpClientFactory { @@ -178,10 +164,10 @@ await _application return auth?.AccessToken; } - /// + /// ] /// will return null if no auth token is available /// - public async ValueTask CreateClient() + public async ValueTask CreateHttpClient() { var auth = await GetAuth(); if (auth is null) return null; diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs new file mode 100644 index 000000000..1058e5aa2 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FwLiteShared.Auth; + +public class OAuthClientFactory(IServiceProvider provider, + IOptions options, + IRedirectUrlProvider? redirectUrlProvider, + ILogger logger) +{ + private readonly ConcurrentDictionary _helpers = new(); + + private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority; + + /// + /// gets an Auth Helper for the given server + /// + public OAuthClient GetClient(LexboxServer server) + { + var helper = _helpers.GetOrAdd(AuthorityKey(server), + static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server), + (server, provider)); + //an auth helper can get created based on the server host, however in development that will not be the same as the client host + //so we need to recreate it if the host is not valid, this is only required when not using system web view login + if (!options.Value.SystemWebViewLogin && redirectUrlProvider is not null && redirectUrlProvider.ShouldRecreateAuthHelper(helper.RedirectUrl)) + { + logger.LogInformation("Recreating auth helper with Redirect Url {RedirectUrl}", helper.RedirectUrl); + _helpers.TryRemove(AuthorityKey(server), out _); + return GetClient(server); + } + + return helper; + } + + /// + /// get auth helper for a given project + /// + public OAuthClient GetClient(ProjectData project) + { + var originDomain = project.OriginDomain; + if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); + return GetClient(options.Value.GetServer(project)); + } +} diff --git a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs similarity index 96% rename from backend/FwLite/LocalWebApp/Auth/OAuthService.cs rename to backend/FwLite/FwLiteShared/Auth/OAuthService.cs index fe9d9c94a..9c0605a6c 100644 --- a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs @@ -1,11 +1,12 @@ using System.Threading.Channels; using System.Web; -using LocalWebApp.Utils; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; //this class is commented with a number of step comments, these are the steps in the OAuth flow //if a step comes before a method that means it awaits that call, if it comes after that means it resumes after the above await @@ -36,7 +37,7 @@ public async Task SubmitLoginRequest(IPublicClientApplication appl private async Task HandleSystemWebViewLogin(IPublicClientApplication application, CancellationToken cancellation) { - var result = await application.AcquireTokenInteractive(AuthHelpers.DefaultScopes) + var result = await application.AcquireTokenInteractive(OAuthClient.DefaultScopes) .WithUseEmbeddedWebView(false) .WithSystemWebViewOptions(new() { }) .ExecuteAsync(cancellation); @@ -69,7 +70,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { //todo we can get stuck here if the user doesn't complete the login, this basically bricks the login at the moment. We need a timeout or something //step 2 - var result = await loginRequest.Application.AcquireTokenInteractive(AuthHelpers.DefaultScopes) + var result = await loginRequest.Application.AcquireTokenInteractive(OAuthClient.DefaultScopes) .WithCustomWebUi(loginRequest) .ExecuteAsync(stoppingToken); //step 7, causes step 8 to resume diff --git a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs similarity index 89% rename from backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs rename to backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs index 35da0129f..51e0c859b 100644 --- a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs +++ b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs @@ -1,4 +1,4 @@ -namespace LocalWebApp.Utils; +namespace FwLiteShared; public static class CancellationTokenExtensions { diff --git a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs b/backend/FwLite/FwLiteShared/ChangeEventBus.cs similarity index 52% rename from backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs rename to backend/FwLite/FwLiteShared/ChangeEventBus.cs index 06d331d8e..29734cc0e 100644 --- a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs +++ b/backend/FwLite/FwLiteShared/ChangeEventBus.cs @@ -1,33 +1,13 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using LcmCrdt; -using LocalWebApp.Hubs; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Caching.Memory; using MiniLcm.Models; -namespace LocalWebApp.Services; +namespace FwLiteShared; -public class ChangeEventBus( - ProjectContext projectContext, - IHubContext hubContext, - ILogger logger, - IMemoryCache cache) +public class ChangeEventBus(ProjectContext projectContext) : IDisposable { - public IDisposable ListenForEntryChanges(string projectName, string connectionId) => - _entryUpdated - .Where(n => n.ProjectName == projectName) - .Subscribe(n => OnEntryChangedExternal(n.Entry, connectionId)); - - private void OnEntryChangedExternal(Entry e, string connectionId) - { - var currentFilter = CrdtMiniLcmApiHub.CurrentProjectFilter(cache, connectionId); - if (currentFilter.Invoke(e)) - { - _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(e); - } - } private record struct ChangeNotification(Entry Entry, string ProjectName); diff --git a/backend/FwLite/FwLiteShared/FwLiteShared.csproj b/backend/FwLite/FwLiteShared/FwLiteShared.csproj new file mode 100644 index 000000000..9da6832da --- /dev/null +++ b/backend/FwLite/FwLiteShared/FwLiteShared.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs new file mode 100644 index 000000000..fc0ca094f --- /dev/null +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -0,0 +1,62 @@ +using FwDataMiniLcmBridge; +using FwLiteProjectSync; +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using FwLiteShared.Sync; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FwLiteShared; + +public static class FwLiteSharedKernel +{ + public static IServiceCollection AddFwLiteShared(this IServiceCollection services, IHostEnvironment environment) + { + services.AddHttpClient(); + services.AddAuthHelpers(environment); + services.AddLcmCrdtClient(); + services.AddFwDataBridge(); + services.AddFwLiteProjectSync(); + + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + return services; + } + + private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(CurrentAuthHelperFactory); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations(); + services.AddSingleton(); + var httpClientBuilder = services.AddHttpClient(OAuthClient.AuthHttpClientName); + if (environment.IsDevelopment()) + { + // Allow self-signed certificates in development + httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() => + { + return new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true + }; + }); + } + } + + private static OAuthClient CurrentAuthHelperFactory(this IServiceProvider serviceProvider) + { + var authHelpersFactory = serviceProvider.GetRequiredService(); + var currentProjectService = serviceProvider.GetRequiredService(); + return authHelpersFactory.GetClient(currentProjectService.ProjectData); + } +} diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs new file mode 100644 index 000000000..f53b28070 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -0,0 +1,84 @@ +using FwDataMiniLcmBridge; +using FwLiteShared.Auth; +using FwLiteShared.Sync; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; + +namespace FwLiteShared.Projects; + +public record ProjectModel( + string Name, + bool Crdt, + bool Fwdata, + bool Lexbox = false, + string? ServerAuthority = null, + Guid? Id = null); + +public record ServerProjects(LexboxServer Server, ProjectModel[] Projects); +public class CombinedProjectsService(LexboxProjectService lexboxProjectService, CrdtProjectsService crdtProjectsService, + FieldWorksProjectList fieldWorksProjectList) +{ + public async Task RemoteProjects() + { + var lexboxServers = lexboxProjectService.Servers(); + ServerProjects[] serverProjects = new ServerProjects[lexboxServers.Length]; + for (var i = 0; i < lexboxServers.Length; i++) + { + var server = lexboxServers[i]; + var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); + serverProjects[i] = new ServerProjects(server, + lexboxProjects.Select(p => new ProjectModel(p.Name, + Crdt: p.IsCrdtProject, + Fwdata: false, + Lexbox: true, + server.Authority.Authority, + p.Id)) + .ToArray()); + } + + return serverProjects; + } + + public async Task> LocalProjects() + { + var crdtProjects = await crdtProjectsService.ListProjects(); + //todo get project Id and use that to specify the Id in the model. Also pull out server + var projects = crdtProjects.ToDictionary(p => p.Name, + p => + { + var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null; + return new ProjectModel(p.Name, + true, + false, + p.Data?.OriginDomain is not null, + uri?.Authority, + p.Data?.Id); + }); + //basically populate projects and indicate if they are lexbox or fwdata + foreach (var p in fieldWorksProjectList.EnumerateProjects()) + { + if (projects.TryGetValue(p.Name, out var project)) + { + projects[p.Name] = project with { Fwdata = true }; + } + else + { + projects.Add(p.Name, new ProjectModel(p.Name, false, true)); + } + } + + return projects.Values; + } + + public async Task DownloadProject(Guid lexboxProjectId, string projectName, LexboxServer server) + { + await crdtProjectsService.CreateProject(new(projectName, + lexboxProjectId, + server.Authority, + async (provider, project) => + { + await provider.GetRequiredService().ExecuteSync(); + }, + SeedNewProjectData: false)); + } +} diff --git a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs similarity index 85% rename from backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs rename to backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs index 20f6ab04c..e78cc7a42 100644 --- a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs +++ b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs @@ -1,14 +1,16 @@ using System.Diagnostics; -using FwLiteProjectSync; using FwDataMiniLcmBridge; +using FwLiteProjectSync; using Humanizer; using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using MiniLcm; -namespace LocalWebApp.Services; +namespace FwLiteShared.Projects; public class ImportFwdataService( - ProjectsService projectsService, + CrdtProjectsService crdtProjectsService, ILogger logger, FwDataFactory fwDataFactory, FieldWorksProjectList fieldWorksProjectList, @@ -26,7 +28,7 @@ public async Task Import(string projectName) try { using var fwDataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, false); - var project = await projectsService.CreateProject(new(fwDataProject.Name, + var project = await crdtProjectsService.CreateProject(new(fwDataProject.Name, SeedNewProjectData: false, FwProjectId: fwDataApi.ProjectId, AfterCreate: async (provider, project) => diff --git a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs similarity index 89% rename from backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs rename to backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs index c392ce503..b60b2e915 100644 --- a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs +++ b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs @@ -1,14 +1,17 @@ -using LcmCrdt; -using LocalWebApp.Auth; -using Microsoft.Extensions.Options; +using System.Net.Http.Json; +using FwLiteShared.Auth; +using FwLiteShared.Sync; +using LcmCrdt; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using MiniLcm.Push; -namespace LocalWebApp.Services; +namespace FwLiteShared.Projects; public class LexboxProjectService( - AuthHelpersFactory helpersFactory, + OAuthClientFactory clientFactory, ILogger logger, IHttpMessageHandlerFactory httpMessageHandlerFactory, BackgroundSyncService backgroundSyncService, @@ -28,7 +31,7 @@ public async Task GetLexboxProjects(LexboxServer server) async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var httpClient = await helpersFactory.GetHelper(server).CreateClient(); + var httpClient = await clientFactory.GetClient(server).CreateHttpClient(); if (httpClient is null) return []; try { @@ -49,7 +52,7 @@ private static string CacheKey(LexboxServer server) public async Task GetLexboxProjectId(LexboxServer server, string code) { - var httpClient = await helpersFactory.GetHelper(server).CreateClient(); + var httpClient = await clientFactory.GetClient(server).CreateHttpClient(); if (httpClient is null) return null; try { @@ -86,7 +89,7 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT return connection; } - if (await helpersFactory.GetHelper(server).GetCurrentToken() is null) + if (await clientFactory.GetClient(server).GetCurrentToken() is null) { logger.LogWarning("Unable to create signalR client, user is not authenticated to {OriginDomain}", server.Authority); @@ -103,10 +106,10 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT connectionOptions.HttpMessageHandlerFactory = handler => { //use a client that does not validate certs in dev - return httpMessageHandlerFactory.CreateHandler(AuthHelpers.AuthHttpClientName); + return httpMessageHandlerFactory.CreateHandler(OAuthClient.AuthHttpClientName); }; connectionOptions.AccessTokenProvider = - async () => await helpersFactory.GetHelper(server).GetCurrentToken(); + async () => await clientFactory.GetClient(server).GetCurrentToken(); }) .Build(); diff --git a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs similarity index 87% rename from backend/FwLite/LocalWebApp/BackgroundSyncService.cs rename to backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs index bbe93c26c..cacac6867 100644 --- a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs @@ -1,12 +1,15 @@ using System.Threading.Channels; -using SIL.Harmony; using LcmCrdt; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SIL.Harmony; -namespace LocalWebApp; +namespace FwLiteShared.Sync; public class BackgroundSyncService( - ProjectsService projectsService, + CrdtProjectsService crdtProjectsService, IHostApplicationLifetime applicationLifetime, ProjectContext projectContext, ILogger logger, @@ -28,7 +31,7 @@ public void TriggerSync(Guid projectId, Guid? ignoredClientId = null) return; } - var crdtProject = projectsService.GetProject(projectData.Name); + var crdtProject = crdtProjectsService.GetProject(projectData.Name); if (crdtProject is null) { logger.LogWarning("Received project update for unknown project {ProjectName}", projectData.Name); @@ -58,7 +61,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { //need to wait until application is started, otherwise Server urls will be unknown which prevents creating downstream services await StartedAsync(); - var crdtProjects = await projectsService.ListProjects(); + var crdtProjects = await crdtProjectsService.ListProjects(); foreach (var crdtProject in crdtProjects) { await SyncProject(crdtProject); @@ -76,7 +79,7 @@ private async Task SyncProject(CrdtProject crdtProject) { try { - await using var serviceScope = projectsService.CreateProjectScope(crdtProject); + await using var serviceScope = crdtProjectsService.CreateProjectScope(crdtProject); await serviceScope.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); var syncService = serviceScope.ServiceProvider.GetRequiredService(); return await syncService.ExecuteSync(); diff --git a/backend/FwLite/LocalWebApp/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs similarity index 76% rename from backend/FwLite/LocalWebApp/SyncService.cs rename to backend/FwLite/FwLiteShared/Sync/SyncService.cs index 7c4be59f6..e797be14d 100644 --- a/backend/FwLite/LocalWebApp/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -1,20 +1,21 @@ -using SIL.Harmony; +using FwLiteShared.Auth; +using FwLiteShared.Projects; using LcmCrdt; using LcmCrdt.RemoteSync; -using LocalWebApp.Auth; -using LocalWebApp.Services; +using Microsoft.Extensions.Logging; using MiniLcm; using MiniLcm.Models; -using SIL.Harmony.Entities; +using SIL.Harmony; -namespace LocalWebApp; +namespace FwLiteShared.Sync; public class SyncService( DataModel dataModel, CrdtHttpSyncService remoteSyncServiceServer, - AuthHelpersFactory authHelpersFactory, + OAuthClientFactory oAuthClientFactory, CurrentProjectService currentProjectService, ChangeEventBus changeEventBus, + LexboxProjectService lexboxProjectService, IMiniLcmApi lexboxApi, ILogger logger) { @@ -28,7 +29,7 @@ public async Task ExecuteSync() return new SyncResults([], [], false); } - var httpClient = await authHelpersFactory.GetHelper(project).CreateClient(); + var httpClient = await oAuthClientFactory.GetClient(project).CreateHttpClient(); if (httpClient is null) { logger.LogWarning( @@ -75,4 +76,21 @@ private async Task SendNotifications(SyncResults syncResults) _ => null }; } + + public async Task UploadProject(Guid lexboxProjectId, LexboxServer server) + { + await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId); + try + { + await ExecuteSync(); + } + catch + { + await currentProjectService.SetProjectSyncOrigin(null, null); + throw; + } + + //todo maybe decouple this + lexboxProjectService.InvalidateProjectsCache(server); + } } diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs index 359edff20..3173e290d 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs @@ -43,7 +43,7 @@ public async Task InitializeAsync() { await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. - await ProjectsService.InitProjectDb(_crdtDbContext, + await CrdtProjectsService.InitProjectDb(_crdtDbContext, new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid())); await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); } diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index bc93339cc..1ce5a0321 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -37,7 +37,7 @@ public async Task InitializeAsync() _crdtDbContext = _services.ServiceProvider.GetRequiredService(); await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. - await ProjectsService.InitProjectDb(_crdtDbContext, + await CrdtProjectsService.InitProjectDb(_crdtDbContext, new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid())); await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index 702431f66..9fb67472a 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -16,7 +16,7 @@ public async Task OpeningAProjectWorks() using var host = builder.Build(); var services = host.Services; var asyncScope = services.CreateAsyncScope(); - await asyncScope.ServiceProvider.GetRequiredService() + await asyncScope.ServiceProvider.GetRequiredService() .CreateProject(new(Name: "OpeningAProjectWorks", Path: "", SeedNewProjectData: true)); var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(new CrdtProject("OpeningAProjectWorks", sqliteConnectionString)); diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs similarity index 60% rename from backend/FwLite/LcmCrdt/ProjectsService.cs rename to backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 63aba27fa..aadc0dbaf 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -1,4 +1,5 @@ -using SIL.Harmony; +using System.Text.RegularExpressions; +using SIL.Harmony; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,7 +8,7 @@ namespace LcmCrdt; -public class ProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache) +public partial class CrdtProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache) { public Task ListProjects() { @@ -42,8 +43,14 @@ public record CreateProjectRequest( string? Path = null, Guid? FwProjectId = null); + public async Task CreateExampleProject(string name) + { + return await CreateProject(new(name, AfterCreate: SampleProjectData, SeedNewProjectData: true)); + } + public async Task CreateProject(CreateProjectRequest request) { + if (!ProjectName().IsMatch(request.Name)) throw new InvalidOperationException("Project name is invalid"); //poor man's sanitation var name = Path.GetFileName(request.Name); var sqliteFile = Path.Combine(request.Path ?? config.Value.ProjectPath, $"{name}.sqlite"); @@ -104,4 +111,62 @@ public void SetActiveProject(string name) var project = GetProject(name) ?? throw new InvalidOperationException($"Crdt Project {name} not found"); SetProjectScope(project); } + + [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] + public static partial Regex ProjectName(); + + public static async Task SampleProjectData(IServiceProvider provider, CrdtProject project) + { + var lexboxApi = provider.GetRequiredService(); + await lexboxApi.CreateEntry(new() + { + Id = Guid.NewGuid(), + LexemeForm = { Values = { { "en", "Apple" } } }, + CitationForm = { Values = { { "en", "Apple" } } }, + LiteralMeaning = { Values = { { "en", "Fruit" } } }, + Senses = + [ + new() + { + Gloss = { Values = { { "en", "Fruit" } } }, + Definition = + { + Values = + { + { + "en", + "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" + } + } + }, + SemanticDomains = [], + ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] + } + ] + }); + + await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular, + new() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Vernacular, + WsId = "en", + Name = "English", + Abbreviation = "en", + Font = "Arial", + Exemplars = WritingSystem.LatinExemplars + }); + + await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, + new() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Analysis, + WsId = "en", + Name = "English", + Abbreviation = "en", + Font = "Arial", + Exemplars = WritingSystem.LatinExemplars + }); + } } diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs new file mode 100644 index 000000000..e70e6c412 --- /dev/null +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -0,0 +1,104 @@ +using Humanizer; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Db; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using SIL.Harmony.Entities; + +namespace LcmCrdt; +public record ProjectActivity( + Guid CommitId, + DateTimeOffset Timestamp, + List> Changes) +{ + public string ChangeName => ChangeNameHelper(Changes); + + private static string ChangeNameHelper(List> changeEntities) + { + return changeEntities switch + { + { Count: 0 } => "No changes", + { Count: 1 } => changeEntities[0].Change switch + { + //todo call JsonPatchChange.Summarize() instead of this + IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + + change.EntityType.Name, + IChange change => change.GetType().Name.Humanize() + }, + { Count: var count } => $"{count} changes" + }; + } +} + +public record HistoryLineItem( + Guid CommitId, + Guid EntityId, + DateTimeOffset Timestamp, + Guid? SnapshotId, + string? ChangeName, + IObjectWithId? Entity, + string? EntityName) +{ + public HistoryLineItem( + Guid commitId, + Guid entityId, + DateTimeOffset timestamp, + Guid? snapshotId, + IChange? change, + IObjectBase? entity, + string typeName) : this(commitId, + entityId, + new DateTimeOffset(timestamp.Ticks, + TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC + snapshotId, + change?.GetType().Name, + (IObjectWithId?) entity?.DbObject, + typeName) + { + } +} + +public class HistoryService(ICrdtDbContext dbContext, DataModel dataModel) +{ + public IAsyncEnumerable ProjectActivity() + { + return dbContext.Commits + .DefaultOrderDescending() + .Take(20) + .Select(c => new ProjectActivity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities)) + .AsAsyncEnumerable(); + } + + public async Task GetSnapshot(Guid snapshotId) + { + return await dbContext.Snapshots.SingleOrDefaultAsync(s => s.Id == snapshotId); + } + + public async Task GetObject(DateTime timestamp, Guid entityId) + { + //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included + //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now. + return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId); + } + + public IAsyncEnumerable GetHistory(Guid entityId) + { + var changeEntities = dbContext.Set>(); + var query = from commit in dbContext.Commits.DefaultOrder() + from snapshot in dbContext.Snapshots.LeftJoin( + s => s.CommitId == commit.Id && s.EntityId == entityId) + from change in changeEntities.LeftJoin(c => + c.CommitId == commit.Id && c.EntityId == entityId) + where snapshot.Id != null || change.EntityId != null + select new HistoryLineItem(commit.Id, + entityId, + commit.HybridDateTime.DateTime, + snapshot.Id, + change.Change, + snapshot.Entity, + snapshot.TypeName); + return query.ToLinqToDB().AsAsyncEnumerable(); + } +} diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index ce06a235f..66bd926ff 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 74562c440..397ead976 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -35,8 +35,9 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic ); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddHttpClient(); services.AddSingleton(provider => new RefitSettings @@ -188,7 +189,7 @@ public static Task OpenCrdtProject(this IServiceProvider services, //this method must not be async, otherwise Setting the project scope will not work as expected. //the project is stored in the async scope, if a new scope is created in this method then it will be gone once the method returns //making the lcm api unusable - var projectsService = services.GetRequiredService(); + var projectsService = services.GetRequiredService(); projectsService.SetProjectScope(project); return LoadMiniLcmApi(services); } diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs b/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs deleted file mode 100644 index d9fc64aac..000000000 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Concurrent; -using LcmCrdt; -using LocalWebApp.Services; -using Microsoft.Extensions.Options; - -namespace LocalWebApp.Auth; - -public class AuthHelpersFactory( - IServiceProvider provider, - ProjectContext projectContext, - IOptions options, - IHttpContextAccessor contextAccessor) -{ - private readonly ConcurrentDictionary _helpers = new(); - - private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority; - - /// - /// gets an Auth Helper for the given server - /// - public AuthHelpers GetHelper(LexboxServer server) - { - var helper = _helpers.GetOrAdd(AuthorityKey(server), - static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server), - (server, provider)); - //an auth helper can get created based on the server host, however in development that will not be the same as the client host - //so we need to recreate it if the host is not valid - if (!helper.IsHostUrlValid()) - { - _helpers.TryRemove(AuthorityKey(server), out _); - return GetHelper(server); - } - - return helper; - } - - /// - /// get auth helper for a given project - /// - public AuthHelpers GetHelper(ProjectData project) - { - var originDomain = project.OriginDomain; - if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); - return GetHelper(options.Value.GetServer(project)); - } - - /// - /// get the auth helper for the current project, this method is used when trying to inject an AuthHelper into a service - /// - /// when not in the context of a project (typically requests include the project name in the path) - public AuthHelpers GetCurrentHelper() - { - if (projectContext.Project is null) - throw new InvalidOperationException("No current project, probably not in a request context"); - var currentProjectService = - contextAccessor.HttpContext?.RequestServices.GetRequiredService(); - if (currentProjectService is null) throw new InvalidOperationException("No current project service"); - return GetHelper(currentProjectService.ProjectData); - } -} diff --git a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs index dcc92ae36..e9725f397 100644 --- a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs +++ b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs @@ -1,6 +1,10 @@ -using LcmCrdt; +using FwLiteShared; +using FwLiteShared.Projects; +using FwLiteShared.Sync; +using LcmCrdt; using LcmCrdt.Data; using LocalWebApp.Services; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using MiniLcm; using MiniLcm.Models; @@ -15,7 +19,9 @@ public class CrdtMiniLcmApiHub( ChangeEventBus changeEventBus, CurrentProjectService projectContext, LexboxProjectService lexboxProjectService, - IMemoryCache memoryCache) : MiniLcmApiHubBase(miniLcmApi) + IMemoryCache memoryCache, + IHubContext hubContext +) : MiniLcmApiHubBase(miniLcmApi) { public const string ProjectRouteKey = "project"; public static string ProjectGroup(string projectName) => "crdt-" + projectName; @@ -31,12 +37,24 @@ public override async Task OnConnectedAsync() await syncService.ExecuteSync(); Cleanup = [ - changeEventBus.ListenForEntryChanges(projectContext.Project.Name, Context.ConnectionId) + changeEventBus.OnEntryUpdated.Subscribe(e => OnEntryChangedExternal(e, hubContext, memoryCache, Context.ConnectionId)) ]; await lexboxProjectService.ListenForProjectChanges(projectContext.ProjectData, Context.ConnectionAborted); } + private static void OnEntryChangedExternal(Entry entry, + IHubContext hubContext, + IMemoryCache cache, + string connectionId) + { + var currentFilter = CurrentProjectFilter(cache, connectionId); + if (currentFilter.Invoke(entry)) + { + _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(entry); + } + } + public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs index aa27e512d..4b375f243 100644 --- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs +++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs @@ -2,9 +2,11 @@ using SIL.Harmony; using FwLiteProjectSync; using FwDataMiniLcmBridge; +using FwLiteShared; +using FwLiteShared.Auth; +using FwLiteShared.Sync; using LcmCrdt; using LocalWebApp.Services; -using LocalWebApp.Auth; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Options; @@ -17,18 +19,9 @@ public static class LocalAppKernel public static IServiceCollection AddLocalAppServices(this IServiceCollection services, IHostEnvironment environment) { services.AddHttpContextAccessor(); - services.AddHttpClient(); - services.AddAuthHelpers(environment); services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(s => s.GetRequiredService()); - services.AddLcmCrdtClient(); - services.AddFwLiteProjectSync(); - services.AddFwDataBridge(); + services.AddSingleton(); + services.AddFwLiteShared(environment); services.AddOptions().BindConfiguration("LocalWebApp"); @@ -44,28 +37,4 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser }); return services; } - - private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment) - { - services.AddSingleton(); - services.AddTransient(sp => sp.GetRequiredService().GetCurrentHelper()); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations(); - services.AddSingleton(); - var httpClientBuilder = services.AddHttpClient(AuthHelpers.AuthHttpClientName); - if (environment.IsDevelopment()) - { - // Allow self-signed certificates in development - httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() => - { - return new HttpClientHandler - { - ClientCertificateOptions = ClientCertificateOption.Manual, - ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true - }; - }); - } - - } } diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj index 301cc5491..6b480b782 100644 --- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj +++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj @@ -18,9 +18,7 @@ - - @@ -40,6 +38,7 @@ + diff --git a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs index b05a93b34..91f5ba5da 100644 --- a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs +++ b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs @@ -1,9 +1,9 @@ using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.LcmUtils; +using FwLiteShared.Auth; using LcmCrdt; using LocalWebApp; using LocalWebApp.Hubs; -using LocalWebApp.Auth; using LocalWebApp.Routes; using LocalWebApp.Utils; using Microsoft.AspNetCore.SignalR; @@ -89,7 +89,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio var projectName = context.GetProjectName(); if (!string.IsNullOrWhiteSpace(projectName)) { - var projectsService = context.RequestServices.GetRequiredService(); + var projectsService = context.RequestServices.GetRequiredService(); projectsService.SetProjectScope(projectsService.GetProject(projectName) ?? throw new InvalidOperationException( $"Project {projectName} not found")); diff --git a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs index f14f8cb1b..4d00e619e 100644 --- a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs @@ -25,38 +25,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) }); return operation; }); - group.MapGet("/", - (ICrdtDbContext dbcontext) => - { - return dbcontext.Commits - .DefaultOrderDescending() - .Take(20) - .Select(c => new Activity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities)) - .AsAsyncEnumerable(); - }); + group.MapGet("/", (HistoryService historyService) => historyService.ProjectActivity()); return group; } - - private static string ChangeName(List> changeEntities) - { - return changeEntities switch - { - { Count: 0 } => "No changes", - { Count: 1 } => changeEntities[0].Change switch - { - //todo call JsonPatchChange.Summarize() instead of this - IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + change.EntityType.Name, - IChange change => change.GetType().Name.Humanize() - }, - { Count: var count } => $"{count} changes" - }; - } - - public record Activity( - Guid CommitId, - DateTimeOffset Timestamp, - List> Changes) - { - public string ChangeName => ChangeName(Changes); - } } diff --git a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs index 21f521741..a270c3adc 100644 --- a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs @@ -1,6 +1,6 @@ using System.Security.AccessControl; using System.Web; -using LocalWebApp.Auth; +using FwLiteShared.Auth; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -13,28 +13,21 @@ public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) { var group = app.MapGroup("/api/auth").WithOpenApi(); - group.MapGet("/servers", (IOptions options, AuthHelpersFactory factory) => - { - return options.Value.LexboxServers.ToAsyncEnumerable().SelectAwait(async s => - { - var currentName = await factory.GetHelper(s).GetCurrentName(); - return new ServerStatus(s.DisplayName, - !string.IsNullOrEmpty(currentName), - currentName, s.Authority.Authority); - }); - }); + group.MapGet("/servers", (AuthService authService) => authService.Servers()); group.MapGet("/login/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options, [FromHeader] string referer) => + async (AuthService authService, string authority, IOptions options, [FromHeader] string referer) => { var returnUrl = new Uri(referer).PathAndQuery; - var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(returnUrl); - if (result.HandledBySystemWebView) + //todo blazor, once we're using blazor this endpoint will only be used for non webview logins + if (options.Value.SystemWebViewLogin) { + await authService.SignInWebView(options.Value.GetServerByAuthority(authority)); return Results.Redirect(returnUrl); } - - if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null"); - return Results.Redirect(result.AuthUri.ToString()); + else + { + return Results.Redirect(await authService.SignInWebApp(options.Value.GetServerByAuthority(authority), returnUrl)); + } }); group.MapGet("/oauth-callback", async (OAuthService oAuthService, HttpContext context) => @@ -49,14 +42,14 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) return Results.Redirect(returnUrl); }).WithName(CallbackRoute); group.MapGet("/me/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options) => + async (AuthService authService, string authority, IOptions options) => { - return new { name = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).GetCurrentName() }; + return new { name = await authService.GetLoggedInName(options.Value.GetServerByAuthority(authority)) }; }); group.MapGet("/logout/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options) => + async (AuthService authService, string authority, IOptions options) => { - await factory.GetHelper(options.Value.GetServerByAuthority(authority)).Logout(); + await authService.Logout(options.Value.GetServerByAuthority(authority)); return Results.Redirect("/"); }); return group; diff --git a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs index 354d0e9ff..f77ea2d9e 100644 --- a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs @@ -1,4 +1,5 @@ -using SIL.Harmony; +using LcmCrdt; +using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Core; using SIL.Harmony.Db; @@ -24,63 +25,12 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap return operation; }); group.MapGet("/snapshot/{snapshotId:guid}", - async (Guid snapshotId, ICrdtDbContext dbcontext) => - { - return await dbcontext.Snapshots.Where(s => s.Id == snapshotId).SingleOrDefaultAsync(); - }); + async (Guid snapshotId, HistoryService historyService) => await historyService.GetSnapshot(snapshotId)); group.MapGet("/snapshot/at/{timestamp}", - async (DateTime timestamp, Guid entityId, DataModel dataModel) => - { - //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included - //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now. - return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId); - }); + async (DateTime timestamp, Guid entityId, HistoryService historyService) => + await historyService.GetObject(timestamp, entityId)); group.MapGet("/{entityId}", - (Guid entityId, ICrdtDbContext dbcontext) => - { - var query = from commit in dbcontext.Commits.DefaultOrder() - from snapshot in dbcontext.Snapshots.LeftJoin( - s => s.CommitId == commit.Id && s.EntityId == entityId) - from change in dbcontext.Set>().LeftJoin(c => - c.CommitId == commit.Id && c.EntityId == entityId) - where snapshot.Id != null || change.EntityId != null - select new HistoryLineItem(commit.Id, - entityId, - commit.HybridDateTime.DateTime, - snapshot.Id, - change.Change, - snapshot.Entity, - snapshot.TypeName); - return query.ToLinqToDB().AsAsyncEnumerable(); - }); + (Guid entityId, HistoryService historyService) => historyService.GetHistory(entityId)); return group; } - - public record HistoryLineItem( - Guid CommitId, - Guid EntityId, - DateTimeOffset Timestamp, - Guid? SnapshotId, - string? ChangeName, - IObjectBase? Entity, - string? EntityName) - { - public HistoryLineItem( - Guid commitId, - Guid entityId, - DateTimeOffset timestamp, - Guid? snapshotId, - IChange? change, - IObjectBase? entity, - string typeName) : this(commitId, - entityId, - new DateTimeOffset(timestamp.Ticks, - TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC - snapshotId, - change?.GetType().Name, - entity, - typeName) - { - } - } } diff --git a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs index 426044b4a..cc057a783 100644 --- a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs @@ -1,4 +1,5 @@ - using SIL.Harmony.Db; + using FwLiteShared.Projects; + using SIL.Harmony.Db; using LocalWebApp.Services; using Microsoft.OpenApi.Models; using MiniLcm; diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index 62316ba1b..dc29e4463 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -1,7 +1,10 @@ using System.Text.RegularExpressions; using FwDataMiniLcmBridge; +using FwLiteShared; +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using FwLiteShared.Sync; using LcmCrdt; -using LocalWebApp.Auth; using LocalWebApp.Hubs; using LocalWebApp.Services; using Microsoft.AspNetCore.Mvc; @@ -11,174 +14,54 @@ namespace LocalWebApp.Routes; -public static partial class ProjectRoutes +public static class ProjectRoutes { public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication app) { var group = app.MapGroup("/api").WithOpenApi(); group.MapGet("/remoteProjects", - async ( - LexboxProjectService lexboxProjectService, - IOptions options) => + async (CombinedProjectsService combinedProjectsService) => { - var serversProjects = new Dictionary(); - foreach (var server in options.Value.LexboxServers) - { - var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); - serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel - (p.Name, Crdt: p.IsCrdtProject, Fwdata: false, Lexbox: true, server.Authority.Authority, p.Id)) - .ToArray()); - } - - return serversProjects; + return (await combinedProjectsService.RemoteProjects()).ToDictionary(p => p.Server.Authority.Authority, p => p.Projects); }); group.MapGet("/localProjects", - async ( - ProjectsService projectService, - FieldWorksProjectList fieldWorksProjectList) => - { - var crdtProjects = await projectService.ListProjects(); - //todo get project Id and use that to specify the Id in the model. Also pull out server - var projects = crdtProjects.ToDictionary(p => p.Name, p => - { - var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null; - return new ProjectModel(p.Name, - true, - false, - p.Data?.OriginDomain is not null, - uri?.Authority, - p.Data?.Id); - }); - //basically populate projects and indicate if they are lexbox or fwdata - foreach (var p in fieldWorksProjectList.EnumerateProjects()) - { - if (projects.TryGetValue(p.Name, out var project)) - { - projects[p.Name] = project with { Fwdata = true }; - } - else - { - projects.Add(p.Name, new ProjectModel(p.Name, false, true)); - } - } - return projects.Values; - }); + async (CombinedProjectsService combinedProjectsService) => await combinedProjectsService.LocalProjects()); group.MapPost("/project", - async (ProjectsService projectService, string name) => + async (CrdtProjectsService projectService, string name) => { if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest("Project name is required"); if (projectService.ProjectExists(name)) return Results.BadRequest("Project already exists"); - if (!ProjectName().IsMatch(name)) + if (!CrdtProjectsService.ProjectName().IsMatch(name)) return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed"); - await projectService.CreateProject(new(name, AfterCreate: AfterCreate, SeedNewProjectData: true)); + await projectService.CreateExampleProject(name); return TypedResults.Ok(); }); group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", - async (LexboxProjectService lexboxProjectService, - SyncService syncService, + async (SyncService syncService, IOptions options, - CurrentProjectService currentProjectService, string serverAuthority, [FromQuery] Guid lexboxProjectId) => { var server = options.Value.GetServerByAuthority(serverAuthority); - await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId); - try - { - await syncService.ExecuteSync(); - } - catch - { - await currentProjectService.SetProjectSyncOrigin(null, null); - throw; - } - lexboxProjectService.InvalidateProjectsCache(server); + await syncService.UploadProject(lexboxProjectId, server); return TypedResults.Ok(); }); group.MapPost("/download/crdt/{serverAuthority}/{projectId}", - async (LexboxProjectService lexboxProjectService, - IOptions options, - ProjectsService projectService, + async (IOptions options, + CombinedProjectsService combinedProjectsService, Guid projectId, [FromQuery] string projectName, string serverAuthority ) => { - if (!ProjectName().IsMatch(projectName)) + if (!CrdtProjectsService.ProjectName().IsMatch(projectName)) return Results.BadRequest("Project name is invalid"); var server = options.Value.GetServerByAuthority(serverAuthority); - await projectService.CreateProject(new(projectName, - projectId, - server.Authority, - async (provider, project) => - { - await provider.GetRequiredService().ExecuteSync(); - }, - SeedNewProjectData: false)); + await combinedProjectsService.DownloadProject(projectId, projectName, server); return TypedResults.Ok(); }); return group; } - - public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false, string? ServerAuthority = null, Guid? Id = null); - - private static async Task AfterCreate(IServiceProvider provider, CrdtProject project) - { - var lexboxApi = provider.GetRequiredService(); - await lexboxApi.CreateEntry(new() - { - Id = Guid.NewGuid(), - LexemeForm = { Values = { { "en", "Apple" } } }, - CitationForm = { Values = { { "en", "Apple" } } }, - LiteralMeaning = { Values = { { "en", "Fruit" } } }, - Senses = - [ - new() - { - Gloss = { Values = { { "en", "Fruit" } } }, - Definition = - { - Values = - { - { - "en", - "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" - } - } - }, - SemanticDomains = [], - ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] - } - ] - }); - - await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular, - new() - { - Id = Guid.NewGuid(), - Type = WritingSystemType.Vernacular, - WsId = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - - await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, - new() - { - Id = Guid.NewGuid(), - Type = WritingSystemType.Analysis, - WsId = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - } - - [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] - private static partial Regex ProjectName(); } diff --git a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs index 91cf0ef3d..965442f43 100644 --- a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs @@ -1,4 +1,5 @@ -using LocalWebApp.Hubs; +using FwLiteShared; +using LocalWebApp.Hubs; using LocalWebApp.Services; using Microsoft.OpenApi.Models; using MiniLcm; diff --git a/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs new file mode 100644 index 000000000..531c84c59 --- /dev/null +++ b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs @@ -0,0 +1,29 @@ +using FwLiteShared.Auth; +using LocalWebApp.Routes; + +namespace LocalWebApp.Services; + +public class ServerRedirectUrlProvider(LinkGenerator linkGenerator, UrlContext urlContext): IRedirectUrlProvider +{ + public string? GetRedirectUrl() + { + var (hostUrl, _) = urlContext.GetUrl(); + return RedirectUrlFromHost(hostUrl); + } + + private string? RedirectUrlFromHost(Uri hostUrl) + { + var redirectHost = HostString.FromUriComponent(hostUrl); + return linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, + new RouteValueDictionary(), + hostUrl.Scheme, + redirectHost); + } + + public bool ShouldRecreateAuthHelper(string? redirectUrl) + { + var (hostUrl, guess) = urlContext.GetUrl(); + if (guess) return false; + return RedirectUrlFromHost(hostUrl) != redirectUrl; + } +} diff --git a/backend/FwLite/LocalWebApp/UrlContext.cs b/backend/FwLite/LocalWebApp/UrlContext.cs index ec261a555..bafefe88f 100644 --- a/backend/FwLite/LocalWebApp/UrlContext.cs +++ b/backend/FwLite/LocalWebApp/UrlContext.cs @@ -18,7 +18,7 @@ public class UrlContext(IServer server, IHttpContextAccessor contextAccessor) var uriBuilder = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host.Replace("127.0.0.1", "localhost"), httpContext.Request.Host.Port ?? 80); return (uriBuilder.Uri, false); } - var address = server.Features.Get()?.Addresses.FirstOrDefault() ?? throw new InvalidOperationException("No server address"); + var address = server.Features.Get()?.Addresses.FirstOrDefault(a => a.StartsWith("http:")) ?? throw new InvalidOperationException("No server address"); if (address.StartsWith("http://127.0.0.1")) address = address.Replace("http://127.0.0.1", "http://localhost"); if (address.StartsWith("https://127.0.0.1")) address = address.Replace("https://127.0.0.1", "http://localhost"); return (new Uri(address), true);