diff --git a/Crypter.API/AssemblyInfo.cs b/Crypter.API/AssemblyInfo.cs
new file mode 100644
index 000000000..c633c25d6
--- /dev/null
+++ b/Crypter.API/AssemblyInfo.cs
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using Immediate.Handlers.Shared;
+
+[assembly: ImmediateAssemblyIdentifier("CrypterApi")]
diff --git a/Crypter.API/Controllers/UserContactController.cs b/Crypter.API/Controllers/UserContactController.cs
deleted file mode 100644
index 269edad56..000000000
--- a/Crypter.API/Controllers/UserContactController.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright (C) 2023 Crypter File Transfer
- *
- * This file is part of the Crypter file transfer project.
- *
- * Crypter is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * The Crypter source code is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * You can be released from the requirements of the aforementioned license
- * by purchasing a commercial license. Buying such a license is mandatory
- * as soon as you develop commercial activities involving the Crypter source
- * code without disclosing the source code of your own applications.
- *
- * Contact the current copyright holder to discuss commercial license options.
- */
-
-using System.Collections.Generic;
-using System.Net;
-using System.Threading;
-using System.Threading.Tasks;
-using Crypter.API.Controllers.Base;
-using Crypter.Common.Contracts;
-using Crypter.Common.Contracts.Features.Contacts;
-using Crypter.Core.Features.UserContacts.Commands;
-using Crypter.Core.Features.UserContacts.Queries;
-using EasyMonads;
-using MediatR;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Crypter.API.Controllers;
-
-[ApiController]
-[Route("api/user/contact")]
-public class UserContactController : CrypterControllerBase
-{
- private readonly ISender _sender;
-
- public UserContactController(ISender sender)
- {
- _sender = sender;
- }
-
- ///
- /// Get a list of user contacts.
- ///
- ///
- ///
- [HttpGet]
- [Authorize]
- [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))]
- [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))]
- public async Task GetUserContactsAsync(CancellationToken cancellationToken)
- {
- GetUserContactsQuery request = new GetUserContactsQuery(UserId);
- List result = await _sender.Send(request, cancellationToken);
- return Ok(result);
- }
-
- ///
- /// Add a user as a contact.
- ///
- ///
- ///
- [HttpPost]
- [Authorize]
- [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserContact))]
- [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))]
- [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
- [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
- [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(ErrorResponse))]
- public async Task AddUserContactAsync([FromQuery] string username)
- {
- IActionResult MakeErrorResponse(AddUserContactError error)
- {
-#pragma warning disable CS8524
- return error switch
- {
- AddUserContactError.UnknownError => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error),
- AddUserContactError.NotFound => MakeErrorResponseBase(HttpStatusCode.NotFound, error),
- AddUserContactError.InvalidUser => MakeErrorResponseBase(HttpStatusCode.BadRequest, error)
- };
-#pragma warning restore CS8524
- }
-
- AddUserContactCommand request = new AddUserContactCommand(UserId, username);
- return await _sender.Send(request)
- .MatchAsync(
- MakeErrorResponse,
- Ok,
- MakeErrorResponse(AddUserContactError.UnknownError));
- }
-
- ///
- /// Remove a user from contacts.
- ///
- ///
- ///
- [HttpDelete]
- [Authorize]
- [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(void))]
- [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))]
- public async Task RemoveUserContactAsync([FromQuery] string username)
- {
- RemoveUserContactCommand request = new RemoveUserContactCommand(UserId, username);
- await _sender.Send(request);
- return Ok();
- }
-}
diff --git a/Crypter.API/Crypter.API.csproj b/Crypter.API/Crypter.API.csproj
index 5b38112ab..525a54c53 100644
--- a/Crypter.API/Crypter.API.csproj
+++ b/Crypter.API/Crypter.API.csproj
@@ -25,6 +25,8 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs
new file mode 100644
index 000000000..23baac08f
--- /dev/null
+++ b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Crypter.API.Methods;
+using Crypter.Common.Contracts;
+using Crypter.Common.Contracts.Features.Contacts;
+using Crypter.Core.Features.UserContacts.Commands;
+using EasyMonads;
+using Immediate.Apis.Shared;
+using Immediate.Handlers.Shared;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Crypter.API.Endpoints.UserContacts;
+
+[Handler]
+[MapPost("api/user/contact")]
+[Authorize]
+public static partial class AddUserContactEndpoint
+{
+ public sealed record Request
+ {
+ [FromQuery]
+ public string? Username { get; init; }
+ }
+
+ internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) =>
+ endpoint
+ .WithSummary("Add a user as a contact.")
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status404NotFound)
+ .Produces(StatusCodes.Status500InternalServerError);
+
+ private static async ValueTask HandleAsync(
+ [AsParameters] Request request,
+ IHttpContextAccessor httpContextAccessor,
+ AddUserContactCommand.Handler handler,
+ CancellationToken cancellationToken)
+ {
+ Guid userId = EndpointUser.ParseUserId(httpContextAccessor);
+ AddUserContactCommand.Command command = new AddUserContactCommand.Command(userId, request.Username);
+
+ Either result = await handler.HandleAsync(command, cancellationToken);
+ return result.Match(
+ MakeErrorResponse,
+ x => Results.Ok(x),
+ MakeErrorResponse(AddUserContactError.UnknownError));
+ }
+
+ private static IResult MakeErrorResponse(AddUserContactError error)
+ {
+#pragma warning disable CS8524
+ return error switch
+ {
+ AddUserContactError.UnknownError => EndpointResults.MakeErrorResponse(HttpStatusCode.InternalServerError, error),
+ AddUserContactError.NotFound => EndpointResults.MakeErrorResponse(HttpStatusCode.NotFound, error),
+ AddUserContactError.InvalidUser => EndpointResults.MakeErrorResponse(HttpStatusCode.BadRequest, error)
+ };
+#pragma warning restore CS8524
+ }
+}
diff --git a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs
new file mode 100644
index 000000000..734763913
--- /dev/null
+++ b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Crypter.API.Methods;
+using Crypter.Common.Contracts.Features.Contacts;
+using Crypter.Core.Features.UserContacts.Queries;
+using Immediate.Apis.Shared;
+using Immediate.Handlers.Shared;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+namespace Crypter.API.Endpoints.UserContacts;
+
+[Handler]
+[MapGet("api/user/contact")]
+[Authorize]
+public static partial class GetUserContactsEndpoint
+{
+ public sealed record Request;
+
+ internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) =>
+ endpoint
+ .WithSummary("Get a list of user contacts.")
+ .Produces>(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized);
+
+ private static async ValueTask HandleAsync(
+ Request request,
+ IHttpContextAccessor httpContextAccessor,
+ GetUserContactsQuery.Handler handler,
+ CancellationToken cancellationToken)
+ {
+ Guid userId = EndpointUser.ParseUserId(httpContextAccessor);
+ List result = await handler.HandleAsync(new GetUserContactsQuery.Query(userId), cancellationToken);
+ return Results.Ok(result);
+ }
+}
diff --git a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs
new file mode 100644
index 000000000..6b47a074f
--- /dev/null
+++ b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Crypter.API.Methods;
+using Crypter.Common.Contracts;
+using Crypter.Common.Contracts.Features.Contacts;
+using Crypter.Core.Features.UserContacts.Commands;
+using EasyMonads;
+using Immediate.Apis.Shared;
+using Immediate.Handlers.Shared;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Crypter.API.Endpoints.UserContacts;
+
+[Handler]
+[MapDelete("api/user/contact")]
+[Authorize]
+public static partial class RemoveUserContactEndpoint
+{
+ public sealed record Request
+ {
+ [FromQuery]
+ public string? Username { get; init; }
+ }
+
+ internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) =>
+ endpoint
+ .WithSummary("Remove a user from contacts.")
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status500InternalServerError);
+
+ private static async ValueTask HandleAsync(
+ [AsParameters] Request request,
+ IHttpContextAccessor httpContextAccessor,
+ RemoveUserContactCommand.Handler handler,
+ CancellationToken cancellationToken)
+ {
+ Guid userId = EndpointUser.ParseUserId(httpContextAccessor);
+ RemoveUserContactCommand.Command command = new RemoveUserContactCommand.Command(userId, request.Username);
+
+ Either result = await handler.HandleAsync(command, cancellationToken);
+ return result.Match(
+ MakeErrorResponse,
+ _ => Results.Ok(),
+ MakeErrorResponse(RemoveUserContactError.UnknownError));
+ }
+
+ private static IResult MakeErrorResponse(RemoveUserContactError error)
+ {
+#pragma warning disable CS8524
+ return error switch
+ {
+ RemoveUserContactError.UnknownError => EndpointResults.MakeErrorResponse(HttpStatusCode.InternalServerError, error),
+ RemoveUserContactError.InvalidUser => EndpointResults.MakeErrorResponse(HttpStatusCode.BadRequest, error)
+ };
+#pragma warning restore CS8524
+ }
+}
diff --git a/Crypter.API/Methods/EndpointResults.cs b/Crypter.API/Methods/EndpointResults.cs
new file mode 100644
index 000000000..aa526ebd8
--- /dev/null
+++ b/Crypter.API/Methods/EndpointResults.cs
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using System;
+using System.Net;
+using Crypter.Common.Contracts;
+using Microsoft.AspNetCore.Http;
+
+namespace Crypter.API.Methods;
+
+internal static class EndpointResults
+{
+ internal static IResult MakeErrorResponse(HttpStatusCode httpStatus, Enum errorCode)
+ {
+ ErrorResponse errorResponse = new ErrorResponse((int)httpStatus, errorCode);
+ return Results.Json(errorResponse, statusCode: (int)httpStatus);
+ }
+}
diff --git a/Crypter.API/Methods/EndpointUser.cs b/Crypter.API/Methods/EndpointUser.cs
new file mode 100644
index 000000000..33435899b
--- /dev/null
+++ b/Crypter.API/Methods/EndpointUser.cs
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+using System;
+using Crypter.Core.Services;
+using Microsoft.AspNetCore.Http;
+
+namespace Crypter.API.Methods;
+
+internal static class EndpointUser
+{
+ internal static Guid ParseUserId(IHttpContextAccessor httpContextAccessor)
+ {
+ return TokenService.ParseUserId(httpContextAccessor.HttpContext!.User);
+ }
+}
diff --git a/Crypter.API/Program.cs b/Crypter.API/Program.cs
index 36967f517..d39e6220a 100644
--- a/Crypter.API/Program.cs
+++ b/Crypter.API/Program.cs
@@ -27,6 +27,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
+using Crypter.API;
using Crypter.API.Configuration;
using Crypter.API.MetadataProviders;
using Crypter.API.Middleware;
@@ -86,6 +87,8 @@
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddCors();
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddCrypterApiHandlers();
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
@@ -151,6 +154,7 @@
app.UseAuthorization();
app.UseMiddleware();
app.MapControllers();
+app.MapCrypterApiEndpoints();
await app.MigrateDatabaseAsync();
app.ScheduleRecurringReports();
diff --git a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs
index 630ca893a..46bd6169a 100644
--- a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs
+++ b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs
@@ -206,13 +206,11 @@ public async Task> PostEitherUnitResponseAsync> DeleteUnitResponseAsync(string uri)
+ public async Task> DeleteEitherUnitResponseAsync(string uri)
{
Func requestFactory = MakeRequestMessageFactory(HttpMethod.Delete, uri);
using HttpResponseMessage response = await SendWithAuthenticationAsync(requestFactory, false);
- return response.IsSuccessStatusCode
- ? Unit.Default
- : Maybe.None;
+ return await DeserializeEitherUnitResponseAsync(response);
}
public async Task> SendAsync(Func requestFactory)
diff --git a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs
index 0882d3f41..ba67e525d 100644
--- a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs
+++ b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs
@@ -116,13 +116,10 @@ public async Task> PostEitherUnitResponseAsync> DeleteUnitResponseAsync(string uri)
+ public async Task> DeleteEitherUnitResponseAsync(string uri)
{
- using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri);
- using HttpResponseMessage response = await _httpClient.SendAsync(request);
- return response.IsSuccessStatusCode
- ? Unit.Default
- : Maybe.None;
+ using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, uri);
+ return await SendRequestEitherUnitResponseAsync(request);
}
public async Task> SendAsync(Func requestFactory)
diff --git a/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs
index bc387b301..6dcf977d5 100644
--- a/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs
+++ b/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs
@@ -55,9 +55,10 @@ public Task> AddUserContactAsync(string
.ExtractErrorCode();
}
- public Task> RemoveUserContactAsync(string contactUsername)
+ public Task> RemoveUserContactAsync(string contactUsername)
{
string url = $"api/user/contact?username={contactUsername}";
- return _crypterAuthenticatedHttpClient.DeleteUnitResponseAsync(url);
+ return _crypterAuthenticatedHttpClient.DeleteEitherUnitResponseAsync(url)
+ .ExtractErrorCode();
}
}
diff --git a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs
index 83958076d..ab227503b 100644
--- a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs
+++ b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs
@@ -58,7 +58,7 @@ Task> PostMaybeUnitResponseAsync(string uri, TRequest body
Task> PostEitherUnitResponseAsync(string uri, TRequest body)
where TRequest : class;
- Task> DeleteUnitResponseAsync(string uri);
+ Task> DeleteEitherUnitResponseAsync(string uri);
Task> SendAsync(Func requestFactory)
where TResponse : class;
diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs
index fee8cc5ae..b6c62d300 100644
--- a/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs
+++ b/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs
@@ -35,5 +35,5 @@ public interface IUserContactRequests
{
Task>> GetUserContactsAsync();
Task> AddUserContactAsync(string contactUsername);
- Task> RemoveUserContactAsync(string contactUsername);
+ Task> RemoveUserContactAsync(string contactUsername);
}
diff --git a/Crypter.Common.Client/Services/UserContactsService.cs b/Crypter.Common.Client/Services/UserContactsService.cs
index 3f87de5ed..61a1d82fc 100644
--- a/Crypter.Common.Client/Services/UserContactsService.cs
+++ b/Crypter.Common.Client/Services/UserContactsService.cs
@@ -83,8 +83,9 @@ public async Task RemoveContactAsync(string contactUsername)
{
await LoadContactsAsync();
string lowerContactUsername = contactUsername.ToLower();
- Maybe response = await _crypterApiClient.UserContact.RemoveUserContactAsync(lowerContactUsername);
- response.IfSome(_ => _contacts!.Remove(lowerContactUsername));
+ Either response =
+ await _crypterApiClient.UserContact.RemoveUserContactAsync(lowerContactUsername);
+ response.DoRight(_ => _contacts!.Remove(lowerContactUsername));
}
private async Task> FetchContactsAsync()
diff --git a/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs b/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs
new file mode 100644
index 000000000..2bca35937
--- /dev/null
+++ b/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 Crypter File Transfer
+ *
+ * This file is part of the Crypter file transfer project.
+ *
+ * Crypter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Crypter source code is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * You can be released from the requirements of the aforementioned license
+ * by purchasing a commercial license. Buying such a license is mandatory
+ * as soon as you develop commercial activities involving the Crypter source
+ * code without disclosing the source code of your own applications.
+ *
+ * Contact the current copyright holder to discuss commercial license options.
+ */
+
+namespace Crypter.Common.Contracts.Features.Contacts;
+
+public enum RemoveUserContactError
+{
+ UnknownError,
+ InvalidUser
+}
diff --git a/Crypter.Core/AssemblyInfo.cs b/Crypter.Core/AssemblyInfo.cs
index aaf13f726..f983a8a77 100644
--- a/Crypter.Core/AssemblyInfo.cs
+++ b/Crypter.Core/AssemblyInfo.cs
@@ -24,6 +24,10 @@
* Contact the current copyright holder to discuss commercial license options.
*/
+using Immediate.Handlers.Shared;
+
+[assembly: ImmediateAssemblyIdentifier("CrypterCore")]
+
namespace Crypter.Core;
public class AssemblyInfo
diff --git a/Crypter.Core/Crypter.Core.csproj b/Crypter.Core/Crypter.Core.csproj
index e0fb8063e..5c3717d42 100644
--- a/Crypter.Core/Crypter.Core.csproj
+++ b/Crypter.Core/Crypter.Core.csproj
@@ -11,6 +11,7 @@
+
diff --git a/Crypter.Core/DependencyInjection.cs b/Crypter.Core/DependencyInjection.cs
index 240658b28..90ec39a40 100644
--- a/Crypter.Core/DependencyInjection.cs
+++ b/Crypter.Core/DependencyInjection.cs
@@ -60,6 +60,7 @@ public static IServiceCollection AddCrypterCore(this IServiceCollection services
services.AddDataAccess(defaultConnectionString);
services.AddMediatR(cfg => cfg
.RegisterServicesFromAssemblyContaining(typeof(AssemblyInfo)));
+ services.AddCrypterCoreHandlers();
services.TryAddSingleton();
services.TryAddSingleton();
diff --git a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs
index 50044e370..2f6cc900a 100644
--- a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs
+++ b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs
@@ -29,33 +29,34 @@
using System.Threading;
using System.Threading.Tasks;
using Crypter.Common.Contracts.Features.Contacts;
+using Crypter.Common.Primitives;
using Crypter.Core.LinqExpressions;
-using Crypter.Core.MediatorMonads;
using Crypter.DataAccess;
using Crypter.DataAccess.Entities;
using EasyMonads;
+using Immediate.Handlers.Shared;
using Microsoft.EntityFrameworkCore;
namespace Crypter.Core.Features.UserContacts.Commands;
-public record AddUserContactCommand(Guid UserId, string ContactUsername)
- : IEitherRequest;
-
-internal class AddUserContactCommandHandler
- : IEitherRequestHandler
+[Handler]
+public static partial class AddUserContactCommand
{
- private readonly DataContext _dataContext;
+ public sealed record Command(Guid UserId, string? ContactUsername);
- public AddUserContactCommandHandler(DataContext dataContext)
+ private static async ValueTask> HandleAsync(
+ Command request,
+ DataContext dataContext,
+ CancellationToken cancellationToken)
{
- _dataContext = dataContext;
- }
+ if (!Username.TryFrom(request.ContactUsername!, out Username? validContactUsername))
+ {
+ return AddUserContactError.InvalidUser;
+ }
- public async Task> Handle(AddUserContactCommand request, CancellationToken cancellationToken)
- {
- string lowerContactUsername = request.ContactUsername.ToLower();
+ string lowerContactUsername = validContactUsername.Value.ToLower();
- var foundUser = await _dataContext.Users
+ var foundUser = await dataContext.Users
.Where(x => x.Username.ToLower() == lowerContactUsername)
.Where(LinqUserExpressions.UserPrivacyAllowsVisitor(request.UserId))
.Select(x => new { x.Id, x.Username, x.Profile!.Alias })
@@ -71,7 +72,7 @@ public async Task> Handle(AddUserContac
return AddUserContactError.InvalidUser;
}
- bool contactExists = await _dataContext.UserContacts
+ bool contactExists = await dataContext.UserContacts
.Where(x => x.OwnerId == request.UserId)
.Where(x => x.ContactId == foundUser.Id)
.AnyAsync(CancellationToken.None);
@@ -79,8 +80,8 @@ public async Task> Handle(AddUserContac
if (!contactExists)
{
UserContactEntity newContactEntity = new UserContactEntity(request.UserId, foundUser.Id);
- _dataContext.UserContacts.Add(newContactEntity);
- await _dataContext.SaveChangesAsync(CancellationToken.None);
+ dataContext.UserContacts.Add(newContactEntity);
+ await dataContext.SaveChangesAsync(CancellationToken.None);
}
return new UserContact(foundUser.Username, foundUser.Alias);
diff --git a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs
index 46a1418d9..bd1570a0d 100644
--- a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs
+++ b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs
@@ -28,36 +28,41 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Crypter.Common.Contracts.Features.Contacts;
+using Crypter.Common.Primitives;
using Crypter.DataAccess;
using Crypter.DataAccess.Entities;
using EasyMonads;
+using Immediate.Handlers.Shared;
using Microsoft.EntityFrameworkCore;
namespace Crypter.Core.Features.UserContacts.Commands;
-public sealed record RemoveUserContactCommand(Guid UserId, string ContactUsername) : MediatR.IRequest;
-
-internal class RemoveUserContactCommandHandler : MediatR.IRequestHandler
+[Handler]
+public static partial class RemoveUserContactCommand
{
- private readonly DataContext _dataContext;
+ public sealed record Command(Guid UserId, string? ContactUsername);
- public RemoveUserContactCommandHandler(DataContext dataContext)
+ private static async ValueTask> HandleAsync(
+ Command request,
+ DataContext dataContext,
+ CancellationToken cancellationToken)
{
- _dataContext = dataContext;
- }
+ if (!Username.TryFrom(request.ContactUsername!, out Username? validContactUsername))
+ {
+ return RemoveUserContactError.InvalidUser;
+ }
- public async Task Handle(RemoveUserContactCommand request, CancellationToken cancellationToken)
- {
- string lowerContactUsername = request.ContactUsername.ToLower();
+ string lowerContactUsername = validContactUsername.Value.ToLower();
- UserContactEntity? contactEntity = await _dataContext.UserContacts
+ UserContactEntity? contactEntity = await dataContext.UserContacts
.Where(x => x.OwnerId == request.UserId && x.Contact!.Username.ToLower() == lowerContactUsername)
.FirstOrDefaultAsync(CancellationToken.None);
if (contactEntity is not null)
{
- _dataContext.UserContacts.Remove(contactEntity);
- await _dataContext.SaveChangesAsync(CancellationToken.None);
+ dataContext.UserContacts.Remove(contactEntity);
+ await dataContext.SaveChangesAsync(CancellationToken.None);
}
return Unit.Default;
diff --git a/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs b/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs
index 5b24bdb5a..c9cbff196 100644
--- a/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs
+++ b/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs
@@ -34,31 +34,28 @@
using Crypter.Common.Enums;
using Crypter.DataAccess;
using Crypter.DataAccess.Entities;
-using MediatR;
+using Immediate.Handlers.Shared;
using Microsoft.EntityFrameworkCore;
namespace Crypter.Core.Features.UserContacts.Queries;
-public sealed record GetUserContactsQuery(Guid UserId) : IRequest>;
-
-internal class GetUserContactsQueryHandler : IRequestHandler>
+[Handler]
+public static partial class GetUserContactsQuery
{
- private readonly DataContext _dataContext;
+ public sealed record Query(Guid UserId);
- public GetUserContactsQueryHandler(DataContext dataContext)
- {
- _dataContext = dataContext;
- }
-
- public Task> Handle(GetUserContactsQuery request, CancellationToken cancellationToken)
+ private static async ValueTask> HandleAsync(
+ Query request,
+ DataContext dataContext,
+ CancellationToken cancellationToken)
{
- return _dataContext.UserContacts
+ return await dataContext.UserContacts
.Where(x => x.OwnerId == request.UserId)
.Select(x => x.Contact)
.Select(ToUserContactDto(request.UserId))
.ToListAsync(cancellationToken);
}
-
+
private static Expression> ToUserContactDto(Guid? visitorId)
{
return x => x != null
diff --git a/Crypter.Test/Integration_Tests/TestMethods.cs b/Crypter.Test/Integration_Tests/TestMethods.cs
index 9859b77c1..b657cd468 100644
--- a/Crypter.Test/Integration_Tests/TestMethods.cs
+++ b/Crypter.Test/Integration_Tests/TestMethods.cs
@@ -24,14 +24,18 @@
* Contact the current copyright holder to discuss commercial license options.
*/
+using System.Net.Http;
+using System.Net.Http.Headers;
using System.Threading.Tasks;
using Crypter.Common.Client.Interfaces.HttpClients;
using Crypter.Common.Client.Interfaces.Repositories;
+using Crypter.Common.Client.Models;
using Crypter.Common.Contracts.Features.Transfer;
using Crypter.Common.Contracts.Features.UserAuthentication;
using Crypter.Common.Enums;
using Crypter.Crypto.Providers.Default;
using EasyMonads;
+using Microsoft.AspNetCore.Mvc.Testing;
using NUnit.Framework;
namespace Crypter.Test.Integration_Tests;
@@ -53,6 +57,21 @@ await loginResult.DoRightAsync(async loginResponse =>
});
}
+ ///
+ /// Creates an HttpClient carrying the stored authentication token, for tests that need to
+ /// send a request the typed API client cannot express.
+ ///
+ internal static async Task CreateAuthenticatedHttpClientAsync(
+ WebApplicationFactory factory, ITokenRepository tokenRepository)
+ {
+ HttpClient httpClient = factory.CreateClient();
+ Maybe authenticationToken = await tokenRepository.GetAuthenticationTokenAsync();
+ authenticationToken.IfSome(x =>
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", x.Token));
+
+ return httpClient;
+ }
+
internal static async Task InitiateMultipartFileTransferAsync(ICrypterApiClient apiClient, ITokenRepository tokenRepository)
{
await LoginAsync(apiClient, tokenRepository);
diff --git a/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs b/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs
index 7b969be79..c0f238535 100644
--- a/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs
+++ b/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs
@@ -24,9 +24,13 @@
* Contact the current copyright holder to discuss commercial license options.
*/
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
using System.Threading.Tasks;
using Crypter.Common.Client.Interfaces.HttpClients;
using Crypter.Common.Client.Interfaces.Repositories;
+using Crypter.Common.Contracts;
using Crypter.Common.Contracts.Features.Contacts;
using Crypter.Common.Contracts.Features.UserAuthentication;
using Crypter.Common.Enums;
@@ -113,6 +117,9 @@ await userLoginResult.DoRightAsync(async loginResponse =>
Assert.That(userRegistrationResult.IsRight, Is.True);
Assert.That(userLoginResult.IsRight, Is.True);
Assert.That(result.IsLeft, Is.True);
+ result.DoLeftOrNeither(
+ left: error => Assert.That(error, Is.EqualTo(AddUserContactError.InvalidUser)),
+ neither: Assert.Fail);
}
[TestCase]
@@ -136,5 +143,40 @@ await userLoginResult.DoRightAsync(async loginResponse =>
Assert.That(userRegistrationResult.IsRight, Is.True);
Assert.That(userLoginResult.IsRight, Is.True);
Assert.That(result.IsLeft, Is.True);
+ result.DoLeftOrNeither(
+ left: error => Assert.That(error, Is.EqualTo(AddUserContactError.NotFound)),
+ neither: Assert.Fail);
+ }
+
+ [Test]
+ public async Task Add_User_Contact_Fails_For_Absent_Username_Parameter_Async()
+ {
+ await TestMethods.LoginAsync(_client!, _clientTokenRepository!);
+ using HttpClient httpClient =
+ await TestMethods.CreateAuthenticatedHttpClientAsync(_factory!, _clientTokenRepository!);
+
+ using HttpResponseMessage response = await httpClient.PostAsync("api/user/contact", null);
+ ErrorResponse? errorResponse = await response.Content.ReadFromJsonAsync();
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(errorResponse, Is.Not.Null);
+ Assert.That(errorResponse!.Errors, Has.Count.EqualTo(1));
+ Assert.That(errorResponse.Errors[0].ErrorCode, Is.EqualTo((int)AddUserContactError.InvalidUser));
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("no spaces allowed")]
+ [TestCase("bad*characters")]
+ public async Task Add_User_Contact_Fails_For_Invalid_Username_Async(string contactUsername)
+ {
+ await TestMethods.LoginAsync(_client!, _clientTokenRepository!);
+
+ Either result = await _client!.UserContact.AddUserContactAsync(contactUsername);
+
+ Assert.That(result.IsLeft, Is.True);
+ result.DoLeftOrNeither(
+ left: error => Assert.That(error, Is.EqualTo(AddUserContactError.InvalidUser)),
+ neither: Assert.Fail);
}
}
diff --git a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs
index e08372ad4..675afebd2 100644
--- a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs
+++ b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs
@@ -25,9 +25,13 @@
*/
using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
using System.Threading.Tasks;
using Crypter.Common.Client.Interfaces.HttpClients;
using Crypter.Common.Client.Interfaces.Repositories;
+using Crypter.Common.Contracts;
using Crypter.Common.Contracts.Features.Contacts;
using Crypter.Common.Contracts.Features.UserAuthentication;
using Crypter.Common.Enums;
@@ -90,7 +94,7 @@ await userLoginResult.DoRightAsync(async loginResponse =>
Either addContactResult = await _client!.UserContact.AddUserContactAsync(contactUsername);
Maybe> secondContactsResult = await _client!.UserContact.GetUserContactsAsync();
- Maybe removeContactResult = await _client!.UserContact.RemoveUserContactAsync(contactUsername);
+ Either removeContactResult = await _client!.UserContact.RemoveUserContactAsync(contactUsername);
Maybe> finalContactsResult = await _client!.UserContact.GetUserContactsAsync();
Assert.That(userRegistrationResult.IsRight, Is.True);
@@ -107,8 +111,50 @@ await userLoginResult.DoRightAsync(async loginResponse =>
Assert.That(x[0].Username, Is.EqualTo(contactUsername));
});
- Assert.That(removeContactResult.IsSome, Is.True);
+ Assert.That(removeContactResult.IsRight, Is.True);
Assert.That(finalContactsResult.IsSome, Is.True);
finalContactsResult.IfSome(x => Assert.That(x, Is.Empty));
}
+
+ [Test]
+ public async Task Remove_User_Contact_Fails_For_Absent_Username_Parameter_Async()
+ {
+ await TestMethods.LoginAsync(_client!, _clientTokenRepository!);
+ using HttpClient httpClient =
+ await TestMethods.CreateAuthenticatedHttpClientAsync(_factory!, _clientTokenRepository!);
+
+ using HttpResponseMessage response = await httpClient.DeleteAsync("api/user/contact");
+ ErrorResponse? errorResponse = await response.Content.ReadFromJsonAsync();
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(errorResponse, Is.Not.Null);
+ Assert.That(errorResponse!.Errors, Has.Count.EqualTo(1));
+ Assert.That(errorResponse.Errors[0].ErrorCode, Is.EqualTo((int)RemoveUserContactError.InvalidUser));
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("no spaces allowed")]
+ [TestCase("bad*characters")]
+ public async Task Remove_User_Contact_Fails_For_Invalid_Username_Async(string contactUsername)
+ {
+ await TestMethods.LoginAsync(_client!, _clientTokenRepository!);
+
+ Either result = await _client!.UserContact.RemoveUserContactAsync(contactUsername);
+
+ Assert.That(result.IsLeft, Is.True);
+ result.DoLeftOrNeither(
+ left: error => Assert.That(error, Is.EqualTo(RemoveUserContactError.InvalidUser)),
+ neither: Assert.Fail);
+ }
+
+ [Test]
+ public async Task Remove_User_Contact_Succeeds_For_Absent_Contact_Async()
+ {
+ await TestMethods.LoginAsync(_client!, _clientTokenRepository!);
+
+ Either result = await _client!.UserContact.RemoveUserContactAsync("Tom_Bombadil");
+
+ Assert.That(result.IsRight, Is.True);
+ }
}