Add Ping backend
.NET 10 solution for Ping, a Discord style chat app: Domain, Application and Infrastructure projects plus the Api host. SignalR for chat and presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible storage for attachments, and OIDC auth shared with the Musify Zitadel instance. Includes the Compose stack for local development.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Ping.Api.Authentication;
|
||||
|
||||
public sealed class AuthenticationConfiguration
|
||||
{
|
||||
public const string SectionName = "Authentication";
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string MetadataAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string IssuerAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string AudienceAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string AuthorizationEndpoint { get; set; }
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string TokenEndpoint { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string[] Scopes { get; set; }
|
||||
|
||||
public string? ScalarRedirectUri { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Ping.Api.Authentication;
|
||||
|
||||
public static class AuthenticationExtensions
|
||||
{
|
||||
public static IServiceCollection AddAuthenticationConfiguration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services
|
||||
.AddOptionsWithValidateOnStart<AuthenticationConfiguration>()
|
||||
.Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
|
||||
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<JwtBearerEventsHandler>();
|
||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||
|
||||
services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
|
||||
services.AddAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Ping.Api.Authentication;
|
||||
|
||||
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
||||
{
|
||||
private CurrentUser(ClaimsPrincipal principal)
|
||||
{
|
||||
Principal = principal;
|
||||
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (long.TryParse(rawId, out var id) && id != 0)
|
||||
Id = id;
|
||||
|
||||
Username = principal.FindFirstValue(ClaimTypes.Name);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal Principal { get; }
|
||||
|
||||
public bool IsAuthenticated { get; }
|
||||
|
||||
public long? Id { get; }
|
||||
|
||||
public string? Username { get; }
|
||||
|
||||
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
|
||||
|
||||
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
|
||||
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Ping.Application.Users;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ping.Api.Authentication;
|
||||
|
||||
public sealed class JwtBearerEventsHandler(
|
||||
IMediator mediator,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AuthenticationConfiguration authenticationConfiguration,
|
||||
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
|
||||
{
|
||||
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
private sealed record UserInfoResponse(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("picture")] string? Picture);
|
||||
|
||||
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
|
||||
{
|
||||
var principal = tokenValidatedContext.Principal!;
|
||||
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(rawId, out var userId))
|
||||
{
|
||||
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = $"user-synced:{userId}";
|
||||
if (cache.TryGetValue(cacheKey, out _))
|
||||
return;
|
||||
|
||||
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
|
||||
|
||||
var username = userInfo?.PreferredUsername
|
||||
?? userInfo?.Name
|
||||
?? principal.FindFirstValue("preferred_username")
|
||||
?? principal.FindFirstValue("name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Name)
|
||||
?? userInfo?.Email
|
||||
?? principal.FindFirstValue("email")
|
||||
?? rawId!;
|
||||
|
||||
var displayName = userInfo?.Name ?? principal.FindFirstValue("name");
|
||||
var avatarUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
|
||||
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SyncUserCommand(userId, username, displayName, avatarUrl),
|
||||
tokenValidatedContext.HttpContext.RequestAborted);
|
||||
|
||||
cache.Set(cacheKey, true, UserSyncCacheTtl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UserInfoResponse?> FetchUserInfoAsync(
|
||||
TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString();
|
||||
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
|
||||
if (accessToken.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo")
|
||||
{
|
||||
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) },
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override Task AuthenticationFailed(AuthenticationFailedContext context)
|
||||
{
|
||||
logger.LogWarning(context.Exception, "JWT authentication failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task Challenge(JwtBearerChallengeContext context)
|
||||
{
|
||||
if (context.AuthenticateFailure is not null)
|
||||
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task MessageReceived(MessageReceivedContext context)
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
context.Token = accessToken;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Ping.Api.Authentication;
|
||||
|
||||
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<JwtBearerOptions>
|
||||
{
|
||||
private readonly AuthenticationConfiguration _configuration = options.Value;
|
||||
|
||||
public void Configure(string? name, JwtBearerOptions options)
|
||||
{
|
||||
if (name != JwtBearerDefaults.AuthenticationScheme)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Configure(options);
|
||||
}
|
||||
|
||||
public void Configure(JwtBearerOptions options)
|
||||
{
|
||||
options.MetadataAddress = _configuration.MetadataAddress;
|
||||
options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata;
|
||||
options.Audience = _configuration.AudienceAddress;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = _configuration.IssuerAddress,
|
||||
ValidAudience = _configuration.AudienceAddress,
|
||||
RoleClaimType = ClaimTypes.Role,
|
||||
NameClaimType = ClaimTypes.Name,
|
||||
};
|
||||
options.EventsType = typeof(JwtBearerEventsHandler);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
namespace Ping.Api.DataTransferObjects.Attachments;
|
||||
|
||||
public record RequestAttachmentUploadUrlRequest(string FileName, string ContentType);
|
||||
@@ -0,0 +1,5 @@
|
||||
using Ping.Domain.ValueObjects;
|
||||
|
||||
namespace Ping.Api.DataTransferObjects.Channels;
|
||||
|
||||
public record CreateChannelRequest(string Name, ChannelType Type);
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Ping.Application.Serialization;
|
||||
|
||||
namespace Ping.Api.DataTransferObjects.Friends;
|
||||
|
||||
public record SendFriendRequestRequest([property: JsonConverter(typeof(LongAsStringConverter))] long AddresseeId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Ping.Api.DataTransferObjects.Messages;
|
||||
|
||||
public record EditMessageRequest(string Content);
|
||||
@@ -0,0 +1,7 @@
|
||||
using Ping.Domain.ValueObjects;
|
||||
|
||||
namespace Ping.Api.DataTransferObjects.Messages;
|
||||
|
||||
public record MessageAttachmentRequest(string Key, string FileName, string ContentType, AttachmentKind Kind);
|
||||
|
||||
public record SendMessageRequest(string? Content, Guid? ReplyToMessageId, IReadOnlyList<MessageAttachmentRequest>? Attachments);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Ping.Api.DataTransferObjects.Servers;
|
||||
|
||||
public record CreateServerRequest(string Name);
|
||||
@@ -0,0 +1,31 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Attachments;
|
||||
using Ping.Api.Filters;
|
||||
using Ping.Application.Attachments;
|
||||
using Ping.Application.Attachments.Responses;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class AttachmentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAttachmentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/attachments")
|
||||
.WithTags("Attachments")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapPost("/upload-url", RequestUploadUrl)
|
||||
.WithName("RequestAttachmentUploadUrl")
|
||||
.WithSummary("Request A Presigned Url To Upload A File Or Voice Message Directly To Storage.")
|
||||
.AddEndpointFilter<ValidationFilter<RequestAttachmentUploadUrlRequest>>()
|
||||
.Produces<AttachmentUploadResponse>()
|
||||
.ProducesValidationProblem();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<AttachmentUploadResponse> RequestUploadUrl(
|
||||
IMediator mediator, CurrentUser currentUser, RequestAttachmentUploadUrlRequest request, CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new RequestAttachmentUploadUrlCommand(currentUser.RequiredId, request.FileName, request.ContentType), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Application.Calls;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class CallEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCallEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/channels/{channelId:guid}/call-token")
|
||||
.WithTags("Calls")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapPost("/", RequestCallToken)
|
||||
.WithName("RequestCallToken")
|
||||
.WithSummary("Request A Livekit Access Token To Join The Voice/Video Call For A Channel.")
|
||||
.Produces<Application.Calls.Responses.CallTokenResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestCallToken(
|
||||
IMediator mediator, CurrentUser currentUser, Guid channelId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new RequestCallTokenCommand(currentUser.RequiredId, channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Channels;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Api.Filters;
|
||||
using Ping.Application.Channels;
|
||||
using Ping.Application.Channels.Responses;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class ChannelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var servers = app.MapGroup("/servers/{serverId:guid}/channels")
|
||||
.WithTags("Channels")
|
||||
.RequireAuthorization();
|
||||
|
||||
servers.MapGet("/", GetServerChannels)
|
||||
.WithName("GetServerChannels")
|
||||
.WithSummary("Get A Server'S Channels.")
|
||||
.Produces<IEnumerable<ChannelResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
servers.MapPost("/", CreateChannel)
|
||||
.WithName("CreateChannel")
|
||||
.WithSummary("Create A Text Or Voice Channel In A Server.")
|
||||
.AddEndpointFilter<ValidationFilter<CreateChannelRequest>>()
|
||||
.Produces<ChannelResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
var directMessages = app.MapGroup("/channels/dm")
|
||||
.WithTags("Channels")
|
||||
.RequireAuthorization();
|
||||
|
||||
directMessages.MapPost("/{otherUserId:long}", GetOrCreateDirectMessageChannel)
|
||||
.WithName("GetOrCreateDirectMessageChannel")
|
||||
.WithSummary("Get Or Create A Direct Message Channel With A Friend.")
|
||||
.Produces<ChannelResponse>()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetServerChannels(
|
||||
IMediator mediator, CurrentUser currentUser, Guid serverId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetServerChannelsQuery(currentUser.RequiredId, serverId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateChannel(
|
||||
IMediator mediator, CurrentUser currentUser, Guid serverId, CreateChannelRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateChannelCommand(currentUser.RequiredId, serverId, request.Name, request.Type), cancellationToken);
|
||||
return result.ToCreatedResult(channel => $"/servers/{serverId}/channels/{channel.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOrCreateDirectMessageChannel(
|
||||
IMediator mediator, CurrentUser currentUser, long otherUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetOrCreateDirectMessageChannelCommand(currentUser.RequiredId, otherUserId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Friends;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Api.Filters;
|
||||
using Ping.Application.Friends;
|
||||
using Ping.Application.Friends.Responses;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class FriendEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapFriendEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/friends")
|
||||
.WithTags("Friends")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/", GetFriends)
|
||||
.WithName("GetFriends")
|
||||
.WithSummary("Get The Current User'S Accepted Friends.")
|
||||
.Produces<IEnumerable<FriendshipResponse>>();
|
||||
|
||||
group.MapGet("/requests", GetPendingFriendRequests)
|
||||
.WithName("GetPendingFriendRequests")
|
||||
.WithSummary("Get Incoming And Outgoing Pending Friend Requests.")
|
||||
.Produces<IEnumerable<FriendshipResponse>>();
|
||||
|
||||
group.MapPost("/requests", SendFriendRequest)
|
||||
.WithName("SendFriendRequest")
|
||||
.WithSummary("Send A Friend Request.")
|
||||
.AddEndpointFilter<ValidationFilter<SendFriendRequestRequest>>()
|
||||
.Produces<FriendshipResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapPost("/requests/{id:guid}/accept", AcceptFriendRequest)
|
||||
.WithName("AcceptFriendRequest")
|
||||
.WithSummary("Accept A Pending Friend Request.")
|
||||
.Produces<FriendshipResponse>()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/requests/{id:guid}/reject", RejectFriendRequest)
|
||||
.WithName("RejectFriendRequest")
|
||||
.WithSummary("Reject A Pending Friend Request.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{id:guid}", RemoveFriend)
|
||||
.WithName("RemoveFriend")
|
||||
.WithSummary("Remove An Existing Friend.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/block/{userId:long}", BlockUser)
|
||||
.WithName("BlockUser")
|
||||
.WithSummary("Block A User.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<FriendshipResponse>> GetFriends(
|
||||
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetFriendsQuery(currentUser.RequiredId), cancellationToken);
|
||||
|
||||
private static async Task<IEnumerable<FriendshipResponse>> GetPendingFriendRequests(
|
||||
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetPendingFriendRequestsQuery(currentUser.RequiredId), cancellationToken);
|
||||
|
||||
private static async Task<IResult> SendFriendRequest(
|
||||
IMediator mediator, CurrentUser currentUser, SendFriendRequestRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new SendFriendRequestCommand(currentUser.RequiredId, request.AddresseeId), cancellationToken);
|
||||
return result.ToCreatedResult(friendship => $"/friends/requests/{friendship.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcceptFriendRequest(
|
||||
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new AcceptFriendRequestCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectFriendRequest(
|
||||
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new RejectFriendRequestCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveFriend(
|
||||
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new RemoveFriendCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BlockUser(
|
||||
IMediator mediator, CurrentUser currentUser, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new BlockUserCommand(currentUser.RequiredId, userId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Messages;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Api.Filters;
|
||||
using Ping.Application.Messages;
|
||||
using Ping.Application.Messages.Responses;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class MessageEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapMessageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var channelMessages = app.MapGroup("/channels/{channelId:guid}/messages")
|
||||
.WithTags("Messages")
|
||||
.RequireAuthorization();
|
||||
|
||||
channelMessages.MapGet("/", GetChannelMessages)
|
||||
.WithName("GetChannelMessages")
|
||||
.WithSummary("Get A Channel'S Messages, Newest First, Optionally Before A Given Message.")
|
||||
.Produces<IEnumerable<MessageResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
channelMessages.MapPost("/", SendMessage)
|
||||
.WithName("SendMessage")
|
||||
.WithSummary("Send A Message To A Channel.")
|
||||
.AddEndpointFilter<ValidationFilter<SendMessageRequest>>()
|
||||
.Produces<MessageResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
var messages = app.MapGroup("/messages")
|
||||
.WithTags("Messages")
|
||||
.RequireAuthorization();
|
||||
|
||||
messages.MapPatch("/{id:guid}", EditMessage)
|
||||
.WithName("EditMessage")
|
||||
.WithSummary("Edit A Message'S Content.")
|
||||
.AddEndpointFilter<ValidationFilter<EditMessageRequest>>()
|
||||
.Produces<MessageResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
messages.MapDelete("/{id:guid}", DeleteMessage)
|
||||
.WithName("DeleteMessage")
|
||||
.WithSummary("Delete A Message (Soft Delete).")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChannelMessages(
|
||||
IMediator mediator, CurrentUser currentUser, Guid channelId, Guid? before, int take, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetChannelMessagesQuery(currentUser.RequiredId, channelId, before, take == 0 ? 50 : take), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SendMessage(
|
||||
IMediator mediator, CurrentUser currentUser, Guid channelId, SendMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = (request.Attachments ?? [])
|
||||
.Select(a => new PendingAttachment(a.Key, a.FileName, a.ContentType, a.Kind))
|
||||
.ToList();
|
||||
|
||||
var result = await mediator.Send(
|
||||
new SendMessageCommand(currentUser.RequiredId, channelId, request.Content ?? string.Empty, request.ReplyToMessageId, attachments),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(message => $"/channels/{channelId}/messages/{message.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> EditMessage(
|
||||
IMediator mediator, CurrentUser currentUser, Guid id, EditMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new EditMessageCommand(currentUser.RequiredId, id, request.Content), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteMessage(
|
||||
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeleteMessageCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Servers;
|
||||
using Ping.Api.Filters;
|
||||
using Ping.Application.Servers;
|
||||
using Ping.Application.Servers.Responses;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class ServerEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/servers")
|
||||
.WithTags("Servers")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/", GetUserServers)
|
||||
.WithName("GetUserServers")
|
||||
.WithSummary("Get The Servers The Current User Is A Member Of.")
|
||||
.Produces<IEnumerable<ServerResponse>>();
|
||||
|
||||
group.MapPost("/", CreateServer)
|
||||
.WithName("CreateServer")
|
||||
.WithSummary("Create A New Server, With The Current User As Owner.")
|
||||
.AddEndpointFilter<ValidationFilter<CreateServerRequest>>()
|
||||
.Produces<ServerResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<ServerResponse>> GetUserServers(
|
||||
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetUserServersQuery(currentUser.RequiredId), cancellationToken);
|
||||
|
||||
private static async Task<IResult> CreateServer(
|
||||
IMediator mediator, CurrentUser currentUser, CreateServerRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var server = await mediator.Send(new CreateServerCommand(currentUser.RequiredId, request.Name), cancellationToken);
|
||||
return Results.Created($"/servers/{server.Id}", server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Mediator;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Application.Users;
|
||||
|
||||
namespace Ping.Api.Endpoints;
|
||||
|
||||
public static class UserEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/users")
|
||||
.WithTags("Users")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/me", GetCurrentUser)
|
||||
.WithName("GetCurrentUser")
|
||||
.WithSummary("Get The Currently Authenticated User.")
|
||||
.Produces<Application.Users.Responses.UserResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}", GetUserById)
|
||||
.WithName("GetUserById")
|
||||
.WithSummary("Get A User By Id.")
|
||||
.Produces<Application.Users.Responses.UserResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/search", SearchUsers)
|
||||
.WithName("SearchUsers")
|
||||
.WithSummary("Search Users By Username, Excluding The Current User.")
|
||||
.Produces<IEnumerable<Application.Users.Responses.UserResponse>>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCurrentUser(IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserByIdQuery(currentUser.RequiredId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUserById(IMediator mediator, long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<Application.Users.Responses.UserResponse>> SearchUsers(
|
||||
IMediator mediator, CurrentUser currentUser, string query, CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new SearchUsersQuery(query, currentUser.RequiredId), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ErrorOr;
|
||||
using IResult = Microsoft.AspNetCore.Http.IResult;
|
||||
|
||||
namespace Ping.Api.Extensions;
|
||||
|
||||
public static class ErrorOrHttpExtensions
|
||||
{
|
||||
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
|
||||
{
|
||||
if (result.IsError)
|
||||
return Problem(result.Errors);
|
||||
|
||||
return result.Value is Success
|
||||
? Results.NoContent()
|
||||
: Results.Ok(result.Value);
|
||||
}
|
||||
|
||||
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
|
||||
result.IsError
|
||||
? Problem(result.Errors)
|
||||
: Results.Created(locationFactory(result.Value), result.Value);
|
||||
|
||||
private static IResult Problem(List<Error> errors)
|
||||
{
|
||||
if (errors.All(e => e.Type == ErrorType.Validation))
|
||||
return Results.ValidationProblem(ToValidationDictionary(errors));
|
||||
|
||||
var error = errors[0];
|
||||
return error.Type switch
|
||||
{
|
||||
ErrorType.NotFound => Results.NotFound(error.Description),
|
||||
ErrorType.Unauthorized => Results.Unauthorized(),
|
||||
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
|
||||
ErrorType.Conflict => Results.Conflict(error.Description),
|
||||
_ => Results.Problem(error.Description),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<Error> errors) =>
|
||||
errors
|
||||
.GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray());
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.Authentication;
|
||||
using Ping.Api.DataTransferObjects.Servers;
|
||||
using Ping.Api.Hubs;
|
||||
using Ping.Api.OpenApi;
|
||||
using Ping.Api.Scalar;
|
||||
using Ping.Application;
|
||||
using Ping.Application.Contracts;
|
||||
using Ping.Infrastructure.Persistence;
|
||||
using Ping.Infrastructure.Services;
|
||||
|
||||
namespace Ping.Api.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddApplication(configuration);
|
||||
services.AddDatabase(configuration);
|
||||
services.AddStorageService(configuration);
|
||||
services.AddCallTokenService(configuration);
|
||||
services.AddAuthenticationConfiguration(configuration);
|
||||
services.AddOpenApiConfiguration();
|
||||
services.AddScalarConfiguration();
|
||||
services.AddValidatorsFromAssemblyContaining<CreateServerRequest>();
|
||||
|
||||
services.AddSignalR();
|
||||
services.AddScoped<IRealtimeNotifier, RealtimeNotifier>();
|
||||
services.AddSingleton<PresenceTracker>();
|
||||
services.AddSingleton<IPresenceTracker>(sp => sp.GetRequiredService<PresenceTracker>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Ping.Api.Filters;
|
||||
|
||||
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||
|
||||
if (argument is null)
|
||||
return await next(context);
|
||||
|
||||
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||
|
||||
return result.IsValid
|
||||
? await next(context)
|
||||
: Results.ValidationProblem(result.ToDictionary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ping.Application.Contracts;
|
||||
using Ping.Application.Shared;
|
||||
using Ping.Domain.ValueObjects;
|
||||
|
||||
namespace Ping.Api.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class ChatHub(IDatabase database, PresenceTracker presence) : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (presence.AddConnection(GetUserId()))
|
||||
await BroadcastPresenceAsync(GetUserId());
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
if (presence.RemoveConnection(GetUserId()))
|
||||
await BroadcastPresenceAsync(GetUserId());
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
public async Task SetBusy(bool busy)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
presence.SetBusy(userId, busy);
|
||||
await BroadcastPresenceAsync(userId);
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceAsync(long userId)
|
||||
{
|
||||
var friendIds = await database.Friendships.AsNoTracking()
|
||||
.Where(f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId))
|
||||
.Select(f => f.RequesterId == userId ? f.AddresseeId : f.RequesterId)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
if (friendIds.Count == 0)
|
||||
return;
|
||||
|
||||
await Clients.Users(friendIds.Select(id => id.ToString()))
|
||||
.SendAsync("PresenceChanged", userId.ToString(), presence.GetStatus(userId).ToString());
|
||||
}
|
||||
|
||||
public async Task JoinChannel(Guid channelId)
|
||||
{
|
||||
var hasAccess = await database.HasAccessAsync(channelId, GetUserId(), Context.ConnectionAborted);
|
||||
if (!hasAccess)
|
||||
throw new HubException("Channel not found");
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(channelId));
|
||||
}
|
||||
|
||||
public Task LeaveChannel(Guid channelId) =>
|
||||
Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(channelId));
|
||||
|
||||
public Task Typing(Guid channelId) =>
|
||||
Clients.OthersInGroup(GroupName(channelId)).SendAsync("Typing", GetUserId(), channelId);
|
||||
|
||||
public async Task RingUser(Guid channelId, string calleeId)
|
||||
{
|
||||
var callerId = GetUserId();
|
||||
if (!long.TryParse(calleeId, out var calleeUserId))
|
||||
throw new HubException("Invalid user id");
|
||||
|
||||
var callerHasAccess = await database.HasAccessAsync(channelId, callerId, Context.ConnectionAborted);
|
||||
var calleeHasAccess = await database.HasAccessAsync(channelId, calleeUserId, Context.ConnectionAborted);
|
||||
if (!callerHasAccess || !calleeHasAccess)
|
||||
throw new HubException("Channel not found");
|
||||
|
||||
await Clients.User(calleeId).SendAsync("IncomingCall", channelId, callerId.ToString());
|
||||
}
|
||||
|
||||
public Task DeclineCall(Guid channelId, string callerId) =>
|
||||
Clients.User(callerId).SendAsync("CallDeclined", channelId);
|
||||
|
||||
public Task CancelCall(Guid channelId, string calleeId) =>
|
||||
Clients.User(calleeId).SendAsync("CallCancelled", channelId);
|
||||
|
||||
private long GetUserId()
|
||||
{
|
||||
var rawId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(rawId, out var id) ? id : throw new HubException("User Id claim is missing or invalid");
|
||||
}
|
||||
|
||||
internal static string GroupName(Guid channelId) => $"channel:{channelId}";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Ping.Application.Contracts;
|
||||
|
||||
namespace Ping.Api.Hubs;
|
||||
|
||||
public sealed class PresenceTracker : IPresenceTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, int> _connectionCounts = new();
|
||||
private readonly ConcurrentDictionary<long, byte> _busyUserIds = new();
|
||||
|
||||
public bool AddConnection(long userId) => _connectionCounts.AddOrUpdate(userId, 1, (_, count) => count + 1) == 1;
|
||||
|
||||
public bool RemoveConnection(long userId)
|
||||
{
|
||||
while (_connectionCounts.TryGetValue(userId, out var count))
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
if (!_connectionCounts.TryRemove(userId, out _))
|
||||
continue;
|
||||
|
||||
_busyUserIds.TryRemove(userId, out _);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_connectionCounts.TryUpdate(userId, count - 1, count))
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void SetBusy(long userId, bool busy)
|
||||
{
|
||||
if (busy)
|
||||
_busyUserIds[userId] = 0;
|
||||
else
|
||||
_busyUserIds.TryRemove(userId, out _);
|
||||
}
|
||||
|
||||
public PresenceStatus GetStatus(long userId)
|
||||
{
|
||||
if (!_connectionCounts.ContainsKey(userId))
|
||||
return PresenceStatus.Offline;
|
||||
|
||||
return _busyUserIds.ContainsKey(userId) ? PresenceStatus.Busy : PresenceStatus.Online;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Ping.Application.Contracts;
|
||||
using Ping.Application.Messages.Responses;
|
||||
|
||||
namespace Ping.Api.Hubs;
|
||||
|
||||
public sealed class RealtimeNotifier(IHubContext<ChatHub> hubContext) : IRealtimeNotifier
|
||||
{
|
||||
public Task MessageCreatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageCreated", message, cancellationToken);
|
||||
|
||||
public Task MessageUpdatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageUpdated", message, cancellationToken);
|
||||
|
||||
public Task MessageDeletedAsync(Guid channelId, Guid messageId, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageDeleted", messageId, cancellationToken);
|
||||
|
||||
public Task FriendRequestReceivedAsync(long addresseeId, Guid friendshipId, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.User(addresseeId.ToString()).SendAsync("FriendRequestReceived", friendshipId, cancellationToken);
|
||||
|
||||
public Task FriendshipUpdatedAsync(long userId, Guid friendshipId, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.User(userId.ToString()).SendAsync("FriendshipUpdated", friendshipId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Ping.Api.OpenApi;
|
||||
|
||||
public static class OpenApiExtensions
|
||||
{
|
||||
public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services.AddOpenApi();
|
||||
services.ConfigureOptions<OpenApiOptionsSetup>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Ping.Api.Authentication;
|
||||
|
||||
namespace Ping.Api.OpenApi;
|
||||
|
||||
public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<OpenApiOptions>
|
||||
{
|
||||
private const string SecuritySchemeId = "OAuth2";
|
||||
|
||||
private readonly AuthenticationConfiguration _configuration = options.Value;
|
||||
|
||||
public void Configure(string? name, OpenApiOptions options) => Configure(options);
|
||||
|
||||
public void Configure(OpenApiOptions options)
|
||||
{
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
document.Components ??= new OpenApiComponents();
|
||||
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||
document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.OAuth2,
|
||||
Flows = new OpenApiOAuthFlows
|
||||
{
|
||||
AuthorizationCode = new OpenApiOAuthFlow
|
||||
{
|
||||
AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint),
|
||||
TokenUrl = new Uri(_configuration.TokenEndpoint),
|
||||
Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
options.AddOperationTransformer((operation, context, _) =>
|
||||
{
|
||||
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAuthorizeData>()
|
||||
.Any();
|
||||
|
||||
var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAllowAnonymous>()
|
||||
.Any();
|
||||
|
||||
if (!requiresAuthorization || allowsAnonymous)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
|
||||
_configuration.Scopes.ToList(),
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Ping.Api</AssemblyName>
|
||||
<RootNamespace>Ping.Api</RootNamespace>
|
||||
<UserSecretsId>ed416956-761d-4ded-8424-fc612ea6803c</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ErrorOr" />
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||
<PackageReference Include="Mediator.Abstractions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.OpenApi" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Ping.Application\Ping.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Ping.Domain\Ping.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Ping.Infrastructure\Ping.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@Ping.Api_HostAddress = http://localhost:5089
|
||||
|
||||
GET {{Ping.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Scalar.AspNetCore;
|
||||
using Ping.Api.Endpoints;
|
||||
using Ping.Api.Extensions;
|
||||
using Ping.Api.Hubs;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
const string devCorsPolicy = "dev-cors";
|
||||
|
||||
builder.Services.AddApplicationServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy =>
|
||||
policy.WithOrigins("http://localhost:3100").AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseCors(devCorsPolicy);
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app
|
||||
.MapUserEndpoints()
|
||||
.MapFriendEndpoints()
|
||||
.MapServerEndpoints()
|
||||
.MapChannelEndpoints()
|
||||
.MapMessageEndpoints()
|
||||
.MapAttachmentEndpoints()
|
||||
.MapCallEndpoints();
|
||||
|
||||
app.MapHub<ChatHub>("/hubs/chat");
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5211",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7050;http://localhost:5089",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Ping.Api.Scalar;
|
||||
|
||||
public static class ScalarExtensions
|
||||
{
|
||||
public static IServiceCollection AddScalarConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services.ConfigureOptions<ScalarOptionsSetup>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Scalar.AspNetCore;
|
||||
using Ping.Api.Authentication;
|
||||
|
||||
namespace Ping.Api.Scalar;
|
||||
|
||||
public sealed class ScalarOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureOptions<ScalarOptions>
|
||||
{
|
||||
private const string SecuritySchemeId = "OAuth2";
|
||||
|
||||
private readonly AuthenticationConfiguration _configuration = options.Value;
|
||||
|
||||
public void Configure(ScalarOptions scalarOptions)
|
||||
{
|
||||
scalarOptions
|
||||
.WithTitle("Ping API")
|
||||
.AddPreferredSecuritySchemes(SecuritySchemeId)
|
||||
.AddAuthorizationCodeFlow(SecuritySchemeId, flow =>
|
||||
{
|
||||
flow.ClientId = _configuration.ClientId;
|
||||
flow.ClientSecret = _configuration.ClientSecret;
|
||||
flow.AuthorizationUrl = _configuration.AuthorizationEndpoint;
|
||||
flow.TokenUrl = _configuration.TokenEndpoint;
|
||||
flow.Pkce = Pkce.Sha256;
|
||||
flow.SelectedScopes = _configuration.Scopes;
|
||||
flow.RedirectUri = _configuration.ScalarRedirectUri;
|
||||
flow.RefreshUrl = _configuration.TokenEndpoint;
|
||||
})
|
||||
.HideModels();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Attachments;
|
||||
|
||||
namespace Ping.Api.Validators.Attachments;
|
||||
|
||||
public sealed class RequestAttachmentUploadUrlRequestValidator : AbstractValidator<RequestAttachmentUploadUrlRequest>
|
||||
{
|
||||
public RequestAttachmentUploadUrlRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.FileName)
|
||||
.NotEmpty()
|
||||
.MaximumLength(255);
|
||||
|
||||
RuleFor(x => x.ContentType)
|
||||
.NotEmpty()
|
||||
.MaximumLength(255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Channels;
|
||||
using Ping.Domain.ValueObjects;
|
||||
|
||||
namespace Ping.Api.Validators.Channels;
|
||||
|
||||
public sealed class CreateChannelRequestValidator : AbstractValidator<CreateChannelRequest>
|
||||
{
|
||||
public CreateChannelRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(100);
|
||||
|
||||
RuleFor(x => x.Type)
|
||||
.IsInEnum()
|
||||
.NotEqual(ChannelType.DirectMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Friends;
|
||||
|
||||
namespace Ping.Api.Validators.Friends;
|
||||
|
||||
public sealed class SendFriendRequestRequestValidator : AbstractValidator<SendFriendRequestRequest>
|
||||
{
|
||||
public SendFriendRequestRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AddresseeId)
|
||||
.GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Messages;
|
||||
|
||||
namespace Ping.Api.Validators.Messages;
|
||||
|
||||
public sealed class EditMessageRequestValidator : AbstractValidator<EditMessageRequest>
|
||||
{
|
||||
public EditMessageRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty()
|
||||
.MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Messages;
|
||||
|
||||
namespace Ping.Api.Validators.Messages;
|
||||
|
||||
public sealed class SendMessageRequestValidator : AbstractValidator<SendMessageRequest>
|
||||
{
|
||||
public SendMessageRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Content)
|
||||
.MaximumLength(4000);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => !string.IsNullOrWhiteSpace(x.Content) || (x.Attachments?.Count ?? 0) > 0)
|
||||
.WithMessage("A message needs content or at least one attachment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
using Ping.Api.DataTransferObjects.Servers;
|
||||
|
||||
namespace Ping.Api.Validators.Servers;
|
||||
|
||||
public sealed class CreateServerRequestValidator : AbstractValidator<CreateServerRequest>
|
||||
{
|
||||
public CreateServerRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Authentication": {
|
||||
"MetadataAddress": "",
|
||||
"IssuerAddress": "",
|
||||
"AudienceAddress": "",
|
||||
"ClientId": "",
|
||||
"AuthorizationEndpoint": "",
|
||||
"TokenEndpoint": "",
|
||||
"Scopes": [],
|
||||
"ScalarRedirectUri": "",
|
||||
"RequireHttpsMetadata": true
|
||||
},
|
||||
"Database": {
|
||||
"ConnectionString": ""
|
||||
},
|
||||
"InfrastructureStorage": {
|
||||
"Address": "",
|
||||
"AccessKey": "",
|
||||
"SecretAccessKey": "",
|
||||
"ForcePathStyle": false,
|
||||
"UseHttp": false
|
||||
},
|
||||
"ApplicationStorage": {
|
||||
"Bucket": ""
|
||||
},
|
||||
"LiveKit": {
|
||||
"ApiKey": "",
|
||||
"ApiSecret": "",
|
||||
"TokenTtlMinutes": 240
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user