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); + } }