diff --git a/.github/workflows/release-pipeline.yaml b/.github/workflows/release-pipeline.yaml
index bab9609dc..59cbfd116 100644
--- a/.github/workflows/release-pipeline.yaml
+++ b/.github/workflows/release-pipeline.yaml
@@ -75,5 +75,5 @@ jobs:
version: ${{ needs.set-version.outputs.version }}
image: 'ghcr.io/sillsdev/lexbox-*'
k8s-environment: production
- deploy-domain: prod.languagedepot.org
+ deploy-domain: languagedepot.org
diff --git a/.idea/.idea.LexBox/.idea/dataSources.xml b/.idea/.idea.LexBox/.idea/dataSources.xml
index db47a63ea..2fbfdd1a0 100644
--- a/.idea/.idea.LexBox/.idea/dataSources.xml
+++ b/.idea/.idea.LexBox/.idea/dataSources.xml
@@ -5,7 +5,16 @@
postgresql
true
org.postgresql.Driver
- jdbc:postgresql://localhost:5433/lexbox
+ jdbc:postgresql://localhost/lexbox
+
+
+
+
+
+
+
+
+
$ProjectFileDir$
@@ -26,7 +35,13 @@
org.postgresql.Driver
jdbc:postgresql://localhost:5434/lexbox
-
+
+
+
+
+
+
+
$ProjectFileDir$
@@ -37,7 +52,13 @@
org.postgresql.Driver
jdbc:postgresql://localhost:5435/lexbox
-
+
+
+
+
+
+
+
$ProjectFileDir$
diff --git a/.idea/.idea.LexBox/.idea/indexLayout.xml b/.idea/.idea.LexBox/.idea/indexLayout.xml
index d08f0ade8..bfb0056af 100644
--- a/.idea/.idea.LexBox/.idea/indexLayout.xml
+++ b/.idea/.idea.LexBox/.idea/indexLayout.xml
@@ -7,7 +7,13 @@
hg-web
+ .github
+ deployment
hg-web
+ hgresumable
+ hgweb
+ otel
+ proxy
frontend/.svelte-kit/output
diff --git a/.idea/.idea.LexBox/.idea/kubernetes-settings.xml b/.idea/.idea.LexBox/.idea/kubernetes-settings.xml
deleted file mode 100644
index 9c05fcbdc..000000000
--- a/.idea/.idea.LexBox/.idea/kubernetes-settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/backend/LexBoxApi/Auth/AdminRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs
similarity index 87%
rename from backend/LexBoxApi/Auth/AdminRequiredAttribute.cs
rename to backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs
index e062e5e9b..856534078 100644
--- a/backend/LexBoxApi/Auth/AdminRequiredAttribute.cs
+++ b/backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs
@@ -1,4 +1,4 @@
-namespace LexBoxApi.Auth;
+namespace LexBoxApi.Auth.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AdminRequiredAttribute : LexboxAuthAttribute
diff --git a/backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs
similarity index 87%
rename from backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs
rename to backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs
index 25b19aa1d..bed625561 100644
--- a/backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs
+++ b/backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs
@@ -1,4 +1,4 @@
-namespace LexBoxApi.Auth;
+namespace LexBoxApi.Auth.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CreateProjectRequiredAttribute: LexboxAuthAttribute
diff --git a/backend/LexBoxApi/Auth/LexboxAuthAttribute.cs b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs
similarity index 95%
rename from backend/LexBoxApi/Auth/LexboxAuthAttribute.cs
rename to backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs
index 5910b6a7a..31cff5996 100644
--- a/backend/LexBoxApi/Auth/LexboxAuthAttribute.cs
+++ b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs
@@ -3,7 +3,7 @@
using Microsoft.AspNetCore.Authorization;
using AuthorizeAttribute = HotChocolate.Authorization.AuthorizeAttribute;
-namespace LexBoxApi.Auth;
+namespace LexBoxApi.Auth.Attributes;
public abstract class LexboxAuthAttribute : DescriptorAttribute, IAuthorizeData
{
diff --git a/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs b/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs
new file mode 100644
index 000000000..215b85860
--- /dev/null
+++ b/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs
@@ -0,0 +1,33 @@
+using LexCore.Auth;
+using Microsoft.AspNetCore.Authorization;
+
+namespace LexBoxApi.Auth.Attributes;
+
+public class RequireAudienceAttribute(params LexboxAudience[] audiences)
+ : LexboxAuthAttribute(PolicyName), IAuthorizationRequirement, IAuthorizationRequirementData
+{
+ public const string PolicyName = "RequireAudiencePolicy";
+ /// audience allowed to access this endpoint
+ /// when false the default audience is also allowed, when true the default audience is not allowed
+ public RequireAudienceAttribute(LexboxAudience audience, bool exclusive = false) : this(exclusive
+ ? [audience]
+ : [audience, LexboxAudience.LexboxApi])
+ {
+ }
+
+ public LexboxAudience[] ValidAudiences { get; } = audiences;
+
+ public IEnumerable GetRequirements()
+ {
+ yield return this;
+ }
+}
+
+public class AllowAnyAudienceAttribute : LexboxAuthAttribute
+{
+ public const string PolicyName = "AllowAnyAudiencePolicy";
+
+ public AllowAnyAudienceAttribute() : base(PolicyName)
+ {
+ }
+}
diff --git a/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs b/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs
new file mode 100644
index 000000000..412a79a8a
--- /dev/null
+++ b/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace LexBoxApi.Auth.Attributes;
+
+///
+/// validates the updated date of the jwt against the database, should be used to make a jwt expire if the user is updated
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class RequireCurrentUserInfoAttribute: Attribute, IAuthorizationRequirement, IAuthorizationRequirementData
+{
+ public IEnumerable GetRequirements()
+ {
+ yield return this;
+ }
+}
diff --git a/backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs
similarity index 87%
rename from backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs
rename to backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs
index 5bfd233cb..a3d2ac897 100644
--- a/backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs
+++ b/backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs
@@ -1,4 +1,4 @@
-namespace LexBoxApi.Auth;
+namespace LexBoxApi.Auth.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class VerifiedEmailRequiredAttribute : LexboxAuthAttribute
diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs
index 5ad7cd923..6e0b93de0 100644
--- a/backend/LexBoxApi/Auth/AuthKernel.cs
+++ b/backend/LexBoxApi/Auth/AuthKernel.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Text;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.Auth.Requirements;
using LexCore.Auth;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -25,10 +26,12 @@ public static void AddLexBoxAuth(IServiceCollection services,
if (environment.IsDevelopment())
{
IdentityModelEventSource.ShowPII = true;
+ IdentityModelEventSource.LogCompleteSecurityArtifact = true;
}
services.AddScoped();
services.AddSingleton();
+ services.AddSingleton();
services.AddAuthorization(options =>
{
//fallback policy is used when there's no auth attribute.
@@ -37,24 +40,11 @@ public static void AddLexBoxAuth(IServiceCollection services,
options.FallbackPolicy = options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireDefaultLexboxAuth()
.Build();
- foreach (var audience in Enum.GetValues())
- {
- if (audience == LexboxAudience.Unknown) continue;
- //for exclusive the endpoint is only accessible for the specified audience
- options.AddPolicy(audience.PolicyName(true), builder =>
- {
- builder.RequireAuthenticatedUser();
- builder.AddRequirements(new AudienceRequirement(audience));
- });
- //for non exclusive the endpoint is also accessible for the default audience
- options.AddPolicy(audience.PolicyName(false), builder =>
- {
- builder.RequireAuthenticatedUser();
- builder.AddRequirements(new AudienceRequirement(audience, LexboxAudience.LexboxApi));
- });
- }
+
//don't use RequireDefaultLexboxAuth here because that only allows the default audience
options.AddPolicy(AllowAnyAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser());
+ //we still need this policy, without it the default policy is used which requires the default audience
+ options.AddPolicy(RequireAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser());
options.AddPolicy(AdminRequiredAttribute.PolicyName,
builder => builder.RequireDefaultLexboxAuth()
@@ -181,7 +171,7 @@ public static void AddLexBoxAuth(IServiceCollection services,
public static AuthorizationPolicyBuilder RequireDefaultLexboxAuth(this AuthorizationPolicyBuilder builder)
{
return builder.RequireAuthenticatedUser()
- .AddRequirements(new AudienceRequirement(LexboxAudience.LexboxApi));
+ .AddRequirements(new RequireAudienceAttribute(LexboxAudience.LexboxApi, true));
}
public static bool IsJwtRequest(this HttpRequest request)
diff --git a/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs b/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs
deleted file mode 100644
index 8310692ef..000000000
--- a/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using LexCore.Auth;
-
-namespace LexBoxApi.Auth;
-
-//todo for now this attribute only supports a single audience, this may be fine for now.
-//once we are at dotnet 8 we can support multiple audiences using the new IAuthorizationRequirementData api
-//https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-7.0#iauthorizationrequirementdata
-public class RequireAudienceAttribute : LexboxAuthAttribute
-{
- /// audience allowed to access this endpoint
- /// when false the default audience is also allowed, when true the default audience is not allowed
- public RequireAudienceAttribute(LexboxAudience audience, bool exclusive = false) : base(audience.PolicyName(exclusive))
- {
- }
-}
-
-public class AllowAnyAudienceAttribute : LexboxAuthAttribute
-{
- public const string PolicyName = "AllowAnyAudiencePolicy";
- public AllowAnyAudienceAttribute() : base(PolicyName)
- {
- }
-}
diff --git a/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs b/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs
index 20d3b9a0b..2688a6be9 100644
--- a/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs
+++ b/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs
@@ -1,22 +1,14 @@
-using LexCore.Auth;
+using LexBoxApi.Auth.Attributes;
+using LexCore.Auth;
using Microsoft.AspNetCore.Authorization;
-using Microsoft.IdentityModel.JsonWebTokens;
namespace LexBoxApi.Auth.Requirements;
-public class AudienceRequirement : IAuthorizationRequirement
+public class AudienceRequirementHandler(ILogger logger) : AuthorizationHandler
{
- public LexboxAudience[] ValidAudiences { get; }
- public AudienceRequirement(params LexboxAudience[] validAudiences)
- {
- ValidAudiences = validAudiences;
- }
-}
-
-public class AudienceRequirementHandler : AuthorizationHandler
-{
- protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AudienceRequirement requirement)
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
+ RequireAudienceAttribute requirement)
{
var claim = context.User.FindFirst(LexAuthConstants.AudienceClaimType);
if (Enum.TryParse(claim?.Value, out var audience) &&
@@ -26,6 +18,7 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte
}
else
{
+ logger.LogInformation("Token does not have the required audience: [{Audience}] not in {ValidAudiences}", claim?.Value, string.Join(',', requirement.ValidAudiences));
context.Fail(new AuthorizationFailureReason(this,
$"Token does not have the required audience: {requirement.ValidAudiences}"));
}
diff --git a/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs b/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs
new file mode 100644
index 000000000..0b42808a6
--- /dev/null
+++ b/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs
@@ -0,0 +1,28 @@
+using LexBoxApi.Auth.Attributes;
+using LexBoxApi.Services;
+using LexData;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.EntityFrameworkCore;
+
+namespace LexBoxApi.Auth.Requirements;
+
+public class ValidateUserUpdatedHandler(IHttpContextAccessor httpContextAccessor, ILogger logger) : AuthorizationHandler
+{
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, RequireCurrentUserInfoAttribute requirement)
+ {
+ var httpContext = httpContextAccessor.HttpContext;
+ var user = httpContext?.RequestServices.GetRequiredService().MaybeUser;
+ if (user is null) return;
+ var userService = httpContext!.RequestServices.GetRequiredService();
+ var actualUpdatedDate = await userService.GetUserUpdatedDate(user.Id);
+ if (actualUpdatedDate != user.UpdatedDate)
+ {
+ logger.LogInformation("User has been updated since login, {UpdatedDate} != {ActualUpdatedDate}", user.UpdatedDate, actualUpdatedDate);
+ context.Fail(new AuthorizationFailureReason(this, "User has been updated since login"));
+ }
+ else
+ {
+ context.Succeed(requirement);
+ }
+ }
+}
diff --git a/backend/LexBoxApi/Controllers/AdminController.cs b/backend/LexBoxApi/Controllers/AdminController.cs
index 087137803..f6dedcf57 100644
--- a/backend/LexBoxApi/Controllers/AdminController.cs
+++ b/backend/LexBoxApi/Controllers/AdminController.cs
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.Services;
using LexCore;
using LexData;
@@ -33,9 +34,9 @@ public record ResetPasswordAdminRequest([Required(AllowEmptyStrings = false)] st
public async Task ResetPasswordAdmin(ResetPasswordAdminRequest request)
{
var passwordHash = request.PasswordHash;
- var lexAuthUser = _loggedInContext.User;
var user = await _lexBoxDbContext.Users.FirstAsync(u => u.Id == request.userId);
user.PasswordHash = PasswordHashing.HashPassword(passwordHash, user.Salt, true);
+ user.UpdateUpdatedDate();
await _lexBoxDbContext.SaveChangesAsync();
await _emailService.SendPasswordChangedEmail(user);
return Ok();
diff --git a/backend/LexBoxApi/Controllers/AuthTestingController.cs b/backend/LexBoxApi/Controllers/AuthTestingController.cs
index 028f19dc0..908ab6289 100644
--- a/backend/LexBoxApi/Controllers/AuthTestingController.cs
+++ b/backend/LexBoxApi/Controllers/AuthTestingController.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexCore.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs
index c690dd856..36168db3e 100644
--- a/backend/LexBoxApi/Controllers/LoginController.cs
+++ b/backend/LexBoxApi/Controllers/LoginController.cs
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.Models;
using LexBoxApi.Otel;
using LexBoxApi.Services;
@@ -52,11 +53,23 @@ public async Task LoginRedirect(
string jwt, // This is required because auth looks for a jwt in the query string
string returnTo)
{
+ var user = _loggedInContext.User;
+ var userUpdatedDate = await _userService.GetUserUpdatedDate(user.Id);
+ if (userUpdatedDate != user.UpdatedDate)
+ {
+ return await EmailLinkExpired();
+ }
await HttpContext.SignInAsync(User,
new AuthenticationProperties { IsPersistent = true });
return Redirect(returnTo);
}
+ private async Task EmailLinkExpired()
+ {
+ await HttpContext.SignOutAsync();
+ return Redirect("/login?message=link_expired");
+ }
+
[HttpGet("verifyEmail")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -73,9 +86,17 @@ public async Task> VerifyEmail(
var userId = _loggedInContext.User.Id;
var user = await _lexBoxDbContext.Users.FindAsync(userId);
if (user == null) return NotFound();
+ //users can verify their email even if the updated date is out of sync when not changing their email
+ //this is to prevent some edge cases where changing their name and then using an old verify email link would fail
+ if (user.Email != _loggedInContext.User.Email &&
+ user.UpdatedDate.ToUnixTimeSeconds() != _loggedInContext.User.UpdatedDate)
+ {
+ return await EmailLinkExpired();
+ }
user.Email = _loggedInContext.User.Email;
user.EmailVerified = true;
+ user.UpdateUpdatedDate();
await _lexBoxDbContext.SaveChangesAsync();
await RefreshJwt();
return Redirect(returnTo);
@@ -136,12 +157,18 @@ public record ResetPasswordRequest([Required(AllowEmptyStrings = false)] string
[HttpPost("resetPassword")]
[RequireAudience(LexboxAudience.ForgotPassword)]
+ [RequireCurrentUserInfo]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesDefaultResponseType]
public async Task ResetPassword(ResetPasswordRequest request)
{
var passwordHash = request.PasswordHash;
var lexAuthUser = _loggedInContext.User;
- var user = await _lexBoxDbContext.Users.FirstAsync(u => u.Id == lexAuthUser.Id);
+ var user = await _lexBoxDbContext.Users.FindAsync(lexAuthUser.Id);
+ if (user == null) return NotFound();
user.PasswordHash = PasswordHashing.HashPassword(passwordHash, user.Salt, true);
+ user.UpdateUpdatedDate();
await _lexBoxDbContext.SaveChangesAsync();
await _emailService.SendPasswordChangedEmail(user);
//the old jwt is only valid for calling forgot password endpoints, we need to generate a new one
diff --git a/backend/LexBoxApi/Controllers/MigrationController.cs b/backend/LexBoxApi/Controllers/MigrationController.cs
index 9af6dcf06..02d6af91f 100644
--- a/backend/LexBoxApi/Controllers/MigrationController.cs
+++ b/backend/LexBoxApi/Controllers/MigrationController.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.Services;
using LexCore.Entities;
using LexData;
diff --git a/backend/LexBoxApi/Controllers/ProjectController.cs b/backend/LexBoxApi/Controllers/ProjectController.cs
index 64e8e94e8..aba869eb0 100644
--- a/backend/LexBoxApi/Controllers/ProjectController.cs
+++ b/backend/LexBoxApi/Controllers/ProjectController.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.Services;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
diff --git a/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs b/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs
index 6747f7439..532d69f04 100644
--- a/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs
+++ b/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs
@@ -1,5 +1,6 @@
using System.Linq.Expressions;
using System.Reflection;
+using System.Text.RegularExpressions;
using HotChocolate.Data.Filters;
using HotChocolate.Data.Filters.Expressions;
using HotChocolate.Language;
@@ -12,9 +13,10 @@ namespace LexBoxApi.GraphQL.CustomFilters;
/// Postgres doesn't support substring comparisons on nondeterministic collations, so we offer a
/// case insensitive filter that explicitly uses a deterministic collation (und-x-icu) instead.
///
-public class QueryableStringDeterministicInvariantContainsHandler : QueryableStringOperationHandler
+public class QueryableStringDeterministicInvariantContainsHandler(InputParser inputParser)
+ : QueryableStringOperationHandler(inputParser)
{
- private static readonly MethodInfo Ilike = ((Func)NpgsqlDbFunctionsExtensions.ILike).Method;
+ private static readonly MethodInfo Ilike = ((Func)NpgsqlDbFunctionsExtensions.ILike).Method;
private static readonly MethodInfo Collate = ((Func)RelationalDbFunctionsExtensions.Collate).Method;
private static readonly ConstantExpression EfFunctions = Expression.Constant(EF.Functions);
@@ -25,22 +27,19 @@ static QueryableStringDeterministicInvariantContainsHandler()
}
protected override int Operation => CustomFilterOperations.IContains;
-
- public QueryableStringDeterministicInvariantContainsHandler(InputParser inputParser)
- : base(inputParser)
- {
- }
+ private static readonly Regex EscapeLikePatternRegex = new(@"([\\_%])", RegexOptions.Compiled);
public override Expression HandleOperation(
QueryableFilterContext context,
IFilterOperationField field,
IValueNode value,
- object? search)
+ object? searchObject)
{
- if (search is not string)
- throw new InvalidOperationException($"Expected {nameof(QueryableStringDeterministicInvariantContainsHandler)} to be called with a string, but was {search}.");
+ if (searchObject is not string search)
+ throw new InvalidOperationException($"Expected {nameof(QueryableStringDeterministicInvariantContainsHandler)} to be called with a string, but was {searchObject}.");
- var pattern = $"%{search}%";
+ var escapedString = EscapeLikePatternRegex.Replace(search, @"\$1");
+ var pattern = $"%{escapedString}%";
var property = context.GetInstance();
var collatedValueExpression = Expression.Call(
@@ -53,6 +52,8 @@ public override Expression HandleOperation(
Expression.Constant("und-x-icu")
);
+ //this is a bit of a hack to make sure that the pattern is interpreted as a query parameter instead of a string constant. This means queries will be cached.
+ Expression> lambda = () => pattern;
// property != null && EF.Functions.ILike(EF.Functions.Collate(property, "und-x-icu"), "%search%")
return Expression.AndAlso(
Expression.NotEqual(property, Expression.Constant(null, typeof(object))),
@@ -61,7 +62,8 @@ public override Expression HandleOperation(
Ilike,
EfFunctions,
collatedValueExpression,
- Expression.Constant(pattern)
+ lambda.Body,
+ Expression.Constant(@"\")
)
);
}
diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
index 0121e0e92..0f79ccb4e 100644
--- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
+++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
@@ -40,17 +40,8 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
descriptor.AddDefaults();
descriptor.AddDeterministicInvariantContainsFilter();
})
- .AddProjections(descriptor =>
- {
- descriptor.Provider(new QueryableProjectionProvider(providerDescriptor =>
- {
- //does not work because hot chocolate wants to make this as the select `p => new project { userCount = p.usercount}`
- // which doesn't work when using projectable because the field needs to be write only
- //shelving it for now
- providerDescriptor.RegisterFieldHandler();
- providerDescriptor.AddDefaults();
- }));
- }).SetPagingOptions(new ()
+ .AddProjections()
+ .SetPagingOptions(new ()
{
DefaultPageSize = 100,
MaxPageSize = 1000,
diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs
index f51999500..9c705d535 100644
--- a/backend/LexBoxApi/GraphQL/LexQueries.cs
+++ b/backend/LexBoxApi/GraphQL/LexQueries.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs
index bf1a0aa5b..bf1f34bf1 100644
--- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs
+++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Models.Project;
using LexBoxApi.Services;
diff --git a/backend/LexBoxApi/GraphQL/TestQueries.cs b/backend/LexBoxApi/GraphQL/TestQueries.cs
index ef296ac06..9b2cfc8e3 100644
--- a/backend/LexBoxApi/GraphQL/TestQueries.cs
+++ b/backend/LexBoxApi/GraphQL/TestQueries.cs
@@ -1,4 +1,5 @@
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexCore.Auth;
namespace LexBoxApi.GraphQL;
diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs
index 8696d3832..caeceb7e9 100644
--- a/backend/LexBoxApi/GraphQL/UserMutations.cs
+++ b/backend/LexBoxApi/GraphQL/UserMutations.cs
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Models.Project;
using LexBoxApi.Services;
@@ -80,7 +81,7 @@ LexAuthService lexAuthService
user.IsAdmin = adminInput.Role == UserRole.admin;
}
}
-
+ user.UpdateUpdatedDate();
await dbContext.SaveChangesAsync();
if (!input.Email.IsNullOrEmpty() && !input.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs
index 85f8f4742..753b02dad 100644
--- a/backend/LexBoxApi/Program.cs
+++ b/backend/LexBoxApi/Program.cs
@@ -1,7 +1,9 @@
using System.Net;
+using System.Text.Json;
using System.Text.Json.Serialization;
using LexBoxApi;
using LexBoxApi.Auth;
+using LexBoxApi.Auth.Attributes;
using LexBoxApi.ErrorHandling;
using LexBoxApi.Otel;
using LexBoxApi.Services;
@@ -10,7 +12,9 @@
using LexSyncReverseProxy;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.HttpLogging;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using tusdotnet;
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
@@ -50,8 +54,10 @@
options.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider());
}).AddJsonOptions(options =>
{
- options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper));
});
+builder.Services.AddSingleton(services =>
+ services.GetRequiredService>().Value.JsonSerializerOptions);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs
index 59394907f..4bde9d6ce 100644
--- a/backend/LexBoxApi/Services/EmailService.cs
+++ b/backend/LexBoxApi/Services/EmailService.cs
@@ -1,4 +1,6 @@
using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
using LexBoxApi.Config;
using LexBoxApi.Models.Project;
using LexBoxApi.Otel;
@@ -16,16 +18,19 @@ namespace LexBoxApi.Services;
public class EmailService
{
private readonly EmailConfig _emailConfig;
+ private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly IHttpClientFactory _clientFactory;
private readonly LinkGenerator _linkGenerator;
private readonly IHttpContextAccessor _httpContextAccessor;
public EmailService(IOptions emailConfig,
+ JsonSerializerOptions jsonSerializerOptions,
IHttpClientFactory clientFactory,
LexboxLinkGenerator linkGenerator,
IHttpContextAccessor httpContextAccessor
)
{
+ _jsonSerializerOptions = jsonSerializerOptions;
_clientFactory = clientFactory;
_linkGenerator = linkGenerator;
_httpContextAccessor = httpContextAccessor;
@@ -114,7 +119,7 @@ private async Task RenderEmail(MimeMessage message, T parameters) where T : E
var httpClient = _clientFactory.CreateClient();
httpClient.BaseAddress = new Uri("http://" + _emailConfig.EmailRenderHost);
parameters.BaseUrl = _emailConfig.BaseUrl;
- var response = await httpClient.PostAsJsonAsync("email", parameters);
+ var response = await httpClient.PostAsJsonAsync("email", parameters, _jsonSerializerOptions);
response.EnsureSuccessStatusCode();
var renderResult = await response.Content.ReadFromJsonAsync();
if (renderResult is null)
diff --git a/backend/LexBoxApi/Services/UserService.cs b/backend/LexBoxApi/Services/UserService.cs
index 00b86755e..48fe5d1e7 100644
--- a/backend/LexBoxApi/Services/UserService.cs
+++ b/backend/LexBoxApi/Services/UserService.cs
@@ -17,4 +17,11 @@ public async Task UpdateUserLastActive(Guid id)
await _dbContext.Users.Where(u => u.Id == id)
.ExecuteUpdateAsync(c => c.SetProperty(u => u.LastActive, DateTimeOffset.UtcNow));
}
+
+ public async Task GetUserUpdatedDate(Guid id)
+ {
+ return (await _dbContext.Users.Where(u => u.Id == id)
+ .Select(u => u.UpdatedDate)
+ .SingleOrDefaultAsync()).ToUnixTimeSeconds();
+ }
}
diff --git a/backend/LexCore/Auth/LexAuthConstants.cs b/backend/LexCore/Auth/LexAuthConstants.cs
index bbfa5b325..44c83d292 100644
--- a/backend/LexCore/Auth/LexAuthConstants.cs
+++ b/backend/LexCore/Auth/LexAuthConstants.cs
@@ -10,6 +10,7 @@ public static class LexAuthConstants
public const string ProjectsClaimType = "proj";
public const string EmailUnverifiedClaimType = "unver";
public const string CanCreateProjectClaimType = "mkproj";
+ public const string UpdatedDateClaimType = "date";
}
///
diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs
index 5671ad507..7467b5ab2 100644
--- a/backend/LexCore/Auth/LexAuthUser.cs
+++ b/backend/LexCore/Auth/LexAuthUser.cs
@@ -39,6 +39,11 @@ public record LexAuthUser
case ClaimValueTypes.Boolean:
jsonObject.Add(claim.Type, JsonValue.Create(bool.Parse(claim.Value)));
continue;
+ case ClaimValueTypes.Integer:
+ case ClaimValueTypes.Integer32:
+ case ClaimValueTypes.Integer64:
+ jsonObject.Add(claim.Type, JsonValue.Create(int.Parse(claim.Value)));
+ continue;
default:
jsonObject.Add(claim.Type, JsonValue.Create(claim.Value));
continue;
@@ -83,6 +88,7 @@ public LexAuthUser(User user)
Email = user.Email;
Role = user.IsAdmin ? UserRole.admin : UserRole.user;
Name = user.Name;
+ UpdatedDate = user.UpdatedDate.ToUnixTimeSeconds();
Projects = user.IsAdmin
? Array.Empty() // admins have access to all projects, so we don't include them to prevent going over the jwt limit
: user.Projects.Select(p => new AuthUserProject(p.Role, p.ProjectId)).ToArray();
@@ -92,7 +98,8 @@ public LexAuthUser(User user)
[JsonPropertyName(LexAuthConstants.IdClaimType)]
public required Guid Id { get; set; }
-
+ [JsonPropertyName(LexAuthConstants.UpdatedDateClaimType)]
+ public required long UpdatedDate { get; set; }
[JsonPropertyName(LexAuthConstants.AudienceClaimType)]
public LexboxAudience Audience { get; set; } = LexboxAudience.LexboxApi;
@@ -183,6 +190,9 @@ public IEnumerable GetClaims()
break;
case JsonValueKind.Null:
break;
+ case JsonValueKind.Number:
+ yield return new Claim(jsonProperty.Name, jsonProperty.Value.ToString(), ClaimValueTypes.Integer);
+ break;
default:
yield return new Claim(jsonProperty.Name, jsonProperty.Value.ToString());
break;
diff --git a/backend/LexCore/Auth/LexboxAudience.cs b/backend/LexCore/Auth/LexboxAudience.cs
index df9a8f046..a40c40bfe 100644
--- a/backend/LexCore/Auth/LexboxAudience.cs
+++ b/backend/LexCore/Auth/LexboxAudience.cs
@@ -12,11 +12,3 @@ public enum LexboxAudience
ForgotPassword,
SendAndReceive,
}
-
-public static class LexboxAudienceHelper
-{
- public static string PolicyName(this LexboxAudience audience, bool exclusive)
- {
- return $"RequireAudience{audience}{(exclusive ? "Exclusive" : "")}Policy";
- }
-}
diff --git a/backend/LexCore/Entities/EntityBase.cs b/backend/LexCore/Entities/EntityBase.cs
index 4d4de8212..a7d268c71 100644
--- a/backend/LexCore/Entities/EntityBase.cs
+++ b/backend/LexCore/Entities/EntityBase.cs
@@ -5,4 +5,5 @@ public class EntityBase
public Guid Id { get; init; }
public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedDate { get; set; } = DateTimeOffset.UtcNow;
+ public void UpdateUpdatedDate() => UpdatedDate = DateTimeOffset.UtcNow;
}
diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs
index 26fb4c8a6..f0ad43998 100644
--- a/backend/Testing/ApiTests/ApiTestBase.cs
+++ b/backend/Testing/ApiTests/ApiTestBase.cs
@@ -10,7 +10,7 @@ namespace Testing.ApiTests;
public class ApiTestBase
{
- public readonly string BaseUrl = TestingEnvironmentVariables.ServerBaseUrl;
+ public string BaseUrl => TestingEnvironmentVariables.ServerBaseUrl;
private readonly HttpClientHandler _httpClientHandler = new();
public readonly HttpClient HttpClient;
diff --git a/backend/Testing/Browser/Base/PageTest.cs b/backend/Testing/Browser/Base/PageTest.cs
index 432873744..14fde9f4d 100644
--- a/backend/Testing/Browser/Base/PageTest.cs
+++ b/backend/Testing/Browser/Base/PageTest.cs
@@ -152,8 +152,11 @@ protected async Task RegisterUser(string name, string ema
private async Task GetCurrentUserId()
{
var userResponse = await Page.APIRequest.GetAsync($"{TestingEnvironmentVariables.ServerBaseUrl}/api/user/currentUser");
- var user = await userResponse.JsonAsync();
- return user.ShouldNotBeNull().Id;
+
+ //can't configure playwright json deserialize options to set enum serialization
+ //so just use JsonElement and pull out the Id
+ var user = await userResponse.JsonAsync();
+ return user.ShouldNotBeNull().GetProperty(LexAuthConstants.IdClaimType).GetGuid();
}
}
diff --git a/backend/Testing/Browser/EmailWorkflowTests.cs b/backend/Testing/Browser/EmailWorkflowTests.cs
index 5e0ef5575..069527466 100644
--- a/backend/Testing/Browser/EmailWorkflowTests.cs
+++ b/backend/Testing/Browser/EmailWorkflowTests.cs
@@ -1,4 +1,5 @@
-using Testing.Browser.Base;
+using Shouldly;
+using Testing.Browser.Base;
using Testing.Browser.Page;
using Testing.Browser.Page.External;
@@ -81,6 +82,8 @@ public async Task ForgotPassword()
// Step: Use reset password link
var inboxPage = await MailInboxPage.Get(Page, mailinatorId).Goto();
var emailPage = await inboxPage.OpenEmail();
+ var resetPasswordUrl = await emailPage.GetFirstLanguageDepotUrl();
+ resetPasswordUrl.ShouldNotBeNull().ShouldContain("resetpassword");
var newPage = await Page.Context.RunAndWaitForPageAsync(emailPage.ClickResetPassword);
var resetPasswordPage = await new ResetPasswordPage(newPage).WaitFor();
@@ -94,5 +97,10 @@ public async Task ForgotPassword()
await loginPage.FillForm(email, newPassword);
await loginPage.Submit();
await userDashboardPage.WaitFor();
+
+ // Step: Verify email link has expired
+ await Page.GotoAsync(resetPasswordUrl);
+ loginPage = await new LoginPage(Page).WaitFor();
+ await Expect(loginPage.Page.GetByText("The email you clicked has expired")).ToBeVisibleAsync();
}
}
diff --git a/backend/Testing/Browser/Page/BasePage.cs b/backend/Testing/Browser/Page/BasePage.cs
index d1d802172..838a7cb18 100644
--- a/backend/Testing/Browser/Page/BasePage.cs
+++ b/backend/Testing/Browser/Page/BasePage.cs
@@ -46,13 +46,16 @@ public async Task WaitFor()
{
if (UrlPattern is not null)
{
+ //assert to get a good error message
+ await Assertions.Expect(Page).ToHaveURLAsync(UrlPattern);
+ //still wait to make sure we reach the same state we expect
await Page.WaitForURLAsync(UrlPattern, new() { WaitUntil = WaitUntilState.Load });
}
else
{
await Page.WaitForLoadStateAsync(LoadState.Load);
}
- await Task.WhenAll(TestLocators.Select(l => l.WaitForAsync()));
+ await Task.WhenAll(TestLocators.Select(l => Assertions.Expect(l).ToBeVisibleAsync()));
return (T)this;
}
}
diff --git a/backend/Testing/LexCore/LexAuthUserTests.cs b/backend/Testing/LexCore/LexAuthUserTests.cs
index 170c5c618..f5cb33c72 100644
--- a/backend/Testing/LexCore/LexAuthUserTests.cs
+++ b/backend/Testing/LexCore/LexAuthUserTests.cs
@@ -32,6 +32,7 @@ static LexAuthUserTests()
Email = "test@test.com",
Role = UserRole.user,
Name = "test",
+ UpdatedDate = DateTimeOffset.Now.ToUnixTimeSeconds(),
Projects = new[] { new AuthUserProject(ProjectRole.Manager, Guid.NewGuid()) }
};
diff --git a/backend/Testing/Services/TestingEnvironmentVariables.cs b/backend/Testing/Services/TestingEnvironmentVariables.cs
index 94558be84..35b85f655 100644
--- a/backend/Testing/Services/TestingEnvironmentVariables.cs
+++ b/backend/Testing/Services/TestingEnvironmentVariables.cs
@@ -2,7 +2,7 @@ namespace Testing.Services;
public static class TestingEnvironmentVariables
{
- public static readonly string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost";
+ public static string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost";
public static readonly bool IsDev = ServerHostname.StartsWith("localhost");
public static string HttpScheme = IsDev ? "http://" : "https://";
public static string ServerBaseUrl => $"{HttpScheme}{ServerHostname}";
diff --git a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs
index 5c4681c5b..abbdfd50c 100644
--- a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs
+++ b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs
@@ -75,6 +75,7 @@ public async Task VerifyHgWorking()
{
string version = await _sendReceiveService.GetHgVersion();
version.ShouldStartWith("Mercurial Distributed SCM");
+ _output.WriteLine("Hg version: " + version);
HgRunner.Run("hg version", Environment.CurrentDirectory, 5, new XunitStringBuilderProgress(_output) {ShowVerbose = true});
HgRepository.GetEnvironmentReadinessMessage("en").ShouldBeNull();
}
diff --git a/deployment/base/hg-deployment.yaml b/deployment/base/hg-deployment.yaml
index a16a31b3e..4d1b55ebc 100644
--- a/deployment/base/hg-deployment.yaml
+++ b/deployment/base/hg-deployment.yaml
@@ -65,6 +65,13 @@ spec:
ports:
- containerPort: 8088
+ startupProbe:
+ httpGet:
+ path: /
+ port: 8088
+ failureThreshold: 30
+ periodSeconds: 10
+
volumeMounts:
- name: repos
mountPath: /var/hg/repos
diff --git a/deployment/production/ingress-config-prod.yaml b/deployment/production/ingress-config-prod.yaml
index 2d2c30de9..ae155b2b3 100644
--- a/deployment/production/ingress-config-prod.yaml
+++ b/deployment/production/ingress-config-prod.yaml
@@ -1,9 +1,9 @@
- op: replace
path: /spec/rules/0/host
- value: prod.languagedepot.org
+ value: languagedepot.org
- op: replace
path: /spec/rules/1/host
- value: hg-prod.languageforge.org
+ value: hg-public.languageforge.org
- op: replace
path: /spec/rules/2/host
value: resumable.languagedepot.org
@@ -13,9 +13,7 @@
- op: replace
path: /spec/tls/0/hosts
value:
- - prod.languagedepot.org
- languagedepot.org
- - hg-prod.languageforge.org
- resumable.languagedepot.org
- resumable.languageforge.org
- hg-public.languagedepot.org
@@ -37,11 +35,6 @@
name: lexbox
port:
name: http
-- op: add
- path: /spec/rules/-
- value:
- host: hg-public.languageforge.org
- http: *rule
- op: add
path: /spec/rules/-
value:
@@ -62,37 +55,3 @@
value:
host: admin.languageforge.org
http: *rule
-- op: add
- path: /spec/rules/-
- value:
- host: languagedepot.org
- http:
- paths:
- - path: /api
- pathType: Prefix
- backend:
- service:
- name: lexbox
- port:
- name: http
- - path: /hg
- pathType: Prefix
- backend:
- service:
- name: lexbox
- port:
- name: http
- - path: /v1/traces
- pathType: Prefix
- backend:
- service:
- name: lexbox
- port:
- name: otel
- - path: /
- pathType: Prefix
- backend:
- service:
- name: ui
- port:
- name: sveltekit
diff --git a/deployment/production/lexbox-deployment.patch.yaml b/deployment/production/lexbox-deployment.patch.yaml
index 42c22e35d..3f903858e 100644
--- a/deployment/production/lexbox-deployment.patch.yaml
+++ b/deployment/production/lexbox-deployment.patch.yaml
@@ -24,7 +24,7 @@ spec:
# TODO: need to parameterize this
value: "Language Depot "
- name: Email__BaseUrl
- value: "https://prod.languagedepot.org"
+ value: "https://languagedepot.org"
- name: HgConfig__PublicRedmineHgWebUrl
value: "https://hg-public-redmine.languagedepot.org/"
- name: HgConfig__PrivateRedmineHgWebUrl
diff --git a/frontend/schema.graphql b/frontend/schema.graphql
index aa8f291fa..72061393d 100644
--- a/frontend/schema.graphql
+++ b/frontend/schema.graphql
@@ -91,6 +91,7 @@ type IsAdminResponse {
type LexAuthUser {
id: UUID!
+ updatedDate: DateTime!
audience: LexboxAudience!
email: String!
name: String!
diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte
index fc90feb36..ed9b61071 100644
--- a/frontend/src/lib/components/IconButton.svelte
+++ b/frontend/src/lib/components/IconButton.svelte
@@ -9,6 +9,9 @@
export let active = false;
export let join = false;
export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline';
+ export let active = false;
+ export let join = false;
+