diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 977f687f2..49bd0dd2d 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -71,9 +71,6 @@ jobs: - uses: actions/checkout@v4 with: submodules: true - - uses: testspace-com/setup-testspace@v1.0.6 - with: - domain: ${{ github.repository_owner }} - name: Setup self-hosted dependencies if: ${{ inputs.runs-on == 'self-hosted' }} run: | @@ -143,9 +140,6 @@ jobs: with: check_name: Integration Tests ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }} files: ./test-results/*.trx - - name: Publish results to testspace - if: always() - run: testspace "[.Net Integration/${{ inputs.runs-on }} HG ${{ inputs.hg-version }}]./test-results/*.trx" playwright-test: if: ${{ inputs.run-playwright }} @@ -160,9 +154,6 @@ jobs: - uses: actions/checkout@v4 with: submodules: true - - uses: testspace-com/setup-testspace@v1.0.6 - with: - domain: ${{ github.repository_owner }} - name: Setup self-hosted dependencies if: ${{ inputs.runs-on == 'self-hosted' }} run: | @@ -212,9 +203,6 @@ jobs: env: ZIP_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} run: 7z a ./playwright-traces.7z -mx=0 -mmt=off ./frontend/test-results -p"$ZIP_PASSWORD" - - name: Publish results to testspace - if: always() - run: testspace "[Playwright]./frontend/test-results/results.xml" - name: Upload playwright results if: ${{ always() && steps.password_protect_test_results.outcome == 'success' }} uses: actions/upload-artifact@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 55ce0ba0b..2767675d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,10 @@ "sort-imports.on-save": true, "editor.detectIndentation": false, "eslint.validate": [ + "javascript", + "typescript", + "html", + "json", "svelte" ], "yaml.schemas": { diff --git a/README.md b/README.md index 5e48f2ecc..258bfa16f 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,15 @@ There are some exceptions: * linux: `sudo snap install task --classic` or other options on their website * mac: `brew install go-task/tap/go-task` * via npm: `npm install -g @go-task/cli` - * install [Tilt](https://docs.tilt.dev/) and add it to your path - * on Linux, a good practice is to create `$HOME/.local/bin` and put binaries there; most distributions automatically add `$HOME/.local/bin` to your path if it exists - * don't forget to run `chmod +x $HOME/.local/bin/tilt` - * on Windows, we suggest creating a `bin` folder in your home folder. Put the Tilt binary there, then do the following: + * install [Tilt](https://docs.tilt.dev/) and add it to your path (don't forget to read the script before running it) + * on Linux, the script will install tilt into `$HOME/.local/bin`, creating it if it doesn't exist + * most Linux distributions put `$HOME/.local/bin` in your PATH automatically. If `tilt version` doesn't work, log out and log back in and it should work; otherwise you'll need to add it to your PATH in `$HOME/.bashrc` or equivalent. + * on Windows, the Tilt installer will create a `bin` folder in your home folder and put the Tilt binary there + * you will then need to do the following to make sure the Tilt binary is in your PATH: * go to your System properties, click the **Advanced** tab, and click **Environment Variables...** * Click the Path variable (in either User or System, User is recommended) and click the **Edit...** button - * Add `C:\Users\YOUR_USER_NAME\bin` to the list and click **OK** + * Add `C:\Users\YOUR_USER_NAME\bin` to the list (if it's not already there) and click **OK** + * run `tilt version` to check that Tilt is installed correctly * clone the repo * run `git push` to make sure your GitHub credentials are set up * on Windows, allow the Git Credential Manager to log in to GitHub via your browser 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/FwHeadless.csproj b/backend/FwHeadless/FwHeadless.csproj index 335b1e00b..82dcf0521 100644 --- a/backend/FwHeadless/FwHeadless.csproj +++ b/backend/FwHeadless/FwHeadless.csproj @@ -24,6 +24,8 @@ + + diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index fc008cc15..4a7981381 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -2,11 +2,13 @@ using FwHeadless.Services; 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 @@ -23,5 +25,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 01698cb72..e4e897016 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; @@ -45,11 +47,11 @@ app.MapHealthChecks("/api/healthz"); -app.MapPost("/sync", ExecuteMergeRequest); +app.MapPost("/api/crdt-sync", ExecuteMergeRequest); app.Run(); -static async Task, NotFound>> ExecuteMergeRequest( +static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest( ILogger logger, IServiceProvider services, SendReceiveService srService, @@ -58,6 +60,8 @@ ProjectsService projectsService, ProjectLookupService projectLookupService, CrdtFwdataProjectSyncService syncService, + CrdtHttpSyncService crdtHttpSyncService, + IHttpClientFactory httpClientFactory, Guid projectId, bool dryRun = false) { @@ -75,6 +79,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); @@ -85,25 +94,71 @@ logger.LogDebug("crdtFile: {crdtFile}", crdtFile); logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath); + var fwdataApi = await SetupFwData(fwDataProject, srService, projectCode, logger, fwDataFactory); + using var deferCloseFwData = fwDataFactory.DeferClose(fwDataProject); + 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 = await srService.SendReceive(fwDataProject, projectCode); + logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output); + return TypedResults.Ok(result); +} + +static async Task SetupFwData(FwDataProject fwDataProject, + SendReceiveService srService, + string projectCode, + ILogger logger, + FwDataFactory fwDataFactory) +{ if (File.Exists(fwDataProject.FilePath)) { - var srResult = srService.SendReceive(fwDataProject, projectCode); + var srResult = await srService.SendReceive(fwDataProject, projectCode); logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); } else { - var srResult = srService.Clone(fwDataProject, projectCode); + var srResult = await 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/SendReceiveHelpers.cs b/backend/FwHeadless/SendReceiveHelpers.cs index 691cb9896..68cdeddfa 100644 --- a/backend/FwHeadless/SendReceiveHelpers.cs +++ b/backend/FwHeadless/SendReceiveHelpers.cs @@ -17,10 +17,14 @@ public SendReceiveAuth(FwHeadlessConfig config) : this(config.LexboxUsername, co public record LfMergeBridgeResult(string Output, string ProgressMessages); - private static LfMergeBridgeResult CallLfMergeBridge(string method, IDictionary flexBridgeOptions, IProgress? progress = null) + private static async Task CallLfMergeBridge(string method, IDictionary flexBridgeOptions, IProgress? progress = null) { var sbProgress = new StringBuilderProgress(); - LfMergeBridge.LfMergeBridge.Execute(method, progress ?? sbProgress, flexBridgeOptions.ToDictionary(), out var lfMergeBridgeOutputForClient); + var lfMergeBridgeOutputForClient = await Task.Run(() => + { + LfMergeBridge.LfMergeBridge.Execute(method, progress ?? sbProgress, flexBridgeOptions.ToDictionary(), out var output); + return output; + }); return new LfMergeBridgeResult(lfMergeBridgeOutputForClient, progress == null ? sbProgress.ToString() : ""); } @@ -45,7 +49,7 @@ private static Uri BuildSendReceiveUrl(string baseUrl, string projectCode, SendR return builder.Uri; } - public static LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", string? commitMessage = null, IProgress? progress = null) + public static async Task SendReceive(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", string? commitMessage = null, IProgress? progress = null) { projectCode ??= project.Name; var fwdataInfo = new FileInfo(project.FilePath); @@ -65,10 +69,10 @@ public static LfMergeBridgeResult SendReceive(FwDataProject project, string? pro { "user", "LexBox" }, }; if (commitMessage is not null) flexBridgeOptions["commitMessage"] = commitMessage; - return CallLfMergeBridge("Language_Forge_Send_Receive", flexBridgeOptions, progress); + return await CallLfMergeBridge("Language_Forge_Send_Receive", flexBridgeOptions, progress); } - public static LfMergeBridgeResult CloneProject(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", IProgress? progress = null) + public static async Task CloneProject(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", IProgress? progress = null) { projectCode ??= project.Name; var fwdataInfo = new FileInfo(project.FilePath); @@ -84,6 +88,6 @@ public static LfMergeBridgeResult CloneProject(FwDataProject project, string? pr { "languageDepotRepoUri", repoUrl.ToString() }, { "deleteRepoIfNoSuchBranch", "false" }, }; - return CallLfMergeBridge("Language_Forge_Clone", flexBridgeOptions, progress); + return await CallLfMergeBridge("Language_Forge_Clone", flexBridgeOptions, progress); } } diff --git a/backend/FwHeadless/SendReceiveService.cs b/backend/FwHeadless/SendReceiveService.cs index 96270cb24..a0508ddc3 100644 --- a/backend/FwHeadless/SendReceiveService.cs +++ b/backend/FwHeadless/SendReceiveService.cs @@ -6,9 +6,9 @@ namespace FwHeadless; public class SendReceiveService(IOptions config, SafeLoggingProgress progress) { - public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null) + public async Task SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null) { - return SendReceiveHelpers.SendReceive( + return await SendReceiveHelpers.SendReceive( project: project, projectCode: projectCode, baseUrl: config.Value.HgWebUrl, @@ -19,9 +19,9 @@ public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, ); } - public SendReceiveHelpers.LfMergeBridgeResult Clone(FwDataProject project, string? projectCode) + public async Task Clone(FwDataProject project, string? projectCode) { - return SendReceiveHelpers.CloneProject( + return await SendReceiveHelpers.CloneProject( project: project, projectCode: projectCode, baseUrl: config.Value.HgWebUrl, diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 7c400993c..e9d8fbe69 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1,4 +1,4 @@ -using System.Collections.Frozen; +using System.Collections.Frozen; using System.Reflection; using System.Text; using FwDataMiniLcmBridge.Api.UpdateProxy; @@ -246,6 +246,17 @@ public Task DeletePartOfSpeech(Guid id) return Task.CompletedTask; } + internal SemanticDomain FromLcmSemanticDomain(ICmSemanticDomain semanticDomain) + { + return new SemanticDomain + { + Id = semanticDomain.Guid, + Name = FromLcmMultiString(semanticDomain.Name), + Code = semanticDomain.Abbreviation.UiString ?? "", + Predefined = true, // TODO: Look up in a GUID list of predefined data + }; + } + public IAsyncEnumerable GetSemanticDomains() { return @@ -253,15 +264,16 @@ public IAsyncEnumerable GetSemanticDomains() .AllInstances() .OrderBy(p => p.Abbreviation.UiString) .ToAsyncEnumerable() - .Select(semanticDomain => new SemanticDomain - { - Id = semanticDomain.Guid, - Name = FromLcmMultiString(semanticDomain.Name), - Code = semanticDomain.Abbreviation.UiString ?? "" - }); + .Select(FromLcmSemanticDomain); } - public Task CreateSemanticDomain(SemanticDomain semanticDomain) + public Task GetSemanticDomain(Guid id) + { + var semDom = GetLcmSemanticDomain(id); + return Task.FromResult(semDom is null ? null : FromLcmSemanticDomain(semDom)); + } + + public async Task CreateSemanticDomain(SemanticDomain semanticDomain) { if (semanticDomain.Id == Guid.Empty) semanticDomain.Id = Guid.NewGuid(); UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Semantic Domain", @@ -273,8 +285,35 @@ public Task CreateSemanticDomain(SemanticDomain semanticDomain) .Create(semanticDomain.Id, Cache.LangProject.SemanticDomainListOA); lcmSemanticDomain.OcmCodes = semanticDomain.Code; UpdateLcmMultiString(lcmSemanticDomain.Name, semanticDomain.Name); + // TODO: Find out if semantic domains are guaranteed to have an "en" writing system, or if we should use lcmCache.DefautlAnalWs instead UpdateLcmMultiString(lcmSemanticDomain.Abbreviation, new MultiString(){{"en", semanticDomain.Code}}); }); + return await GetSemanticDomain(semanticDomain.Id) ?? throw new InvalidOperationException("Semantic domain was not created"); + } + + public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + var lcmSemanticDomain = SemanticDomainRepository.GetObject(id); + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update Semantic Domain", + "Revert Semantic Domain", + Cache.ServiceLocator.ActionHandler, + () => + { + var updateProxy = new UpdateSemanticDomainProxy(lcmSemanticDomain, this); + update.Apply(updateProxy); + }); + return Task.FromResult(FromLcmSemanticDomain(lcmSemanticDomain)); + } + + public Task DeleteSemanticDomain(Guid id) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Semantic Domain", + "Revert delete", + Cache.ServiceLocator.ActionHandler, + () => + { + SemanticDomainRepository.GetObject(id).Delete(); + }); return Task.CompletedTask; } @@ -298,7 +337,7 @@ private ComplexFormType ToComplexFormType(ICmPossibility t) public Task CreateComplexFormType(ComplexFormType complexFormType) { - if (complexFormType.Id != default) throw new InvalidOperationException("Complex form type id must be empty"); + if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid(); UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create complex form type", "Remove complex form type", Cache.ActionHandlerAccessor, @@ -306,10 +345,9 @@ public Task CreateComplexFormType(ComplexFormType complexFormTy { var lexComplexFormType = Cache.ServiceLocator .GetInstance() - .Create(); + .Create(complexFormType.Id); ComplexFormTypes.PossibilitiesOS.Add(lexComplexFormType); UpdateLcmMultiString(lexComplexFormType.Name, complexFormType.Name); - complexFormType.Id = lexComplexFormType.Guid; }); return Task.FromResult(ToComplexFormType(ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormType.Id))); } @@ -428,12 +466,7 @@ private Sense FromLexSense(ILexSense sense) Definition = FromLcmMultiString(sense.Definition), PartOfSpeech = sense.MorphoSyntaxAnalysisRA?.GetPartOfSpeech()?.Name.get_String(enWs).Text ?? "", PartOfSpeechId = sense.MorphoSyntaxAnalysisRA?.GetPartOfSpeech()?.Guid, - SemanticDomains = sense.SemanticDomainsRC.Select(s => new SemanticDomain - { - Id = s.Guid, - Name = FromLcmMultiString(s.Name), - Code = s.OcmCodes - }).ToList(), + SemanticDomains = sense.SemanticDomainsRC.Select(FromLcmSemanticDomain).ToList(), ExampleSentences = sense.ExamplesOS.Select(sentence => FromLexExampleSentence(sense.Guid, sentence)).ToList() }; return s; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSemanticDomainProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSemanticDomainProxy.cs new file mode 100644 index 000000000..a154c2951 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSemanticDomainProxy.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using MiniLcm.Models; +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateSemanticDomainProxy : SemanticDomain +{ + private readonly ICmSemanticDomain _lcmSemanticDomain; + private readonly FwDataMiniLcmApi _lexboxLcmApi; + + public UpdateSemanticDomainProxy(ICmSemanticDomain lcmSemanticDomain, FwDataMiniLcmApi lexboxLcmApi) + { + _lcmSemanticDomain = lcmSemanticDomain; + Id = lcmSemanticDomain.Guid; + _lexboxLcmApi = lexboxLcmApi; + } + + public override MultiString Name + { + get => new UpdateMultiStringProxy(_lcmSemanticDomain.Name, _lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override string Code + { + get => _lcmSemanticDomain.Abbreviation.BestAnalysisVernacularAlternative.Text; + set => throw new NotImplementedException(); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs index 3490145a8..78fc74092 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs @@ -1,5 +1,6 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.LcmUtils; +using LexCore.Utils; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -120,7 +121,7 @@ public void CloseCurrentProject() CloseProject(fwDataProject); } - private void CloseProject(FwDataProject project) + public void CloseProject(FwDataProject project) { // if we are shutting down, don't do anything because we want project dispose to be called as part of the shutdown process. if (_shuttingDown) return; @@ -130,4 +131,9 @@ private void CloseProject(FwDataProject project) if (lcmCache is null) return; cache.Remove(cacheKey); } + + public IDisposable DeferClose(FwDataProject project) + { + return Defer.Action(() => CloseProject(project)); + } } diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index 457f4d84e..5ab11b140 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -14,9 +14,9 @@ - + - + @@ -28,6 +28,7 @@ + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs index 2d3a08e55..0b0443e0d 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs @@ -58,7 +58,7 @@ public async Task InitializeAsync() if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true); Directory.CreateDirectory(crdtProjectsFolder); var crdtProject = await _services.ServiceProvider.GetRequiredService() - .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId)); + .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: true)); CrdtApi = (CrdtMiniLcmApi) await _services.ServiceProvider.OpenCrdtProject(crdtProject); } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 7c150891b..138390e28 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -213,6 +213,85 @@ await crdtApi.CreateEntry(new Entry() .For(e => e.ComplexForms).Exclude(c => c.Id)); } + [Fact] + public async Task SemanticDomainsSyncBothWays() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + + var semdom3 = new SemanticDomain() + { + Id = new Guid("f4491f9b-3c5e-42ab-afc0-f22e19d0fff5"), + Name = new MultiString() { { "en", "Language and thought" } }, + Code = "3", + Predefined = true, + }; + await fwdataApi.CreateSemanticDomain(semdom3); + + var semdom4 = new SemanticDomain() + { + Id = new Guid("62b4ae33-f3c2-447a-9ef7-7e41805b6a02"), + Name = new MultiString() { { "en", "Social behavior" } }, + Code = "4", + Predefined = true, + }; + await crdtApi.CreateSemanticDomain(semdom4); + + await _syncService.Sync(crdtApi, fwdataApi); + + var crdtSemanticDomains = await crdtApi.GetSemanticDomains().ToArrayAsync(); + var fwdataSemanticDomains = await fwdataApi.GetSemanticDomains().ToArrayAsync(); + crdtSemanticDomains.Should().ContainEquivalentOf(semdom3); + crdtSemanticDomains.Should().ContainEquivalentOf(semdom4); + fwdataSemanticDomains.Should().ContainEquivalentOf(semdom3); + fwdataSemanticDomains.Should().ContainEquivalentOf(semdom4); + + crdtSemanticDomains.Should().BeEquivalentTo(fwdataSemanticDomains); + } + + [Fact] + public async Task SemanticDomainsSyncInEntries() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + + var semdom3 = new SemanticDomain() + { + Id = new Guid("f4491f9b-3c5e-42ab-afc0-f22e19d0fff5"), + Name = new MultiString() { { "en", "Language and thought" } }, + Code = "3", + Predefined = true, + }; + await fwdataApi.CreateSemanticDomain(semdom3); + // Note we do *not* call crdtApi.CreateSemanticDomain(semdom3); + + await fwdataApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Pear" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Pear" } }, SemanticDomains = [ semdom3 ] } + ] + }); + await crdtApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Banana" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Banana" } }, SemanticDomains = [ semdom3 ] } + ] + }); + await _syncService.Sync(crdtApi, fwdataApi); + + var crdtEntries = await crdtApi.GetEntries().ToArrayAsync(); + var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync(); + crdtEntries.Should().BeEquivalentTo(fwdataEntries, + options => options.For(e => e.Components).Exclude(c => c.Id) + .For(e => e.ComplexForms).Exclude(c => c.Id)); + } + [Fact] public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() { diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 167c58fe7..12258dcee 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -28,7 +28,10 @@ public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataA if (!dryRun) { await SaveProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath, - new ProjectSnapshot(await fwdataApi.GetEntries().ToArrayAsync(), await fwdataApi.GetPartsOfSpeech().ToArrayAsync())); + new ProjectSnapshot( + await fwdataApi.GetEntries().ToArrayAsync(), + await fwdataApi.GetPartsOfSpeech().ToArrayAsync(), + await fwdataApi.GetSemanticDomains().ToArrayAsync())); } return result; } @@ -48,12 +51,16 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, return new SyncResult(entryCount, 0); } - //todo sync complex form types, parts of speech, semantic domains, writing systems + //todo sync complex form types, writing systems var currentFwDataPartsOfSpeech = await fwdataApi.GetPartsOfSpeech().ToArrayAsync(); var crdtChanges = await PartOfSpeechSync.Sync(currentFwDataPartsOfSpeech, projectSnapshot.PartsOfSpeech, crdtApi); var fwdataChanges = await PartOfSpeechSync.Sync(await crdtApi.GetPartsOfSpeech().ToArrayAsync(), currentFwDataPartsOfSpeech, fwdataApi); + var currentFwDataSemanticDomains = await fwdataApi.GetSemanticDomains().ToArrayAsync(); + crdtChanges += await SemanticDomainSync.Sync(currentFwDataSemanticDomains, projectSnapshot.SemanticDomains, crdtApi); + fwdataChanges += await SemanticDomainSync.Sync(await crdtApi.GetSemanticDomains().ToArrayAsync(), currentFwDataSemanticDomains, fwdataApi); + var currentFwDataEntries = await fwdataApi.GetEntries().ToArrayAsync(); crdtChanges += await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi); LogDryRun(crdtApi, "crdt"); @@ -77,7 +84,7 @@ private void LogDryRun(IMiniLcmApi api, string type) logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}"); } - public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech); + public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech, SemanticDomain[] SemanticDomains); private async Task GetProjectSnapshot(string projectName, string? projectPath) { diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index d3b18caf8..959a22113 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -1,4 +1,4 @@ -using MiniLcm; +using MiniLcm; using MiniLcm.Models; namespace FwLiteProjectSync; @@ -49,6 +49,7 @@ public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); return Task.FromResult(partOfSpeech); // Since this is a dry run, api.GetPartOfSpeech would return null } + public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) { DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); @@ -66,10 +67,27 @@ public IAsyncEnumerable GetSemanticDomains() return api.GetSemanticDomains(); } - public Task CreateSemanticDomain(SemanticDomain semanticDomain) + public Task GetSemanticDomain(Guid id) + { + return api.GetSemanticDomain(id); + } + + public Task CreateSemanticDomain(SemanticDomain semanticDomain) { DryRunRecords.Add(new DryRunRecord(nameof(CreateSemanticDomain), $"Create semantic domain {semanticDomain.Name}")); + return Task.FromResult(semanticDomain); + } + + public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update part of speech {id}")); + return GetSemanticDomain(id)!; + } + + public Task DeleteSemanticDomain(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete part of speech {id}")); return Task.CompletedTask; } diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index e53ea52e8..cdaa72ab0 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -16,7 +16,7 @@ public async Task OpeningAProjectWorks() var services = host.Services; var asyncScope = services.CreateAsyncScope(); await asyncScope.ServiceProvider.GetRequiredService() - .CreateProject(new(Name: "OpeningAProjectWorks", Path: "")); + .CreateProject(new(Name: "OpeningAProjectWorks", Path: "", SeedNewProjectData: true)); var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(new CrdtProject("OpeningAProjectWorks", sqliteConnectionString)); miniLcmApi.ProjectData.Name.Should().Be("OpeningAProjectWorks"); diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 22ebcd6c8..c6491705f 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using SIL.Harmony; using SIL.Harmony.Changes; using LcmCrdt.Changes; @@ -107,9 +107,29 @@ public async Task DeletePartOfSpeech(Guid id) return SemanticDomains.AsAsyncEnumerable(); } - public async Task CreateSemanticDomain(MiniLcm.Models.SemanticDomain semanticDomain) + public Task GetSemanticDomain(Guid id) { - await dataModel.AddChange(ClientId, new CreateSemanticDomainChange(semanticDomain.Id, semanticDomain.Name, semanticDomain.Code)); + return SemanticDomains.FirstOrDefaultAsync(semdom => semdom.Id == id); + } + + public async Task CreateSemanticDomain(MiniLcm.Models.SemanticDomain semanticDomain) + { + await dataModel.AddChange(ClientId, new CreateSemanticDomainChange(semanticDomain.Id, semanticDomain.Name, semanticDomain.Code, semanticDomain.Predefined)); + return await GetSemanticDomain(semanticDomain.Id) ?? throw new NullReferenceException(); + } + + public async Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + var semDom = await GetSemanticDomain(id); + if (semDom is null) throw new NullReferenceException($"unable to find semantic domain with id {id}"); + + await dataModel.AddChanges(ClientId, [..semDom.ToChanges(update.Patch)]); + return await GetSemanticDomain(id) ?? throw new NullReferenceException(); + } + + public async Task DeleteSemanticDomain(Guid id) + { + await dataModel.AddChange(ClientId, new DeleteChange(id)); } public async Task BulkImportSemanticDomains(IEnumerable semanticDomains) @@ -122,7 +142,7 @@ public IAsyncEnumerable GetComplexFormTypes() return ComplexFormTypes.AsAsyncEnumerable(); } - public async Task CreateComplexFormType(MiniLcm.Models.ComplexFormType complexFormType) + public async Task CreateComplexFormType(ComplexFormType complexFormType) { if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid(); await dataModel.AddChange(ClientId, new CreateComplexFormType(complexFormType.Id, complexFormType.Name)); diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index c61cc2535..c2382ec8a 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -18,6 +18,8 @@ + + diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 7e4df57c1..9fd13ce3e 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -6,6 +6,7 @@ using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; using LcmCrdt.Objects; +using LcmCrdt.RemoteSync; using LinqToDB; using LinqToDB.AspNet.Logging; using LinqToDB.Data; @@ -14,6 +15,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Refit; using SIL.Harmony.Db; namespace LcmCrdt; @@ -34,6 +37,17 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + + services.AddHttpClient(); + services.AddSingleton(provider => new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer(new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = provider.GetRequiredService>().Value + .MakeJsonTypeResolver() + }) + }); + services.AddSingleton(); return services; } diff --git a/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs b/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs index 08757b4b6..9a9c4c409 100644 --- a/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs +++ b/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs @@ -1,4 +1,4 @@ -using LcmCrdt.Changes; +using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; using LcmCrdt.Utils; using SIL.Harmony.Changes; @@ -154,4 +154,10 @@ public static IEnumerable ToChanges(this PartOfSpeech pos, JsonPatchDoc if (patch.Operations.Count > 0) yield return new JsonPatchChange(pos.Id, patch); } + + public static IEnumerable ToChanges(this SemanticDomain semDom, JsonPatchDocument patch) + { + if (patch.Operations.Count > 0) + yield return new JsonPatchChange(semDom.Id, patch); + } } diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/ProjectsService.cs index a9cbeb43f..63aba27fa 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/ProjectsService.cs @@ -38,7 +38,7 @@ public record CreateProjectRequest( Guid? Id = null, Uri? Domain = null, Func? AfterCreate = null, - bool SeedNewProjectData = true, + bool SeedNewProjectData = false, string? Path = null, Guid? FwProjectId = null); diff --git a/backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs similarity index 68% rename from backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs rename to backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs index 85e06783e..38028bc03 100644 --- a/backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs +++ b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs @@ -1,13 +1,11 @@ -using SIL.Harmony.Core; -using SIL.Harmony; -using SIL.Harmony.Db; -using LcmCrdt; -using LocalWebApp.Auth; +using Microsoft.Extensions.Logging; using Refit; +using SIL.Harmony; +using SIL.Harmony.Core; -namespace LocalWebApp; +namespace LcmCrdt.RemoteSync; -public class CrdtHttpSyncService(AuthHelpersFactory authHelpersFactory, ILogger logger, RefitSettings refitSettings) +public class CrdtHttpSyncService(ILogger logger, RefitSettings refitSettings) { //todo replace with a IMemoryCache check private bool? _isHealthy; @@ -22,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) @@ -42,26 +44,26 @@ public async ValueTask ShouldSync(ISyncHttp syncHttp) return _isHealthy.Value; } - public async ValueTask CreateProjectSyncable(ProjectData project) + /// + /// Creates a Harmony sync client to represent a remote server + /// + /// project data, used to provide the projectId and clientId + /// should have the base url set to the remote server + /// + public async ValueTask CreateProjectSyncable(ProjectData project, HttpClient client) { - if (string.IsNullOrEmpty(project.OriginDomain)) - { - logger.LogWarning("Project {ProjectName} has no origin domain, unable to create http sync client", project.Name); - return NullSyncable.Instance; - } - - var client = await authHelpersFactory.GetHelper(project).CreateClient(); - if (client is null) - { - logger.LogWarning("Unable to create http client to sync project {ProjectName}, user is not authenticated to {OriginDomain}", project.Name, project.OriginDomain); - return NullSyncable.Instance; - } + return new CrdtProjectSync(RestService.For(client, refitSettings), project.Id, project.ClientId, this); + } - return new CrdtProjectSync(RestService.For(client, refitSettings), project.Id, project.ClientId , project.OriginDomain, 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(); } } -public class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, string originDomain, CrdtHttpSyncService httpSyncService) : ISyncable +internal class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, CrdtHttpSyncService httpSyncService) : ISyncable { public ValueTask ShouldSync() { diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs index b13345e35..f3e300034 100644 --- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs +++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs @@ -40,16 +40,6 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser { jsonOptions.PayloadSerializerOptions.TypeInfoResolver = crdtConfig.Value.MakeJsonTypeResolver(); }); - services.AddHttpClient(); - services.AddSingleton(provider => new RefitSettings - { - ContentSerializer = new SystemTextJsonContentSerializer(new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = provider.GetRequiredService>().Value - .MakeJsonTypeResolver() - }) - }); - services.AddSingleton(); return services; } diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj index 40cfbee76..51e42e2a9 100644 --- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj +++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj @@ -19,13 +19,11 @@ - + - - diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index 1ad474bf1..1d945394c 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -69,7 +69,7 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap return Results.BadRequest("Project already exists"); if (!ProjectName().IsMatch(name)) return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed"); - await projectService.CreateProject(new(name, AfterCreate: AfterCreate)); + await projectService.CreateProject(new(name, AfterCreate: AfterCreate, SeedNewProjectData: true)); return TypedResults.Ok(); }); group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", diff --git a/backend/FwLite/LocalWebApp/SyncService.cs b/backend/FwLite/LocalWebApp/SyncService.cs index ac8925a00..7c4be59f6 100644 --- a/backend/FwLite/LocalWebApp/SyncService.cs +++ b/backend/FwLite/LocalWebApp/SyncService.cs @@ -1,5 +1,6 @@ using SIL.Harmony; using LcmCrdt; +using LcmCrdt.RemoteSync; using LocalWebApp.Auth; using LocalWebApp.Services; using MiniLcm; @@ -11,7 +12,7 @@ namespace LocalWebApp; public class SyncService( DataModel dataModel, CrdtHttpSyncService remoteSyncServiceServer, - AuthHelpersFactory factory, + AuthHelpersFactory authHelpersFactory, CurrentProjectService currentProjectService, ChangeEventBus changeEventBus, IMiniLcmApi lexboxApi, @@ -19,7 +20,24 @@ public class SyncService( { public async Task ExecuteSync() { - var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(await currentProjectService.GetProjectData()); + var project = await currentProjectService.GetProjectData(); + if (string.IsNullOrEmpty(project.OriginDomain)) + { + logger.LogWarning("Project {ProjectName} has no origin domain, unable to create http sync client", + project.Name); + return new SyncResults([], [], false); + } + + var httpClient = await authHelpersFactory.GetHelper(project).CreateClient(); + if (httpClient is null) + { + logger.LogWarning( + "Unable to create http client to sync project {ProjectName}, user is not authenticated to {OriginDomain}", + project.Name, + project.OriginDomain); + return new SyncResults([], [], false); + } + var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(project, httpClient); var syncResults = await dataModel.SyncWith(remoteModel); //need to await this, otherwise the database connection will be closed before the notifications are sent await SendNotifications(syncResults); diff --git a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs index 80dac29d2..4f51ca275 100644 --- a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs @@ -74,4 +74,13 @@ public async Task CreateComplexFormComponent_CanCreateMultipleComponentSenses() component2.ComponentEntryId.Should().Be(_componentEntryId); component2.ComponentSenseId.Should().Be(_componentSenseId2); } + + [Fact] + public async Task CreateComplexFormType_Works() + { + var complexFormType = new ComplexFormType() { Id = Guid.NewGuid(), Name = new() { { "en", "test" } } }; + await Api.CreateComplexFormType(complexFormType); + var types = await Api.GetComplexFormTypes().ToArrayAsync(); + types.Should().ContainSingle(t => t.Id == complexFormType.Id); + } } diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 9769f3705..de0bc543e 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using MiniLcm.Models; namespace MiniLcm; @@ -13,6 +13,7 @@ public interface IMiniLcmReadApi IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null); Task GetEntry(Guid id); Task GetPartOfSpeech(Guid id); + Task GetSemanticDomain(Guid id); } public record QueryOptions( diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index db45680da..e925f8cd0 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using MiniLcm.Models; using SystemTextJsonPatch; @@ -13,10 +13,18 @@ Task UpdateWritingSystem(WritingSystemId id, UpdateObjectInput update); + #region PartOfSpeech Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update); Task DeletePartOfSpeech(Guid id); - Task CreateSemanticDomain(SemanticDomain semanticDomain); + #endregion + + #region SemanticDomain + Task CreateSemanticDomain(SemanticDomain semanticDomain); + Task UpdateSemanticDomain(Guid id, UpdateObjectInput update); + Task DeleteSemanticDomain(Guid id); + #endregion + Task CreateComplexFormType(ComplexFormType complexFormType); #region Entry diff --git a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs index 616af0adb..7d144831b 100644 --- a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs +++ b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs @@ -1,10 +1,10 @@ -namespace MiniLcm.Models; +namespace MiniLcm.Models; public class SemanticDomain : IObjectWithId { - public virtual required Guid Id { get; set; } - public virtual required MultiString Name { get; set; } - public virtual required string Code { get; set; } + public virtual Guid Id { get; set; } + public virtual MultiString Name { get; set; } = new(); + public virtual string Code { get; set; } = string.Empty; public DateTimeOffset? DeletedAt { get; set; } public bool Predefined { get; set; } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs new file mode 100644 index 000000000..1219db9aa --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs @@ -0,0 +1,47 @@ +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; +using SystemTextJsonPatch; + +public static class SemanticDomainSync +{ + public static async Task Sync(SemanticDomain[] currentSemanticDomains, + SemanticDomain[] previousSemanticDomains, + IMiniLcmApi api) + { + return await DiffCollection.Diff(api, + previousSemanticDomains, + currentSemanticDomains, + pos => pos.Id, + async (api, currentPos) => + { + await api.CreateSemanticDomain(currentPos); + return 1; + }, + async (api, previousPos) => + { + await api.DeleteSemanticDomain(previousPos.Id); + return 1; + }, + async (api, previousPos, currentPos) => + { + var updateObjectInput = SemanticDomainDiffToUpdate(previousPos, currentPos); + if (updateObjectInput is not null) await api.UpdateSemanticDomain(currentPos.Id, updateObjectInput); + return updateObjectInput is null ? 0 : 1; + }); + } + + public static UpdateObjectInput? SemanticDomainDiffToUpdate(SemanticDomain previousSemanticDomain, SemanticDomain currentSemanticDomain) + { + JsonPatchDocument patchDocument = new(); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(SemanticDomain.Name), + previousSemanticDomain.Name, + currentSemanticDomain.Name)); + // TODO: Once we add abbreviations to MiniLcm's SemanticDomain objects, then: + // patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(SemanticDomain.Abbreviation), + // previousSemanticDomain.Abbreviation, + // currentSemanticDomain.Abbreviation)); + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +} 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/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index abd27c078..97d7585a0 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -50,10 +50,6 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm options.IncludeExceptionDetails = true; }) .AddType() - .AddType(new DateTimeType("DateTime")) - .AddType(new UuidType("UUID")) - .AddType(new DateTimeType("timestamptz")) - .AddType(new UuidType("uuid")) .AddInstrumentation(options => { options.IncludeDocument = true; diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 69bbed341..347449122 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -76,7 +76,9 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddScheduledTasks(configuration); services.AddHealthChecks() .AddCheck("hgweb", HealthStatus.Unhealthy, ["hg"], TimeSpan.FromSeconds(5)) - .AddCheck("fw-headless", HealthStatus.Unhealthy, ["fw-headless"], TimeSpan.FromSeconds(5)); + //todo enable this once we want to make lexbox depend on fw-headless + // .AddCheck("fw-headless", HealthStatus.Unhealthy, ["fw-headless"], TimeSpan.FromSeconds(5)) + ; services.AddSyncProxy(); AuthKernel.AddLexBoxAuth(services, configuration, environment); services.AddLexGraphQL(environment); diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index 4f6b93723..d73353b11 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -41,11 +41,7 @@ public async Task SendForgotPasswordEmail(string emailAddress) var httpContext = httpContextAccessor.HttpContext; ArgumentNullException.ThrowIfNull(httpContext); // returnTo is a svelte app url - var forgotLink = _linkGenerator.GetUriByAction(httpContext, - "LoginRedirect", - "Login", - new { jwt, returnTo = "/resetPassword" }); - ArgumentException.ThrowIfNullOrEmpty(forgotLink); + var forgotLink = MakeLoginRedirectUrl(jwt, "/resetPassword"); await RenderEmail(email, new ForgotPasswordEmail(user.Name, forgotLink, lifetime), user.LocalizationCode); await SendEmailWithRetriesAsync(email, retryCount: 5, retryWaitSeconds: 30); } @@ -166,15 +162,11 @@ private async Task SendInvitationEmail( var httpContext = httpContextAccessor.HttpContext; ArgumentNullException.ThrowIfNull(httpContext); - var returnTo = _linkGenerator.GetUriByAction(httpContext, - nameof(LexBoxApi.Controllers.UserController.HandleInviteLink), + var returnTo = _linkGenerator.GetPathByAction(httpContext, + nameof(Controllers.UserController.HandleInviteLink), "User"); - var registerLink = _linkGenerator.GetUriByAction(httpContext, - "LoginRedirect", - "Login", - new { jwt, returnTo }); + var registerLink = MakeLoginRedirectUrl(jwt, returnTo); - ArgumentException.ThrowIfNullOrEmpty(registerLink); if (isProjectInvitation) { await RenderEmail(email, new ProjectInviteEmail(emailAddress, managerName, resourceName ?? "", registerLink, lifetime), language); @@ -296,4 +288,19 @@ private static MimeMessage StartUserEmail(string name, string email) message.To.Add(new MailboxAddress(name, email)); return message; } + + private string MakeLoginRedirectUrl(string jwt, string? returnTo) + { + ArgumentException.ThrowIfNullOrEmpty(jwt); + ArgumentException.ThrowIfNullOrEmpty(returnTo); + if (new Uri(returnTo).IsAbsoluteUri) throw new ArgumentException($"returnTo must be relative, was: {returnTo}", nameof(returnTo)); + var httpContext = httpContextAccessor.HttpContext; + ArgumentNullException.ThrowIfNull(httpContext); + var loginRedirect = _linkGenerator.GetUriByAction(httpContext, + "LoginRedirect", + "Login", + new { jwt, returnTo }); + ArgumentException.ThrowIfNullOrEmpty(loginRedirect); + return loginRedirect; + } } diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index 995c9d35a..35f3ab4dc 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -38,7 +38,7 @@ "CloudFlare": { // always passes key, more info here: https://developers.cloudflare.com/turnstile/frequently-asked-questions/#are-there-sitekeys-and-secret-keys-that-can-be-used-for-testing "TurnstileKey": "1x0000000000000000000000000000000AA", - "AllowDomain": "mailinator.com" + "AllowDomain": "maildev.com" }, "HgConfig": { "RepoPath": "../../hgweb/repos", 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"; diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index 2bc264260..e762a5440 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -78,15 +78,7 @@ public async IAsyncEnumerable GetPartsOfSpeech() foreach (var item in optionListItems) { - yield return new PartOfSpeech - { - Id = item.Guid ?? Guid.Empty, - Name = new MultiString - { - { "en", item.Value ?? item.Abbreviation ?? string.Empty }, - { "__key", item.Key ?? string.Empty } // The key is all that senses have on them, so we need it client-side to find the display name - } - }; + yield return ToPartOfSpeech(item); } } @@ -95,9 +87,19 @@ public async IAsyncEnumerable GetPartsOfSpeech() return await GetPartsOfSpeech().FirstOrDefaultAsync(pos => pos.Id == id); } - public IAsyncEnumerable GetSemanticDomains() + public async IAsyncEnumerable GetSemanticDomains() { - return AsyncEnumerable.Empty(); + var optionListItems = await dbContext.GetOptionListItems(projectCode, "semantic-domain-ddp4"); + + foreach (var item in optionListItems) + { + yield return ToSemanticDomain(item); + } + } + + public async Task GetSemanticDomain(Guid id) + { + return await GetSemanticDomains().FirstOrDefaultAsync(semdom => semdom.Id == id); } public IAsyncEnumerable GetEntries(QueryOptions? options = null) @@ -276,6 +278,36 @@ private static MultiString ToMultiString(Dictionary? multiText return ms; } + private static PartOfSpeech ToPartOfSpeech(Entities.OptionListItem item) + { + return new PartOfSpeech + { + Id = item.Guid ?? Guid.Empty, + Name = new MultiString + { + { "en", item.Value ?? item.Abbreviation ?? string.Empty }, + { "__key", item.Key ?? string.Empty } // The key is all that senses have on them, so we need it client-side to find the display name + }, + // TODO: Abbreviation + Predefined = false, + }; + } + + private static SemanticDomain ToSemanticDomain(Entities.OptionListItem item) + { + // TODO: Needs testing against actual LF testlangproj data + return new SemanticDomain + { + Id = item.Guid ?? Guid.Empty, + Name = new MultiString + { + { "en", item.Value ?? item.Abbreviation ?? string.Empty }, + { "__key", item.Key ?? string.Empty } // The key is all that senses have on them, so we need it client-side to find the display name + }, + Predefined = false, + }; + } + public async Task GetEntry(Guid id) { var entry = await Entries.Find(e => e.Guid == id).FirstOrDefaultAsync(); diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index eb967d245..1f9901865 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Json; using System.Text.Json.Nodes; +using LexCore.Auth; using Microsoft.Extensions.Http.Resilience; using Polly; using Shouldly; @@ -15,6 +16,7 @@ public class ApiTestBase private readonly SocketsHttpHandler _httpClientHandler; public readonly HttpClient HttpClient; public string? CurrJwt { get; private set; } + public LexAuthUser CurrentUser => JwtHelper.ToLexAuthUser(CurrJwt!); public ApiTestBase() { diff --git a/backend/Testing/ApiTests/FlexJwtTests.cs b/backend/Testing/ApiTests/FlexJwtTests.cs index 51328ac7c..4594590a1 100644 --- a/backend/Testing/ApiTests/FlexJwtTests.cs +++ b/backend/Testing/ApiTests/FlexJwtTests.cs @@ -11,8 +11,6 @@ namespace Testing.ApiTests; [Trait("Category", "Integration")] public class FlexJwtTests : ApiTestBase { - private static readonly JwtSecurityTokenHandler TokenHandler = new(); - private async Task GetFlexJwt() { var userJwt = await JwtHelper.GetJwtForUser(new SendReceiveAuth("manager", @@ -23,9 +21,7 @@ private async Task GetFlexJwt() private LexAuthUser ParseUserToken(string jwt) { - var outputJwt = TokenHandler.ReadJwtToken(jwt); - var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); - return LexAuthUser.FromClaimsPrincipal(principal) ?? throw new NullReferenceException("User was null"); + return JwtHelper.ToLexAuthUser(jwt); } [Fact] diff --git a/backend/Testing/ApiTests/GqlMiddlewareTests.cs b/backend/Testing/ApiTests/GqlMiddlewareTests.cs index d31b96238..d6c0ed834 100644 --- a/backend/Testing/ApiTests/GqlMiddlewareTests.cs +++ b/backend/Testing/ApiTests/GqlMiddlewareTests.cs @@ -84,7 +84,7 @@ await Task.WhenAll( var myProjects = json["data"]!["myProjects"]!.AsArray(); var ids = myProjects.Select(p => p!["id"]!.GetValue()); - projects.Select(p => p.id).ShouldBeSubsetOf(ids); + projects.Select(p => p.Id).ShouldBeSubsetOf(ids); } [Fact] diff --git a/backend/Testing/ApiTests/ProjectPermissionTests.cs b/backend/Testing/ApiTests/ProjectPermissionTests.cs new file mode 100644 index 000000000..36f781ffc --- /dev/null +++ b/backend/Testing/ApiTests/ProjectPermissionTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json.Nodes; +using Shouldly; +using Testing.Services; + +namespace Testing.ApiTests; + +[Trait("Category", "Integration")] +public class ProjectPermissionTests : ApiTestBase +{ + private async Task QueryProject(string projectCode, bool expectGqlError = false) + { + var json = await ExecuteGql( + $$""" + query { + projectByCode(code: "{{projectCode}}") { + id + name + users { + user { + id + name + } + } + } + } + """, + expectGqlError); + return json; + } + + private async Task AddUserToProject(Guid projectId, string username) + { + await ExecuteGql( + $$""" + mutation { + addProjectMember(input: { + projectId: "{{projectId}}", + usernameOrEmail: "{{username}}", + role: EDITOR, + canInvite: false + }) { + project { + id + } + errors { + __typename + ... on Error { + message + } + } + } + } + """); + } + + private JsonObject GetProject(JsonObject json) + { + var project = json["data"]!["projectByCode"]?.AsObject(); + project.ShouldNotBeNull(); + return project; + } + + private void MustHaveMembers(JsonObject project, int? count = null) + { + var members = project["users"]!.AsArray(); + members.ShouldNotBeNull().ShouldNotBeEmpty(); + if (count is not null) members.Count.ShouldBe(count.Value); + } + + private void MustNotHaveMembers(JsonObject project) + { + var users = project["users"]!.AsArray(); + users.ShouldBeEmpty(); + } + + private void MustHaveOnlyUserAsMember(JsonObject project, Guid userId) + { + var users = project["users"]!.AsArray(); + users.ShouldContain(node => node!["user"]!["id"]!.GetValue() == userId, + "user list " + users.ToJsonString()); + } + + [Fact] + public async Task MemberCanSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig()); + //refresh jwt + await LoginAs("manager"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveMembers(json); + } + + [Fact] + public async Task NonMemberCannotSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig()); + await LoginAs("user"); + var json = GetProject(await QueryProject(project.Code)); + MustNotHaveMembers(json); + } + + [Fact] + public async Task ConfidentialProject_ManagerCanSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("manager"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveMembers(json); + } + + [Fact] + public async Task ConfidentialProject_NonManagerCannotSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("manager"); + await AddUserToProject(project.Id, "editor"); + MustHaveMembers(GetProject(await QueryProject(project.Code)), count: 2); + await LoginAs("editor"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveOnlyUserAsMember(json, CurrentUser.Id); + } + + [Fact] + public async Task ConfidentialProject_NonMemberCannotSeeProject() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("user"); + var json = await QueryProject(project.Code, expectGqlError: true); + var error = json["errors"]!.AsArray().First()?.AsObject(); + error.ShouldNotBeNull(); + error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + } +} diff --git a/backend/Testing/Services/JwtHelper.cs b/backend/Testing/Services/JwtHelper.cs index cbee0b188..af6a8172a 100644 --- a/backend/Testing/Services/JwtHelper.cs +++ b/backend/Testing/Services/JwtHelper.cs @@ -1,8 +1,12 @@ +using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; using LexBoxApi.Auth; +using LexCore.Auth; using Microsoft.Extensions.Http.Resilience; +using Mono.Unix.Native; using Polly; using Shouldly; using Testing.ApiTests; @@ -80,4 +84,12 @@ public static void ClearCookies(SocketsHttpHandler httpClientHandler) cookie.Expired = true; } } + + private static readonly JwtSecurityTokenHandler TokenHandler = new(); + public static LexAuthUser ToLexAuthUser(string jwt) + { + var outputJwt = TokenHandler.ReadJwtToken(jwt); + var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); + return LexAuthUser.FromClaimsPrincipal(principal) ?? throw new NullReferenceException("User was null"); + } } diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index f8e46f7c1..dfea6d1bd 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -32,6 +32,14 @@ public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, boo return new ProjectConfig(id, projectName, projectCode, dir, isConfidential, owningOrgId); } + public static async Task RegisterProjectInLexBox( + this ApiTestBase apiTester, + ProjectConfig config, + bool waitForRepoReady = false) + { + return await RegisterProjectInLexBox(config, apiTester, waitForRepoReady); + } + public static async Task RegisterProjectInLexBox( ProjectConfig config, ApiTestBase apiTester, @@ -65,7 +73,7 @@ ... on DbError { } """); if (waitForRepoReady) await WaitForHgRefreshIntervalAsync(); - return new LexboxProject(apiTester, config.Id); + return new LexboxProject(apiTester, config); } public static async Task AddMemberToProject( @@ -136,20 +144,22 @@ private static string GetNewProjectDir(string projectCode, public record LexboxProject : IAsyncDisposable { - public readonly Guid id; + private static string? _jwt; + public Guid Id => _config.Id; + public string Code => _config.Code; + private readonly ProjectConfig _config; private readonly ApiTestBase _apiTester; - private readonly string _jwt; - public LexboxProject(ApiTestBase apiTester, Guid id) + public LexboxProject(ApiTestBase apiTester, ProjectConfig config) { - this.id = id; + _config = config; _apiTester = apiTester; - _jwt = apiTester.CurrJwt ?? throw new InvalidOperationException("No JWT found"); } public async ValueTask DisposeAsync() { - var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{id}?jwt={_jwt}"); + _jwt ??= await JwtHelper.GetJwtForUser(AdminAuth); + var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{Id}?jwt={_jwt}"); response.EnsureSuccessStatusCode(); } } diff --git a/deployment/base/fw-headless-deployment.yaml b/deployment/base/fw-headless-deployment.yaml index 9447cfe4c..efe6175d8 100644 --- a/deployment/base/fw-headless-deployment.yaml +++ b/deployment/base/fw-headless-deployment.yaml @@ -31,10 +31,7 @@ spec: matchLabels: app: fw-headless strategy: - rollingUpdate: - maxSurge: 2 - maxUnavailable: 0 - type: RollingUpdate + type: Recreate template: # https://kubernetes.io/docs/concepts/workloads/pods/#pod-templates metadata: diff --git a/deployment/develop/lexbox-deployment.patch.yaml b/deployment/develop/lexbox-deployment.patch.yaml index 0e1db473d..582d87f7e 100644 --- a/deployment/develop/lexbox-deployment.patch.yaml +++ b/deployment/develop/lexbox-deployment.patch.yaml @@ -12,7 +12,7 @@ spec: - name: lexbox-api env: - name: CloudFlare__AllowDomain - value: "mailinator.com" + value: "developermail.com" - name: ASPNETCORE_ENVIRONMENT value: "Staging" #we don't want to act like dev as that's for local development valueFrom: diff --git a/deployment/local-dev/lexbox-deployment.patch.yaml b/deployment/local-dev/lexbox-deployment.patch.yaml index 9d2f83656..08ebb18ef 100644 --- a/deployment/local-dev/lexbox-deployment.patch.yaml +++ b/deployment/local-dev/lexbox-deployment.patch.yaml @@ -35,7 +35,7 @@ spec: value: "1x0000000000000000000000000000000AA" valueFrom: - name: CloudFlare__AllowDomain - value: "mailinator.com" + value: "maildev.com" - name: HealthChecksConfig__RequireFwHeadlessContainerVersionMatch value: "false" - name: HealthChecksConfig__RequireHealthyFwHeadlessContainer diff --git a/deployment/staging/lexbox-deployment.patch.yaml b/deployment/staging/lexbox-deployment.patch.yaml index 123bbdada..c72962881 100644 --- a/deployment/staging/lexbox-deployment.patch.yaml +++ b/deployment/staging/lexbox-deployment.patch.yaml @@ -18,7 +18,7 @@ spec: env: - name: CloudFlare__AllowDomain - value: "mailinator.com" + value: "developermail.com" - name: Email__SmtpHost value: email-smtp.us-east-1.amazonaws.com - name: Email__SmtpPort diff --git a/frontend/.npmrc b/frontend/.npmrc index 8153bffaf..366dd5c83 100644 --- a/frontend/.npmrc +++ b/frontend/.npmrc @@ -1,2 +1,3 @@ engine-strict=true include-workspace-root=true +ignore-workspace-root-check=true # we commonly add packages to the root package.json (aka frontend/package.json) diff --git a/frontend/package.json b/frontend/package.json index 94a988851..302d078e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -95,6 +95,7 @@ "@types/set-cookie-parser": "^2.4.7", "@vitejs/plugin-basic-ssl": "^1.1.0", "css-tree": "^2.3.1", + "e2e-mailbox": "1.1.5", "js-cookie": "^3.0.5", "just-order-by": "^1.0.0", "mjml": "^4.15.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2628def54..30589ae61 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: css-tree: specifier: ^2.3.1 version: 2.3.1 + e2e-mailbox: + specifier: 1.1.5 + version: 1.1.5 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -2138,6 +2141,9 @@ packages: cpu: [x64] os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sveltejs/adapter-node@4.0.1': resolution: {integrity: sha512-IviiTtKCDp+0QoTmmMlGGZBA1EoUNsjecU6XGV9k62S3f01SNsVhpqi2e4nbI62BLGKh/YKKfFii+Vz/b9XIxg==} peerDependencies: @@ -2637,6 +2643,9 @@ packages: async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -2652,6 +2661,9 @@ packages: peerDependencies: postcss: ^8.1.0 + axios@1.7.4: + resolution: {integrity: sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==} + axobject-query@4.0.0: resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} @@ -2900,6 +2912,10 @@ packages: combine-errors@3.0.3: resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -3101,6 +3117,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dependency-graph@0.11.0: resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} engines: {node: '>= 0.6.0'} @@ -3195,6 +3215,9 @@ packages: resolution: {integrity: sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==} engines: {node: '>=4'} + e2e-mailbox@1.1.5: + resolution: {integrity: sha512-te+CSEbba3eHzYZNopYF8M31yYuE33k/28+6BZ7mhTNQ92PsjByAHEPiWsL/U2NEy5NLWbWR7rF2o7dH4GRX/Q==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3221,6 +3244,14 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encoding-japanese@2.0.0: + resolution: {integrity: sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==} + engines: {node: '>=8.10.0'} + + encoding-japanese@2.1.0: + resolution: {integrity: sha512-58XySVxUgVlBikBTbQ8WdDxBDHIdXucB16LO5PBHR8t75D54wQrNo4cg+58+R1CtJfKnsVsvt9XlteRaR8xw1w==} + engines: {node: '>=8.10.0'} + entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} @@ -3452,10 +3483,23 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -3617,6 +3661,10 @@ packages: engines: {node: '>=6'} hasBin: true + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + htmlparser2@5.0.1: resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} @@ -3642,6 +3690,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3926,10 +3978,31 @@ packages: kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libbase64@1.2.1: + resolution: {integrity: sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.2.0: + resolution: {integrity: sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==} + + libmime@5.3.5: + resolution: {integrity: sha512-nSlR1yRZ43L3cZCiWEw7ali3jY29Hz9CQQ96Oy+sSspYnIP5N54ucOPHqooBsXzwrX1pwn13VUE05q4WmzfaLg==} + + libqp@2.0.1: + resolution: {integrity: sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==} + + libqp@2.1.0: + resolution: {integrity: sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -3945,6 +4018,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + listr2@4.0.5: resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} engines: {node: '>=12'} @@ -4076,6 +4152,12 @@ packages: magic-string@0.30.12: resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} + mailparser@3.7.1: + resolution: {integrity: sha512-RCnBhy5q8XtB3mXzxcAfT1huNqN93HTYYyL6XawlIKycfxM/rXPg9tXoZ7D46+SgCS1zxKzw+BayDQSvncSTTw==} + + mailsplit@5.4.0: + resolution: {integrity: sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==} + map-cache@0.2.2: resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} engines: {node: '>=0.10.0'} @@ -4229,6 +4311,14 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -4433,6 +4523,10 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + nodemailer@6.9.13: + resolution: {integrity: sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==} + engines: {node: '>=6.0.0'} + nopt@7.2.0: resolution: {integrity: sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -4554,6 +4648,9 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} @@ -4606,6 +4703,9 @@ packages: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -4810,9 +4910,16 @@ packages: resolution: {integrity: sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==} engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -4998,6 +5105,9 @@ packages: scuid@1.1.0: resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5333,6 +5443,10 @@ packages: title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + tlds@1.252.0: + resolution: {integrity: sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -5431,6 +5545,9 @@ packages: ua-parser-js@1.0.37: resolution: {integrity: sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==} + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.3.2: resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==} @@ -8052,6 +8169,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.9.6': optional: true + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@sveltejs/adapter-node@4.0.1(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.9(@types/node@20.12.12)))(svelte@4.2.19)(vite@5.4.9(@types/node@20.12.12)))': dependencies: '@rollup/plugin-commonjs': 25.0.7(rollup@4.9.6) @@ -8681,6 +8803,8 @@ snapshots: async@3.2.5: {} + asynckit@0.4.0: {} + atomically@1.7.0: {} auto-bind@4.0.0: {} @@ -8695,6 +8819,14 @@ snapshots: postcss: 8.4.47 postcss-value-parser: 4.2.0 + axios@1.7.4: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.0.0: dependencies: dequal: 2.0.3 @@ -9026,6 +9158,10 @@ snapshots: custom-error-instance: 2.1.1 lodash.uniqby: 4.5.0 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} commander@11.1.0: {} @@ -9210,6 +9346,8 @@ snapshots: define-lazy-prop@2.0.0: {} + delayed-stream@1.0.0: {} + dependency-graph@0.11.0: {} dequal@2.0.3: {} @@ -9299,6 +9437,13 @@ snapshots: dset@3.1.3: {} + e2e-mailbox@1.1.5: + dependencies: + axios: 1.7.4 + mailparser: 3.7.1 + transitivePeerDependencies: + - debug + eastasianwidth@0.2.0: {} editorconfig@1.0.4: @@ -9321,6 +9466,10 @@ snapshots: enabled@2.0.0: {} + encoding-japanese@2.0.0: {} + + encoding-japanese@2.1.0: {} + entities@2.2.0: {} entities@4.5.0: {} @@ -9605,11 +9754,19 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.9: {} + foreground-child@3.1.1: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fraction.js@4.3.7: {} fs.realpath@1.0.0: {} @@ -9792,6 +9949,14 @@ snapshots: relateurl: 0.2.7 uglify-js: 3.17.4 + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + htmlparser2@5.0.1: dependencies: domelementtype: 2.3.0 @@ -9833,6 +9998,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.1: {} @@ -10082,11 +10251,35 @@ snapshots: kuler@2.0.0: {} + leac@0.6.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + libbase64@1.2.1: {} + + libbase64@1.3.0: {} + + libmime@5.2.0: + dependencies: + encoding-japanese: 2.0.0 + iconv-lite: 0.6.3 + libbase64: 1.2.1 + libqp: 2.0.1 + + libmime@5.3.5: + dependencies: + encoding-japanese: 2.1.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.0 + + libqp@2.0.1: {} + + libqp@2.1.0: {} + lilconfig@2.1.0: {} lilconfig@3.0.0: {} @@ -10096,6 +10289,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + listr2@4.0.5: dependencies: cli-truncate: 2.1.0 @@ -10230,6 +10427,25 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + mailparser@3.7.1: + dependencies: + encoding-japanese: 2.1.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.6.3 + libmime: 5.3.5 + linkify-it: 5.0.0 + mailsplit: 5.4.0 + nodemailer: 6.9.13 + punycode.js: 2.3.1 + tlds: 1.252.0 + + mailsplit@5.4.0: + dependencies: + libbase64: 1.2.1 + libmime: 5.2.0 + libqp: 2.0.1 + map-cache@0.2.2: {} markdown-table@3.0.3: {} @@ -10555,6 +10771,12 @@ snapshots: braces: 3.0.2 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -10940,6 +11162,8 @@ snapshots: node-releases@2.0.18: optional: true + nodemailer@6.9.13: {} + nopt@7.2.0: dependencies: abbrev: 2.0.0 @@ -11072,6 +11296,11 @@ snapshots: dependencies: entities: 4.5.0 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -11111,6 +11340,8 @@ snapshots: pathval@2.0.0: {} + peberminta@0.9.0: {} + periscopic@3.1.0: dependencies: '@types/estree': 1.0.5 @@ -11302,8 +11533,12 @@ snapshots: '@types/node': 20.12.12 long: 5.2.3 + proxy-from-env@1.1.0: {} + psl@1.9.0: {} + punycode.js@2.3.1: {} + punycode@1.4.1: {} punycode@2.3.1: {} @@ -11516,6 +11751,10 @@ snapshots: scuid@1.1.0: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver@6.3.1: {} semver@7.5.4: @@ -11923,6 +12162,8 @@ snapshots: dependencies: tslib: 2.6.2 + tlds@1.252.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -12001,6 +12242,8 @@ snapshots: ua-parser-js@1.0.37: {} + uc.micro@2.1.0: {} + ufo@1.3.2: {} ufo@1.5.3: {} diff --git a/frontend/schema.graphql b/frontend/schema.graphql index a66589a16..30d71fdc4 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1249,13 +1249,10 @@ directive @listSize("The `assumedSize` argument can be used to statically define "The `@specifiedBy` directive is used within the type system definition language to provide a URL for specifying the behavior of custom scalar definitions." directive @specifiedBy("The specifiedBy URL points to a human-readable specification. This field will only read a result for scalar types." url: String!) on SCALAR +"The `DateTime` scalar represents an ISO-8601 compliant date time type." scalar DateTime @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time") "The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1." scalar Long -scalar UUID - -scalar timestamptz @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time") - -scalar uuid \ No newline at end of file +scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122") \ No newline at end of file diff --git a/frontend/src/lib/components/Users/UserFilter.svelte b/frontend/src/lib/components/Users/UserFilter.svelte index 1fc55a834..a013482a2 100644 --- a/frontend/src/lib/components/Users/UserFilter.svelte +++ b/frontend/src/lib/components/Users/UserFilter.svelte @@ -1,6 +1,4 @@ - +{#key options} + +{/key}
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte index 0f44e9576..2cbcc4e25 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte @@ -94,7 +94,9 @@ $: empty = !value; - +{#key options} + +{/key}
diff --git a/frontend/viewer/tailwind.config.cjs b/frontend/viewer/tailwind.config.cjs index 7691179c6..2e0d53eeb 100644 --- a/frontend/viewer/tailwind.config.cjs +++ b/frontend/viewer/tailwind.config.cjs @@ -11,6 +11,9 @@ module.exports = { }, plugins: [ iconsPlugin({ + // Root source: https://github.com/Templarian/MaterialDesign + // Our source (that pulls from ☝️): https://www.npmjs.com/package/@iconify-json/mdi + // Search showing aliases and version (of root source) icons were introduced: https://pictogrammers.com/library/mdi/ collections: getIconCollections(['mdi']), }), svelteUx({ colorSpace: 'oklch' }),