commit 0623875dfaf6d7d634df80a7015543f8b2d309f8 Author: XShine555 Date: Mon Sep 21 19:32:55 2026 +0200 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. diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..59536cb --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "ping-api", + "runtimeExecutable": "dotnet", + "runtimeArgs": ["run", "--project", "backend/Hosts/Ping.Api/Ping.Api.csproj"], + "port": 5211 + }, + { + "name": "ping-web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "frontend"], + "port": 3100 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..932e74f --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# .NET +bin/ +obj/ +[Dd]ebug/ +[Rr]elease/ +*.user +*.userosscache +.vs/ + +# Rider / VS +.idea/ + +# OS +Thumbs.db +.DS_Store + +# Node / frontend +node_modules/ +dist/ +dist-ssr/ +*.local + +# Environment files (backend/deploy/.env, frontend/.env.local, ...) +.env +.env.* +*.env +!.env.example +!.env.prod.example + +# Deploy secrets and runtime state +deploy/keys/ +deploy/secrets/ + +# Claude Code local settings +.claude/settings.local.json diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ac71402 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.idea/ +.git/ +.claude/ +**/DesignSettings.json +*.user +Tests/ diff --git a/backend/Core/Ping.Application/ApplicationDependencyInjection.cs b/backend/Core/Ping.Application/ApplicationDependencyInjection.cs new file mode 100644 index 0000000..683ad17 --- /dev/null +++ b/backend/Core/Ping.Application/ApplicationDependencyInjection.cs @@ -0,0 +1,19 @@ +using Mediator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Ping.Application.Configuration; + +namespace Ping.Application +{ + public static class ApplicationDependencyInjection + { + public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped); + + services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); + + return services; + } + } +} diff --git a/backend/Core/Ping.Application/Attachments/RequestAttachmentUploadUrl.cs b/backend/Core/Ping.Application/Attachments/RequestAttachmentUploadUrl.cs new file mode 100644 index 0000000..c5d3e71 --- /dev/null +++ b/backend/Core/Ping.Application/Attachments/RequestAttachmentUploadUrl.cs @@ -0,0 +1,27 @@ +using Mediator; +using Ping.Application.Attachments.Responses; +using Ping.Application.Configuration; +using Ping.Application.Contracts; + +namespace Ping.Application.Attachments +{ + public record RequestAttachmentUploadUrlCommand(long RequesterId, string FileName, string ContentType) + : ICommand; + + public class RequestAttachmentUploadUrlCommandHandler(IStorageService storageService, ApplicationStorageConfiguration storageConfiguration) + : ICommandHandler + { + private static readonly TimeSpan UploadUrlExpiration = TimeSpan.FromMinutes(10); + + public async ValueTask Handle(RequestAttachmentUploadUrlCommand request, CancellationToken cancellationToken) + { + var key = $"attachments/{request.RequesterId}/{Guid.NewGuid()}/{request.FileName}"; + var expiresAt = DateTime.UtcNow + UploadUrlExpiration; + + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, key, request.ContentType, UploadUrlExpiration, cancellationToken); + + return new AttachmentUploadResponse(uploadUrl, storageConfiguration.Bucket, key, expiresAt); + } + } +} diff --git a/backend/Core/Ping.Application/Attachments/Responses/AttachmentUploadResponse.cs b/backend/Core/Ping.Application/Attachments/Responses/AttachmentUploadResponse.cs new file mode 100644 index 0000000..5f798bf --- /dev/null +++ b/backend/Core/Ping.Application/Attachments/Responses/AttachmentUploadResponse.cs @@ -0,0 +1,4 @@ +namespace Ping.Application.Attachments.Responses +{ + public record AttachmentUploadResponse(string UploadUrl, string Bucket, string Key, DateTime ExpiresAt); +} diff --git a/backend/Core/Ping.Application/Calls/RequestCallToken.cs b/backend/Core/Ping.Application/Calls/RequestCallToken.cs new file mode 100644 index 0000000..ddacead --- /dev/null +++ b/backend/Core/Ping.Application/Calls/RequestCallToken.cs @@ -0,0 +1,36 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Calls.Responses; +using Ping.Application.Contracts; +using Ping.Application.Shared; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Calls +{ + public record RequestCallTokenCommand(long CurrentUserId, Guid ChannelId) : ICommand>; + + public class RequestCallTokenCommandHandler(IDatabase database, ICallTokenService callTokenService) + : ICommandHandler> + { + public async ValueTask> Handle(RequestCallTokenCommand request, CancellationToken cancellationToken) + { + var hasAccess = await database.HasAccessAsync(request.ChannelId, request.CurrentUserId, cancellationToken); + if (!hasAccess) + return Error.NotFound(description: $"Channel {request.ChannelId} not found"); + + var channel = await database.Channels.AsNoTracking().FirstAsync(c => c.Id == request.ChannelId, cancellationToken); + if (channel.Type != ChannelType.Voice && channel.Type != ChannelType.DirectMessage) + return Error.Validation(description: "Calls are only available in voice channels and direct messages"); + + var user = await database.Users.AsNoTracking().FirstAsync(u => u.Id == request.CurrentUserId, cancellationToken); + + var accessToken = callTokenService.CreateAccessToken( + roomName: request.ChannelId.ToString(), + identity: request.CurrentUserId.ToString(), + displayName: user.DisplayName ?? user.Username); + + return new CallTokenResponse(accessToken.Jwt, accessToken.RoomName); + } + } +} diff --git a/backend/Core/Ping.Application/Calls/Responses/CallTokenResponse.cs b/backend/Core/Ping.Application/Calls/Responses/CallTokenResponse.cs new file mode 100644 index 0000000..98633f8 --- /dev/null +++ b/backend/Core/Ping.Application/Calls/Responses/CallTokenResponse.cs @@ -0,0 +1,4 @@ +namespace Ping.Application.Calls.Responses +{ + public record CallTokenResponse(string Token, string RoomName); +} diff --git a/backend/Core/Ping.Application/Channels/CreateChannel.cs b/backend/Core/Ping.Application/Channels/CreateChannel.cs new file mode 100644 index 0000000..95ccbcf --- /dev/null +++ b/backend/Core/Ping.Application/Channels/CreateChannel.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Channels.Responses; +using Ping.Application.Contracts; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Channels +{ + public record CreateChannelCommand(long CurrentUserId, Guid ServerId, string Name, ChannelType Type) + : ICommand>; + + public class CreateChannelCommandHandler(IDatabase database) + : ICommandHandler> + { + public async ValueTask> Handle(CreateChannelCommand request, CancellationToken cancellationToken) + { + var membership = await database.ServerMembers.FirstOrDefaultAsync( + m => m.ServerId == request.ServerId && m.UserId == request.CurrentUserId, cancellationToken); + + if (membership is null) + return Error.NotFound(description: $"Server {request.ServerId} not found"); + + if (membership.Role != ServerMemberRole.Owner) + return Error.Forbidden(description: "Only the server owner can create channels"); + + if (request.Type == ChannelType.DirectMessage) + return Error.Validation(description: "Server channels cannot be direct messages"); + + var position = await database.Channels + .Where(c => c.ServerId == request.ServerId) + .CountAsync(cancellationToken); + + var channel = new Channel + { + Id = Guid.NewGuid(), + ServerId = request.ServerId, + Name = request.Name, + Type = request.Type, + Position = position, + }; + await database.Channels.AddAsync(channel, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + + return ChannelResponse.FromEntity(channel); + } + } +} diff --git a/backend/Core/Ping.Application/Channels/GetOrCreateDirectMessageChannel.cs b/backend/Core/Ping.Application/Channels/GetOrCreateDirectMessageChannel.cs new file mode 100644 index 0000000..88a91cc --- /dev/null +++ b/backend/Core/Ping.Application/Channels/GetOrCreateDirectMessageChannel.cs @@ -0,0 +1,61 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Channels.Responses; +using Ping.Application.Contracts; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Channels +{ + public record GetOrCreateDirectMessageChannelCommand(long CurrentUserId, long OtherUserId) + : ICommand>; + + public class GetOrCreateDirectMessageChannelCommandHandler(IDatabase database) + : ICommandHandler> + { + public async ValueTask> Handle( + GetOrCreateDirectMessageChannelCommand request, CancellationToken cancellationToken) + { + if (request.CurrentUserId == request.OtherUserId) + return Error.Validation(description: "You cannot open a direct message channel with yourself"); + + var areFriends = await database.Friendships.AnyAsync(f => + f.Status == FriendshipStatus.Accepted && + ((f.RequesterId == request.CurrentUserId && f.AddresseeId == request.OtherUserId) || + (f.RequesterId == request.OtherUserId && f.AddresseeId == request.CurrentUserId)), + cancellationToken); + + if (!areFriends) + return Error.Forbidden(description: "You can only message users on your friends list"); + + var existingChannelId = await database.ChannelMembers + .Where(m => m.UserId == request.CurrentUserId) + .Select(m => m.ChannelId) + .Intersect(database.ChannelMembers + .Where(m => m.UserId == request.OtherUserId) + .Select(m => m.ChannelId)) + .Where(channelId => database.Channels.Any(c => c.Id == channelId && c.Type == ChannelType.DirectMessage)) + .FirstOrDefaultAsync(cancellationToken); + + if (existingChannelId != Guid.Empty) + { + var existingChannel = await database.Channels.FirstAsync(c => c.Id == existingChannelId, cancellationToken); + return ChannelResponse.FromEntity(existingChannel); + } + + var channel = new Channel + { + Id = Guid.NewGuid(), + Type = ChannelType.DirectMessage, + Position = 0, + }; + await database.Channels.AddAsync(channel, cancellationToken); + await database.ChannelMembers.AddAsync(new ChannelMember { ChannelId = channel.Id, UserId = request.CurrentUserId }, cancellationToken); + await database.ChannelMembers.AddAsync(new ChannelMember { ChannelId = channel.Id, UserId = request.OtherUserId }, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + + return ChannelResponse.FromEntity(channel); + } + } +} diff --git a/backend/Core/Ping.Application/Channels/GetServerChannels.cs b/backend/Core/Ping.Application/Channels/GetServerChannels.cs new file mode 100644 index 0000000..f551ade --- /dev/null +++ b/backend/Core/Ping.Application/Channels/GetServerChannels.cs @@ -0,0 +1,30 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Channels.Responses; +using Ping.Application.Contracts; + +namespace Ping.Application.Channels +{ + public record GetServerChannelsQuery(long CurrentUserId, Guid ServerId) : IQuery>>; + + public class GetServerChannelsQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetServerChannelsQuery request, CancellationToken cancellationToken) + { + var isMember = await database.ServerMembers.AnyAsync( + m => m.ServerId == request.ServerId && m.UserId == request.CurrentUserId, cancellationToken); + + if (!isMember) + return Error.NotFound(description: $"Server {request.ServerId} not found"); + + var channels = await database.Channels.AsNoTracking() + .Where(c => c.ServerId == request.ServerId) + .OrderBy(c => c.Position) + .ToListAsync(cancellationToken); + + return channels.Select(ChannelResponse.FromEntity).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Channels/Responses/ChannelResponse.cs b/backend/Core/Ping.Application/Channels/Responses/ChannelResponse.cs new file mode 100644 index 0000000..2ee7f13 --- /dev/null +++ b/backend/Core/Ping.Application/Channels/Responses/ChannelResponse.cs @@ -0,0 +1,11 @@ +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Channels.Responses +{ + public record ChannelResponse(Guid Id, Guid? ServerId, string? Name, ChannelType Type, int Position) + { + public static ChannelResponse FromEntity(Channel channel) => + new(channel.Id, channel.ServerId, channel.Name, channel.Type, channel.Position); + } +} diff --git a/backend/Core/Ping.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Ping.Application/Configuration/ApplicationStorageConfiguration.cs new file mode 100644 index 0000000..d9c8c19 --- /dev/null +++ b/backend/Core/Ping.Application/Configuration/ApplicationStorageConfiguration.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ping.Application.Configuration +{ + public class ApplicationStorageConfiguration + { + public const string SectionName = "ApplicationStorage"; + + [Required] + public required string Bucket { get; set; } + } +} diff --git a/backend/Core/Ping.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Ping.Application/Configuration/ConfigurationDependencyInjection.cs new file mode 100644 index 0000000..e5c5440 --- /dev/null +++ b/backend/Core/Ping.Application/Configuration/ConfigurationDependencyInjection.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Ping.Application.Configuration +{ + public static class ConfigurationDependencyInjection + { + public static IServiceCollection AddValidatedOptions( + this IServiceCollection services, + IConfiguration configuration, + string sectionName) + where T : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } + } +} diff --git a/backend/Core/Ping.Application/Contracts/ICallTokenService.cs b/backend/Core/Ping.Application/Contracts/ICallTokenService.cs new file mode 100644 index 0000000..1d933c9 --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/ICallTokenService.cs @@ -0,0 +1,9 @@ +namespace Ping.Application.Contracts +{ + public record CallAccessToken(string Jwt, string RoomName); + + public interface ICallTokenService + { + CallAccessToken CreateAccessToken(string roomName, string identity, string? displayName); + } +} diff --git a/backend/Core/Ping.Application/Contracts/IDatabase.cs b/backend/Core/Ping.Application/Contracts/IDatabase.cs new file mode 100644 index 0000000..21aa6e9 --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/IDatabase.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Ping.Domain.Entities; + +namespace Ping.Application.Contracts +{ + public interface IDatabase + { + DbSet Users { get; } + + DbSet Friendships { get; } + + DbSet Servers { get; } + + DbSet ServerMembers { get; } + + DbSet Channels { get; } + + DbSet ChannelMembers { get; } + + DbSet Messages { get; } + + DbSet Attachments { get; } + + Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/backend/Core/Ping.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Ping.Application/Contracts/IDatabaseTransaction.cs new file mode 100644 index 0000000..c5e24fe --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/IDatabaseTransaction.cs @@ -0,0 +1,7 @@ +namespace Ping.Application.Contracts +{ + public interface IDatabaseTransaction : IAsyncDisposable + { + Task CommitAsync(CancellationToken cancellationToken); + } +} diff --git a/backend/Core/Ping.Application/Contracts/IPresenceTracker.cs b/backend/Core/Ping.Application/Contracts/IPresenceTracker.cs new file mode 100644 index 0000000..c3e4a1a --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/IPresenceTracker.cs @@ -0,0 +1,14 @@ +namespace Ping.Application.Contracts +{ + public enum PresenceStatus + { + Offline, + Online, + Busy + } + + public interface IPresenceTracker + { + PresenceStatus GetStatus(long userId); + } +} diff --git a/backend/Core/Ping.Application/Contracts/IRealtimeNotifier.cs b/backend/Core/Ping.Application/Contracts/IRealtimeNotifier.cs new file mode 100644 index 0000000..ae17afa --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/IRealtimeNotifier.cs @@ -0,0 +1,17 @@ +using Ping.Application.Messages.Responses; + +namespace Ping.Application.Contracts +{ + public interface IRealtimeNotifier + { + Task MessageCreatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken); + + Task MessageUpdatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken); + + Task MessageDeletedAsync(Guid channelId, Guid messageId, CancellationToken cancellationToken); + + Task FriendRequestReceivedAsync(long addresseeId, Guid friendshipId, CancellationToken cancellationToken); + + Task FriendshipUpdatedAsync(long userId, Guid friendshipId, CancellationToken cancellationToken); + } +} diff --git a/backend/Core/Ping.Application/Contracts/IStorageService.cs b/backend/Core/Ping.Application/Contracts/IStorageService.cs new file mode 100644 index 0000000..1f08836 --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/IStorageService.cs @@ -0,0 +1,21 @@ +namespace Ping.Application.Contracts +{ + public interface IStorageService + { + Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + + Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken); + + Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + CancellationToken cancellationToken, + bool preventOverwrite = true); + + Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + + Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); + } +} diff --git a/backend/Core/Ping.Application/Contracts/ObjectMetaData.cs b/backend/Core/Ping.Application/Contracts/ObjectMetaData.cs new file mode 100644 index 0000000..4a280c2 --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/ObjectMetaData.cs @@ -0,0 +1,4 @@ +namespace Ping.Application.Contracts +{ + public record ObjectMetaData(string? ContentType, long ContentLength); +} diff --git a/backend/Core/Ping.Application/Contracts/StorageObject.cs b/backend/Core/Ping.Application/Contracts/StorageObject.cs new file mode 100644 index 0000000..53e2f81 --- /dev/null +++ b/backend/Core/Ping.Application/Contracts/StorageObject.cs @@ -0,0 +1,4 @@ +namespace Ping.Application.Contracts +{ + public record StorageObject(string Key, DateTime LastModified); +} diff --git a/backend/Core/Ping.Application/Friends/AcceptFriendRequest.cs b/backend/Core/Ping.Application/Friends/AcceptFriendRequest.cs new file mode 100644 index 0000000..f4bdd69 --- /dev/null +++ b/backend/Core/Ping.Application/Friends/AcceptFriendRequest.cs @@ -0,0 +1,40 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Friends.Responses; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record AcceptFriendRequestCommand(long CurrentUserId, Guid FriendshipId) : ICommand>; + + public class AcceptFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken) + { + var friendship = await database.Friendships + .Include(f => f.Requester) + .Include(f => f.Addressee) + .FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken); + + if (friendship is null) + return Error.NotFound(description: $"Friend request {request.FriendshipId} not found"); + + if (friendship.AddresseeId != request.CurrentUserId) + return Error.Forbidden(description: "Only the addressee can accept this friend request"); + + if (friendship.Status != FriendshipStatus.Pending) + return Error.Conflict(description: "This friend request is no longer pending"); + + friendship.Status = FriendshipStatus.Accepted; + friendship.RespondedAt = DateTime.UtcNow; + await database.SaveChangesAsync(cancellationToken); + + await notifier.FriendshipUpdatedAsync(friendship.RequesterId, friendship.Id, cancellationToken); + + return FriendshipResponse.FromEntity(friendship, request.CurrentUserId); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/BlockUser.cs b/backend/Core/Ping.Application/Friends/BlockUser.cs new file mode 100644 index 0000000..2d8b902 --- /dev/null +++ b/backend/Core/Ping.Application/Friends/BlockUser.cs @@ -0,0 +1,46 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record BlockUserCommand(long CurrentUserId, long TargetUserId) : ICommand>; + + public class BlockUserCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(BlockUserCommand request, CancellationToken cancellationToken) + { + if (request.CurrentUserId == request.TargetUserId) + return Error.Validation(description: "You cannot block yourself"); + + var friendship = await database.Friendships.FirstOrDefaultAsync(f => + (f.RequesterId == request.CurrentUserId && f.AddresseeId == request.TargetUserId) || + (f.RequesterId == request.TargetUserId && f.AddresseeId == request.CurrentUserId), + cancellationToken); + + if (friendship is null) + { + friendship = new Friendship + { + Id = Guid.NewGuid(), + RequesterId = request.CurrentUserId, + AddresseeId = request.TargetUserId, + }; + await database.Friendships.AddAsync(friendship, cancellationToken); + } + + friendship.Status = FriendshipStatus.Blocked; + friendship.BlockedById = request.CurrentUserId; + friendship.RespondedAt = DateTime.UtcNow; + await database.SaveChangesAsync(cancellationToken); + + await notifier.FriendshipUpdatedAsync(request.TargetUserId, friendship.Id, cancellationToken); + + return new Success(); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/GetFriends.cs b/backend/Core/Ping.Application/Friends/GetFriends.cs new file mode 100644 index 0000000..c85174d --- /dev/null +++ b/backend/Core/Ping.Application/Friends/GetFriends.cs @@ -0,0 +1,30 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Friends.Responses; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record GetFriendsQuery(long UserId) : IQuery>; + + public class GetFriendsQueryHandler(IDatabase database, IPresenceTracker presence) + : IQueryHandler> + { + public async ValueTask> Handle(GetFriendsQuery request, CancellationToken cancellationToken) + { + var friendships = await database.Friendships.AsNoTracking() + .Include(f => f.Requester) + .Include(f => f.Addressee) + .Where(f => f.Status == FriendshipStatus.Accepted && + (f.RequesterId == request.UserId || f.AddresseeId == request.UserId)) + .ToListAsync(cancellationToken); + + return friendships.Select(f => + { + var otherUserId = f.RequesterId == request.UserId ? f.AddresseeId : f.RequesterId; + return FriendshipResponse.FromEntity(f, request.UserId, presence.GetStatus(otherUserId)); + }).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/GetPendingFriendRequests.cs b/backend/Core/Ping.Application/Friends/GetPendingFriendRequests.cs new file mode 100644 index 0000000..b9d8b6c --- /dev/null +++ b/backend/Core/Ping.Application/Friends/GetPendingFriendRequests.cs @@ -0,0 +1,26 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Friends.Responses; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record GetPendingFriendRequestsQuery(long UserId) : IQuery>; + + public class GetPendingFriendRequestsQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetPendingFriendRequestsQuery request, CancellationToken cancellationToken) + { + var friendships = await database.Friendships.AsNoTracking() + .Include(f => f.Requester) + .Include(f => f.Addressee) + .Where(f => f.Status == FriendshipStatus.Pending && + (f.RequesterId == request.UserId || f.AddresseeId == request.UserId)) + .ToListAsync(cancellationToken); + + return friendships.Select(f => FriendshipResponse.FromEntity(f, request.UserId)).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/RejectFriendRequest.cs b/backend/Core/Ping.Application/Friends/RejectFriendRequest.cs new file mode 100644 index 0000000..7fbca60 --- /dev/null +++ b/backend/Core/Ping.Application/Friends/RejectFriendRequest.cs @@ -0,0 +1,35 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record RejectFriendRequestCommand(long CurrentUserId, Guid FriendshipId) : ICommand>; + + public class RejectFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(RejectFriendRequestCommand request, CancellationToken cancellationToken) + { + var friendship = await database.Friendships.FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken); + + if (friendship is null) + return Error.NotFound(description: $"Friend request {request.FriendshipId} not found"); + + if (friendship.AddresseeId != request.CurrentUserId) + return Error.Forbidden(description: "Only the addressee can reject this friend request"); + + if (friendship.Status != FriendshipStatus.Pending) + return Error.Conflict(description: "This friend request is no longer pending"); + + database.Friendships.Remove(friendship); + await database.SaveChangesAsync(cancellationToken); + + await notifier.FriendshipUpdatedAsync(friendship.RequesterId, friendship.Id, cancellationToken); + + return new Success(); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/RemoveFriend.cs b/backend/Core/Ping.Application/Friends/RemoveFriend.cs new file mode 100644 index 0000000..1f74097 --- /dev/null +++ b/backend/Core/Ping.Application/Friends/RemoveFriend.cs @@ -0,0 +1,37 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record RemoveFriendCommand(long CurrentUserId, Guid FriendshipId) : ICommand>; + + public class RemoveFriendCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(RemoveFriendCommand request, CancellationToken cancellationToken) + { + var friendship = await database.Friendships.FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken); + + if (friendship is null) + return Error.NotFound(description: $"Friendship {request.FriendshipId} not found"); + + if (friendship.RequesterId != request.CurrentUserId && friendship.AddresseeId != request.CurrentUserId) + return Error.Forbidden(description: "You are not part of this friendship"); + + if (friendship.Status != FriendshipStatus.Accepted) + return Error.Conflict(description: "This friendship is not active"); + + var otherUserId = friendship.RequesterId == request.CurrentUserId ? friendship.AddresseeId : friendship.RequesterId; + + database.Friendships.Remove(friendship); + await database.SaveChangesAsync(cancellationToken); + + await notifier.FriendshipUpdatedAsync(otherUserId, friendship.Id, cancellationToken); + + return new Success(); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/Responses/FriendshipResponse.cs b/backend/Core/Ping.Application/Friends/Responses/FriendshipResponse.cs new file mode 100644 index 0000000..390893c --- /dev/null +++ b/backend/Core/Ping.Application/Friends/Responses/FriendshipResponse.cs @@ -0,0 +1,25 @@ +using Ping.Application.Contracts; +using Ping.Application.Users.Responses; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends.Responses +{ + public record FriendshipResponse( + Guid Id, UserResponse OtherUser, FriendshipStatus Status, bool IsIncoming, DateTime CreatedAt, PresenceStatus Presence) + { + public static FriendshipResponse FromEntity(Friendship friendship, long currentUserId, PresenceStatus presence = PresenceStatus.Offline) + { + var isIncoming = friendship.AddresseeId == currentUserId; + var other = isIncoming ? friendship.Requester : friendship.Addressee; + + return new FriendshipResponse( + friendship.Id, + UserResponse.FromEntity(other!), + friendship.Status, + isIncoming, + friendship.CreatedAt, + presence); + } + } +} diff --git a/backend/Core/Ping.Application/Friends/SendFriendRequest.cs b/backend/Core/Ping.Application/Friends/SendFriendRequest.cs new file mode 100644 index 0000000..9ccf4ad --- /dev/null +++ b/backend/Core/Ping.Application/Friends/SendFriendRequest.cs @@ -0,0 +1,56 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Friends.Responses; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Friends +{ + public record SendFriendRequestCommand(long RequesterId, long AddresseeId) : ICommand>; + + public class SendFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken) + { + if (request.RequesterId == request.AddresseeId) + return Error.Validation(description: "You cannot send a friend request to yourself"); + + var addresseeExists = await database.Users.AnyAsync(u => u.Id == request.AddresseeId, cancellationToken); + if (!addresseeExists) + return Error.NotFound(description: $"User {request.AddresseeId} not found"); + + var existing = await database.Friendships.FirstOrDefaultAsync(f => + (f.RequesterId == request.RequesterId && f.AddresseeId == request.AddresseeId) || + (f.RequesterId == request.AddresseeId && f.AddresseeId == request.RequesterId), + cancellationToken); + + if (existing is not null) + { + return existing.Status switch + { + FriendshipStatus.Blocked => Error.Forbidden(description: "This friendship is blocked"), + FriendshipStatus.Accepted => Error.Conflict(description: "You are already friends"), + _ => Error.Conflict(description: "A friend request already exists between these users"), + }; + } + + var friendship = new Friendship + { + Id = Guid.NewGuid(), + RequesterId = request.RequesterId, + AddresseeId = request.AddresseeId, + }; + await database.Friendships.AddAsync(friendship, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + + await notifier.FriendRequestReceivedAsync(request.AddresseeId, friendship.Id, cancellationToken); + + friendship.Requester = await database.Users.FirstAsync(u => u.Id == request.RequesterId, cancellationToken); + friendship.Addressee = await database.Users.FirstAsync(u => u.Id == request.AddresseeId, cancellationToken); + return FriendshipResponse.FromEntity(friendship, request.RequesterId); + } + } +} diff --git a/backend/Core/Ping.Application/Messages/DeleteMessage.cs b/backend/Core/Ping.Application/Messages/DeleteMessage.cs new file mode 100644 index 0000000..e406d84 --- /dev/null +++ b/backend/Core/Ping.Application/Messages/DeleteMessage.cs @@ -0,0 +1,41 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; + +namespace Ping.Application.Messages +{ + public record DeleteMessageCommand(long CurrentUserId, Guid MessageId) : ICommand>; + + public class DeleteMessageCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(DeleteMessageCommand request, CancellationToken cancellationToken) + { + var message = await database.Messages + .FirstOrDefaultAsync(m => m.Id == request.MessageId && m.DeletedAt == null, cancellationToken); + + if (message is null) + return Error.NotFound(description: $"Message {request.MessageId} not found"); + + if (message.AuthorId != request.CurrentUserId) + { + var isServerOwner = await database.Channels + .Where(c => c.Id == message.ChannelId && c.ServerId != null) + .Select(c => c.ServerId) + .Join(database.Servers, serverId => serverId, server => server.Id, (_, server) => server.OwnerId) + .AnyAsync(ownerId => ownerId == request.CurrentUserId, cancellationToken); + + if (!isServerOwner) + return Error.Forbidden(description: "Only the author or the server owner can delete this message"); + } + + message.DeletedAt = DateTime.UtcNow; + message.Content = string.Empty; + await database.SaveChangesAsync(cancellationToken); + + await notifier.MessageDeletedAsync(message.ChannelId, message.Id, cancellationToken); + return new Success(); + } + } +} diff --git a/backend/Core/Ping.Application/Messages/EditMessage.cs b/backend/Core/Ping.Application/Messages/EditMessage.cs new file mode 100644 index 0000000..136141c --- /dev/null +++ b/backend/Core/Ping.Application/Messages/EditMessage.cs @@ -0,0 +1,39 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Messages.Responses; + +namespace Ping.Application.Messages +{ + public record EditMessageCommand(long CurrentUserId, Guid MessageId, string Content) : ICommand>; + + public class EditMessageCommandHandler(IDatabase database, IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(EditMessageCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Content)) + return Error.Validation(description: "Message content cannot be empty"); + + var message = await database.Messages + .Include(m => m.Author) + .Include(m => m.Attachments) + .FirstOrDefaultAsync(m => m.Id == request.MessageId && m.DeletedAt == null, cancellationToken); + + if (message is null) + return Error.NotFound(description: $"Message {request.MessageId} not found"); + + if (message.AuthorId != request.CurrentUserId) + return Error.Forbidden(description: "Only the author can edit this message"); + + message.Content = request.Content; + message.EditedAt = DateTime.UtcNow; + await database.SaveChangesAsync(cancellationToken); + + var response = MessageResponse.FromEntity(message); + await notifier.MessageUpdatedAsync(message.ChannelId, response, cancellationToken); + return response; + } + } +} diff --git a/backend/Core/Ping.Application/Messages/GetChannelMessages.cs b/backend/Core/Ping.Application/Messages/GetChannelMessages.cs new file mode 100644 index 0000000..5452b51 --- /dev/null +++ b/backend/Core/Ping.Application/Messages/GetChannelMessages.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Messages.Responses; +using Ping.Application.Shared; + +namespace Ping.Application.Messages +{ + public record GetChannelMessagesQuery(long CurrentUserId, Guid ChannelId, Guid? Before, int Take) + : IQuery>>; + + public class GetChannelMessagesQueryHandler(IDatabase database) + : IQueryHandler>> + { + private const int MaxTake = 100; + + public async ValueTask>> Handle(GetChannelMessagesQuery request, CancellationToken cancellationToken) + { + var hasAccess = await database.HasAccessAsync(request.ChannelId, request.CurrentUserId, cancellationToken); + if (!hasAccess) + return Error.NotFound(description: $"Channel {request.ChannelId} not found"); + + var take = Math.Clamp(request.Take, 1, MaxTake); + + var query = database.Messages.AsNoTracking() + .Include(m => m.Author) + .Include(m => m.Attachments) + .Where(m => m.ChannelId == request.ChannelId); + + if (request.Before is { } beforeId) + { + var cursorCreatedAt = await database.Messages + .Where(m => m.Id == beforeId) + .Select(m => m.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + query = query.Where(m => m.CreatedAt < cursorCreatedAt); + } + + var messages = await query + .OrderByDescending(m => m.CreatedAt) + .Take(take) + .ToListAsync(cancellationToken); + + return messages.Select(MessageResponse.FromEntity).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Messages/Responses/AttachmentResponse.cs b/backend/Core/Ping.Application/Messages/Responses/AttachmentResponse.cs new file mode 100644 index 0000000..3c379aa --- /dev/null +++ b/backend/Core/Ping.Application/Messages/Responses/AttachmentResponse.cs @@ -0,0 +1,11 @@ +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Messages.Responses +{ + public record AttachmentResponse(Guid Id, string FileName, string ContentType, long SizeBytes, AttachmentKind Kind) + { + public static AttachmentResponse FromEntity(Attachment attachment) => + new(attachment.Id, attachment.FileName, attachment.ContentType, attachment.SizeBytes, attachment.Kind); + } +} diff --git a/backend/Core/Ping.Application/Messages/Responses/MessageResponse.cs b/backend/Core/Ping.Application/Messages/Responses/MessageResponse.cs new file mode 100644 index 0000000..ff6738a --- /dev/null +++ b/backend/Core/Ping.Application/Messages/Responses/MessageResponse.cs @@ -0,0 +1,27 @@ +using Ping.Application.Users.Responses; +using Ping.Domain.Entities; + +namespace Ping.Application.Messages.Responses +{ + public record MessageResponse( + Guid Id, + Guid ChannelId, + UserResponse Author, + string Content, + Guid? ReplyToMessageId, + DateTime CreatedAt, + DateTime? EditedAt, + IReadOnlyList Attachments) + { + public static MessageResponse FromEntity(Message message) => + new( + message.Id, + message.ChannelId, + UserResponse.FromEntity(message.Author!), + message.Content, + message.ReplyToMessageId, + message.CreatedAt, + message.EditedAt, + message.Attachments.Select(AttachmentResponse.FromEntity).ToList()); + } +} diff --git a/backend/Core/Ping.Application/Messages/SendMessage.cs b/backend/Core/Ping.Application/Messages/SendMessage.cs new file mode 100644 index 0000000..07e2aff --- /dev/null +++ b/backend/Core/Ping.Application/Messages/SendMessage.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Configuration; +using Ping.Application.Contracts; +using Ping.Application.Messages.Responses; +using Ping.Application.Shared; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Messages +{ + public record PendingAttachment(string Key, string FileName, string ContentType, AttachmentKind Kind); + + public record SendMessageCommand( + long AuthorId, + Guid ChannelId, + string Content, + Guid? ReplyToMessageId, + IReadOnlyList Attachments) + : ICommand>; + + public class SendMessageCommandHandler( + IDatabase database, + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + IRealtimeNotifier notifier) + : ICommandHandler> + { + public async ValueTask> Handle(SendMessageCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments.Count == 0) + return Error.Validation(description: "A message needs content or at least one attachment"); + + var hasAccess = await database.HasAccessAsync(request.ChannelId, request.AuthorId, cancellationToken); + if (!hasAccess) + return Error.NotFound(description: $"Channel {request.ChannelId} not found"); + + var message = new Message + { + Id = Guid.NewGuid(), + ChannelId = request.ChannelId, + AuthorId = request.AuthorId, + Content = request.Content, + ReplyToMessageId = request.ReplyToMessageId, + }; + + foreach (var pending in request.Attachments) + { + var metadata = await storageService.HeadObjectAsync(storageConfiguration.Bucket, pending.Key, cancellationToken); + if (metadata is null) + return Error.Validation(description: $"Attachment '{pending.FileName}' was not found in storage"); + + message.Attachments.Add(new Attachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Bucket = storageConfiguration.Bucket, + Key = pending.Key, + FileName = pending.FileName, + ContentType = pending.ContentType, + SizeBytes = metadata.ContentLength, + Kind = pending.Kind, + }); + } + + await database.Messages.AddAsync(message, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + + message.Author = await database.Users.FirstAsync(u => u.Id == request.AuthorId, cancellationToken); + var response = MessageResponse.FromEntity(message); + + await notifier.MessageCreatedAsync(request.ChannelId, response, cancellationToken); + return response; + } + } +} diff --git a/backend/Core/Ping.Application/Ping.Application.csproj b/backend/Core/Ping.Application/Ping.Application.csproj new file mode 100644 index 0000000..7885108 --- /dev/null +++ b/backend/Core/Ping.Application/Ping.Application.csproj @@ -0,0 +1,26 @@ + + + + Ping.Application + Ping.Application + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/backend/Core/Ping.Application/Serialization/LongAsStringConverter.cs b/backend/Core/Ping.Application/Serialization/LongAsStringConverter.cs new file mode 100644 index 0000000..79047e0 --- /dev/null +++ b/backend/Core/Ping.Application/Serialization/LongAsStringConverter.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ping.Application.Serialization +{ + public sealed class LongAsStringConverter : JsonConverter + { + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } + + public sealed class NullableLongAsStringConverter : JsonConverter + { + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + return reader.TokenType == JsonTokenType.String + ? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); + else + writer.WriteNullValue(); + } + } +} diff --git a/backend/Core/Ping.Application/Servers/CreateServer.cs b/backend/Core/Ping.Application/Servers/CreateServer.cs new file mode 100644 index 0000000..31255a9 --- /dev/null +++ b/backend/Core/Ping.Application/Servers/CreateServer.cs @@ -0,0 +1,44 @@ +using Mediator; +using Ping.Application.Contracts; +using Ping.Application.Servers.Responses; +using Ping.Domain.Entities; +using Ping.Domain.ValueObjects; + +namespace Ping.Application.Servers +{ + public record CreateServerCommand(long OwnerId, string Name) : ICommand; + + public class CreateServerCommandHandler(IDatabase database) + : ICommandHandler + { + public async ValueTask Handle(CreateServerCommand request, CancellationToken cancellationToken) + { + var server = new Server + { + Id = Guid.NewGuid(), + Name = request.Name, + OwnerId = request.OwnerId, + }; + await database.Servers.AddAsync(server, cancellationToken); + + await database.ServerMembers.AddAsync(new ServerMember + { + ServerId = server.Id, + UserId = request.OwnerId, + Role = ServerMemberRole.Owner, + }, cancellationToken); + + await database.Channels.AddAsync(new Channel + { + Id = Guid.NewGuid(), + ServerId = server.Id, + Name = "general", + Type = ChannelType.Text, + Position = 0, + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + return ServerResponse.FromEntity(server); + } + } +} diff --git a/backend/Core/Ping.Application/Servers/GetUserServers.cs b/backend/Core/Ping.Application/Servers/GetUserServers.cs new file mode 100644 index 0000000..cf085e5 --- /dev/null +++ b/backend/Core/Ping.Application/Servers/GetUserServers.cs @@ -0,0 +1,23 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Servers.Responses; + +namespace Ping.Application.Servers +{ + public record GetUserServersQuery(long UserId) : IQuery>; + + public class GetUserServersQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserServersQuery request, CancellationToken cancellationToken) + { + var servers = await database.ServerMembers.AsNoTracking() + .Where(m => m.UserId == request.UserId) + .Select(m => m.Server!) + .ToListAsync(cancellationToken); + + return servers.Select(ServerResponse.FromEntity).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Servers/Responses/ServerResponse.cs b/backend/Core/Ping.Application/Servers/Responses/ServerResponse.cs new file mode 100644 index 0000000..8e2041f --- /dev/null +++ b/backend/Core/Ping.Application/Servers/Responses/ServerResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Ping.Application.Serialization; +using Ping.Domain.Entities; + +namespace Ping.Application.Servers.Responses +{ + public record ServerResponse(Guid Id, string Name, string? IconUrl, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerId) + { + public static ServerResponse FromEntity(Server server) => + new(server.Id, server.Name, server.IconUrl, server.OwnerId); + } +} diff --git a/backend/Core/Ping.Application/Shared/ChannelAccess.cs b/backend/Core/Ping.Application/Shared/ChannelAccess.cs new file mode 100644 index 0000000..6ebaaab --- /dev/null +++ b/backend/Core/Ping.Application/Shared/ChannelAccess.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; + +namespace Ping.Application.Shared +{ + public static class ChannelAccess + { + public static async Task HasAccessAsync(this IDatabase database, Guid channelId, long userId, CancellationToken cancellationToken) + { + var isDirectMessageParticipant = await database.ChannelMembers + .AnyAsync(m => m.ChannelId == channelId && m.UserId == userId, cancellationToken); + + if (isDirectMessageParticipant) + return true; + + return await database.Channels.AnyAsync(c => + c.Id == channelId && c.ServerId != null && + database.ServerMembers.Any(sm => sm.ServerId == c.ServerId && sm.UserId == userId), + cancellationToken); + } + } +} diff --git a/backend/Core/Ping.Application/Users/GetUserById.cs b/backend/Core/Ping.Application/Users/GetUserById.cs new file mode 100644 index 0000000..aa87362 --- /dev/null +++ b/backend/Core/Ping.Application/Users/GetUserById.cs @@ -0,0 +1,22 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Users.Responses; + +namespace Ping.Application.Users +{ + public record GetUserByIdQuery(long Id) : IQuery>; + + public class GetUserByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await database.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + return user is null + ? Error.NotFound(description: $"User {request.Id} not found") + : UserResponse.FromEntity(user); + } + } +} diff --git a/backend/Core/Ping.Application/Users/Responses/UserResponse.cs b/backend/Core/Ping.Application/Users/Responses/UserResponse.cs new file mode 100644 index 0000000..7bd5a79 --- /dev/null +++ b/backend/Core/Ping.Application/Users/Responses/UserResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Ping.Application.Serialization; +using Ping.Domain.Entities; + +namespace Ping.Application.Users.Responses +{ + public record UserResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Username, + string? DisplayName, + string? AvatarUrl) + { + public static UserResponse FromEntity(User user) => + new(user.Id, user.Username, user.DisplayName, user.AvatarUrl); + } +} diff --git a/backend/Core/Ping.Application/Users/SearchUsers.cs b/backend/Core/Ping.Application/Users/SearchUsers.cs new file mode 100644 index 0000000..b243bf4 --- /dev/null +++ b/backend/Core/Ping.Application/Users/SearchUsers.cs @@ -0,0 +1,28 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Ping.Application.Contracts; +using Ping.Application.Users.Responses; + +namespace Ping.Application.Users +{ + public record SearchUsersQuery(string UsernameQuery, long ExcludingUserId) : IQuery>; + + public class SearchUsersQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(SearchUsersQuery request, CancellationToken cancellationToken) + { + var normalizedQuery = request.UsernameQuery.Trim().ToUpperInvariant(); + if (normalizedQuery.Length == 0) + return []; + + var users = await database.Users.AsNoTracking() + .Where(u => u.Id != request.ExcludingUserId && u.NormalizedUsername.Contains(normalizedQuery)) + .OrderBy(u => u.Username) + .Take(20) + .ToListAsync(cancellationToken); + + return users.Select(UserResponse.FromEntity).ToList(); + } + } +} diff --git a/backend/Core/Ping.Application/Users/SyncUser.cs b/backend/Core/Ping.Application/Users/SyncUser.cs new file mode 100644 index 0000000..56ed25c --- /dev/null +++ b/backend/Core/Ping.Application/Users/SyncUser.cs @@ -0,0 +1,45 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ping.Application.Contracts; +using Ping.Application.Users.Responses; +using Ping.Domain.Entities; + +namespace Ping.Application.Users +{ + public record SyncUserCommand(long Id, string Username, string? DisplayName, string? AvatarUrl) + : ICommand; + + public class SyncUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(SyncUserCommand request, CancellationToken cancellationToken) + { + var user = await database.Users.FirstOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + + if (user is null) + { + user = new User + { + Id = request.Id, + Username = request.Username, + NormalizedUsername = request.Username.ToUpperInvariant(), + DisplayName = request.DisplayName, + AvatarUrl = request.AvatarUrl, + }; + await database.Users.AddAsync(user, cancellationToken); + logger.LogInformation("Provisioned user {UserId} from identity provider", user.Id); + } + else + { + user.Username = request.Username; + user.NormalizedUsername = request.Username.ToUpperInvariant(); + user.DisplayName = request.DisplayName; + user.AvatarUrl = request.AvatarUrl; + } + + await database.SaveChangesAsync(cancellationToken); + return UserResponse.FromEntity(user); + } + } +} diff --git a/backend/Core/Ping.Domain/Abstractions/IAuditable.cs b/backend/Core/Ping.Domain/Abstractions/IAuditable.cs new file mode 100644 index 0000000..7bfa715 --- /dev/null +++ b/backend/Core/Ping.Domain/Abstractions/IAuditable.cs @@ -0,0 +1,9 @@ +namespace Ping.Domain.Abstractions +{ + public interface IAuditable + { + DateTime CreatedAt { get; set; } + + DateTime UpdatedAt { get; set; } + } +} diff --git a/backend/Core/Ping.Domain/Entities/Attachment.cs b/backend/Core/Ping.Domain/Entities/Attachment.cs new file mode 100644 index 0000000..92f1f5c --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/Attachment.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.ValueObjects; + +namespace Ping.Domain.Entities +{ + [Table("Attachment")] + public class Attachment + { + [Key] + public Guid Id { get; set; } + + public required Guid MessageId { get; set; } + + public Message? Message { get; set; } + + [Required] + [MaxLength(255)] + public required string Bucket { get; set; } + + [Required] + [MaxLength(1024)] + public required string Key { get; set; } + + [Required] + [MaxLength(255)] + public required string FileName { get; set; } + + [Required] + [MaxLength(255)] + public required string ContentType { get; set; } + + public required long SizeBytes { get; set; } + + [Required] + public AttachmentKind Kind { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/backend/Core/Ping.Domain/Entities/Channel.cs b/backend/Core/Ping.Domain/Entities/Channel.cs new file mode 100644 index 0000000..d566995 --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/Channel.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.ValueObjects; + +namespace Ping.Domain.Entities +{ + [Table("Channel")] + public class Channel + { + [Key] + public Guid Id { get; set; } + + public Guid? ServerId { get; set; } + + public Server? ServerNavigation { get; set; } + + [MaxLength(100)] + public string? Name { get; set; } + + [MaxLength(1024)] + public string? Topic { get; set; } + + [Required] + public ChannelType Type { get; set; } + + [Required] + public int Position { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Members { get; set; } = new List(); + } +} diff --git a/backend/Core/Ping.Domain/Entities/ChannelMember.cs b/backend/Core/Ping.Domain/Entities/ChannelMember.cs new file mode 100644 index 0000000..d97bc29 --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/ChannelMember.cs @@ -0,0 +1,13 @@ +namespace Ping.Domain.Entities +{ + public class ChannelMember + { + public required Guid ChannelId { get; set; } + + public Channel? Channel { get; set; } + + public required long UserId { get; set; } + + public User? User { get; set; } + } +} diff --git a/backend/Core/Ping.Domain/Entities/Friendship.cs b/backend/Core/Ping.Domain/Entities/Friendship.cs new file mode 100644 index 0000000..ad873f8 --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/Friendship.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.ValueObjects; + +namespace Ping.Domain.Entities +{ + [Table("Friendship")] + public class Friendship + { + [Key] + public Guid Id { get; set; } + + public required long RequesterId { get; set; } + + public User? Requester { get; set; } + + public required long AddresseeId { get; set; } + + public User? Addressee { get; set; } + + [Required] + public FriendshipStatus Status { get; set; } = FriendshipStatus.Pending; + + public long? BlockedById { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? RespondedAt { get; set; } + } +} diff --git a/backend/Core/Ping.Domain/Entities/Message.cs b/backend/Core/Ping.Domain/Entities/Message.cs new file mode 100644 index 0000000..7265d49 --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/Message.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Ping.Domain.Entities +{ + [Table("Message")] + public class Message + { + [Key] + public Guid Id { get; set; } + + public required Guid ChannelId { get; set; } + + public Channel? Channel { get; set; } + + public required long AuthorId { get; set; } + + public User? Author { get; set; } + + [Required] + [MaxLength(4000)] + public required string Content { get; set; } + + public Guid? ReplyToMessageId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? EditedAt { get; set; } + + public DateTime? DeletedAt { get; set; } + + public ICollection Attachments { get; set; } = new List(); + } +} diff --git a/backend/Core/Ping.Domain/Entities/Server.cs b/backend/Core/Ping.Domain/Entities/Server.cs new file mode 100644 index 0000000..1f43612 --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/Server.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.Abstractions; + +namespace Ping.Domain.Entities +{ + [Table("Server")] + public class Server : IAuditable + { + [Key] + public Guid Id { get; set; } + + [Required] + [MaxLength(100)] + public required string Name { get; set; } + + [MaxLength(2048)] + public string? IconUrl { get; set; } + + public required long OwnerId { get; set; } + + public User? Owner { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Members { get; set; } = new List(); + + public ICollection Channels { get; set; } = new List(); + } +} diff --git a/backend/Core/Ping.Domain/Entities/ServerMember.cs b/backend/Core/Ping.Domain/Entities/ServerMember.cs new file mode 100644 index 0000000..9d54e8a --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/ServerMember.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.ValueObjects; + +namespace Ping.Domain.Entities +{ + [Table("ServerMember")] + public class ServerMember + { + public required Guid ServerId { get; set; } + + public Server? Server { get; set; } + + public required long UserId { get; set; } + + public User? User { get; set; } + + [MaxLength(64)] + public string? Nickname { get; set; } + + [Required] + public ServerMemberRole Role { get; set; } = ServerMemberRole.Member; + + [Required] + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/backend/Core/Ping.Domain/Entities/User.cs b/backend/Core/Ping.Domain/Entities/User.cs new file mode 100644 index 0000000..e9355ac --- /dev/null +++ b/backend/Core/Ping.Domain/Entities/User.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ping.Domain.Abstractions; + +namespace Ping.Domain.Entities +{ + [Table("User")] + public class User : IAuditable + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public required long Id { get; set; } + + [Required] + [MaxLength(64)] + public required string Username { get; set; } + + [Required] + [MaxLength(64)] + public required string NormalizedUsername { get; set; } + + [MaxLength(64)] + public string? DisplayName { get; set; } + + [MaxLength(2048)] + public string? AvatarUrl { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection ServerMemberships { get; set; } = new List(); + } +} diff --git a/backend/Core/Ping.Domain/Ping.Domain.csproj b/backend/Core/Ping.Domain/Ping.Domain.csproj new file mode 100644 index 0000000..d6eafc8 --- /dev/null +++ b/backend/Core/Ping.Domain/Ping.Domain.csproj @@ -0,0 +1,8 @@ + + + + Ping.Domain + Ping.Domain + + + diff --git a/backend/Core/Ping.Domain/ValueObjects/AttachmentKind.cs b/backend/Core/Ping.Domain/ValueObjects/AttachmentKind.cs new file mode 100644 index 0000000..1ad44d2 --- /dev/null +++ b/backend/Core/Ping.Domain/ValueObjects/AttachmentKind.cs @@ -0,0 +1,9 @@ +namespace Ping.Domain.ValueObjects +{ + public enum AttachmentKind + { + File = 0, + Image = 1, + Audio = 2, + } +} diff --git a/backend/Core/Ping.Domain/ValueObjects/ChannelType.cs b/backend/Core/Ping.Domain/ValueObjects/ChannelType.cs new file mode 100644 index 0000000..a36e7a2 --- /dev/null +++ b/backend/Core/Ping.Domain/ValueObjects/ChannelType.cs @@ -0,0 +1,9 @@ +namespace Ping.Domain.ValueObjects +{ + public enum ChannelType + { + Text = 0, + Voice = 1, + DirectMessage = 2, + } +} diff --git a/backend/Core/Ping.Domain/ValueObjects/FriendshipStatus.cs b/backend/Core/Ping.Domain/ValueObjects/FriendshipStatus.cs new file mode 100644 index 0000000..96b000b --- /dev/null +++ b/backend/Core/Ping.Domain/ValueObjects/FriendshipStatus.cs @@ -0,0 +1,9 @@ +namespace Ping.Domain.ValueObjects +{ + public enum FriendshipStatus + { + Pending = 0, + Accepted = 1, + Blocked = 2, + } +} diff --git a/backend/Core/Ping.Domain/ValueObjects/ServerMemberRole.cs b/backend/Core/Ping.Domain/ValueObjects/ServerMemberRole.cs new file mode 100644 index 0000000..586fc85 --- /dev/null +++ b/backend/Core/Ping.Domain/ValueObjects/ServerMemberRole.cs @@ -0,0 +1,8 @@ +namespace Ping.Domain.ValueObjects +{ + public enum ServerMemberRole + { + Member = 0, + Owner = 1, + } +} diff --git a/backend/Core/Ping.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Ping.Infrastructure/Configuration/DatabaseConfiguration.cs new file mode 100644 index 0000000..9a3be1e --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ping.Infrastructure.Configuration +{ + public class DatabaseConfiguration + { + public const string SectionName = "Database"; + + [Required] + public required string ConnectionString { get; set; } + } +} diff --git a/backend/Core/Ping.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/backend/Core/Ping.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs new file mode 100644 index 0000000..3cfdb08 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ping.Infrastructure.Configuration +{ + public class InfrastructureStorageConfiguration + { + public const string SectionName = "InfrastructureStorage"; + + [Url] + [Required] + public required string Address { get; set; } + + [Required] + public required string AccessKey { get; set; } + + [Required] + public required string SecretAccessKey { get; set; } + + [Required] + public bool ForcePathStyle { get; set; } + + [Required] + public bool UseHttp { get; set; } + } +} diff --git a/backend/Core/Ping.Infrastructure/Configuration/LiveKitConfiguration.cs b/backend/Core/Ping.Infrastructure/Configuration/LiveKitConfiguration.cs new file mode 100644 index 0000000..4d4012a --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Configuration/LiveKitConfiguration.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ping.Infrastructure.Configuration +{ + public class LiveKitConfiguration + { + public const string SectionName = "LiveKit"; + + [Required] + public required string ApiKey { get; set; } + + [Required] + public required string ApiSecret { get; set; } + + public int TokenTtlMinutes { get; set; } = 240; + } +} diff --git a/backend/Core/Ping.Infrastructure/DesignSettings.json b/backend/Core/Ping.Infrastructure/DesignSettings.json new file mode 100644 index 0000000..09ae5bd --- /dev/null +++ b/backend/Core/Ping.Infrastructure/DesignSettings.json @@ -0,0 +1,5 @@ +{ + "Database": { + "ConnectionString": "" + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Ping.Infrastructure/Persistence/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..e5899bf --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Ping.Domain.Abstractions; + +namespace Ping.Infrastructure.Persistence +{ + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + ApplyTimestamps(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + ApplyTimestamps(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void ApplyTimestamps(DbContext? context) + { + if (context is null) + return; + + context.ChangeTracker.DetectChanges(); + + var now = DateTime.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = now; + } + } + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/Database.cs b/backend/Core/Ping.Infrastructure/Persistence/Database.cs new file mode 100644 index 0000000..38bb3ed --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/Database.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Ping.Domain.Entities; +using Ping.Infrastructure.Configuration; +using AppIDatabase = Ping.Application.Contracts.IDatabase; + +namespace Ping.Infrastructure.Persistence +{ + public class Database(DatabaseConfiguration configuration) : DbContext, AppIDatabase + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(configuration.ConnectionString); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasKey(m => new { m.ServerId, m.UserId }); + + modelBuilder.Entity() + .HasOne(m => m.Server) + .WithMany(s => s.Members) + .HasForeignKey(m => m.ServerId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(m => m.User) + .WithMany(u => u.ServerMemberships) + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(s => s.Owner) + .WithMany() + .HasForeignKey(s => s.OwnerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(c => c.ServerNavigation) + .WithMany(s => s.Channels) + .HasForeignKey(c => c.ServerId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(m => new { m.ChannelId, m.UserId }); + + modelBuilder.Entity() + .HasOne(m => m.Channel) + .WithMany(c => c.Members) + .HasForeignKey(m => m.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(m => m.User) + .WithMany() + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(m => m.Channel) + .WithMany() + .HasForeignKey(m => m.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(m => m.Author) + .WithMany() + .HasForeignKey(m => m.AuthorId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(m => new { m.ChannelId, m.CreatedAt }); + + modelBuilder.Entity() + .HasOne(a => a.Message) + .WithMany(m => m.Attachments) + .HasForeignKey(a => a.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(f => f.Requester) + .WithMany() + .HasForeignKey(f => f.RequesterId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(f => f.Addressee) + .WithMany() + .HasForeignKey(f => f.AddresseeId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(f => new { f.RequesterId, f.AddresseeId }) + .IsUnique(); + } + + public DbSet Users => Set(); + + public DbSet Friendships => Set(); + + public DbSet Servers => Set(); + + public DbSet ServerMembers => Set(); + + public DbSet Channels => Set(); + + public DbSet ChannelMembers => Set(); + + public DbSet Messages => Set(); + + public DbSet Attachments => Set(); + + public async Task BeginTransactionAsync( + System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : Ping.Application.Contracts.IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Ping.Infrastructure/Persistence/DatabaseDependencyInjection.cs new file mode 100644 index 0000000..ccbb043 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Ping.Application.Contracts; +using Ping.Infrastructure.Configuration; + +namespace Ping.Infrastructure.Persistence +{ + public static class DatabaseDependencyInjection + { + public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(); + + serviceDescriptors.AddDbContext((serviceProvider, options) => + options.AddInterceptors(serviceProvider.GetRequiredService())); + + serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + + return serviceDescriptors; + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Ping.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs new file mode 100644 index 0000000..fa12938 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Ping.Infrastructure.Configuration; + +namespace Ping.Infrastructure.Persistence +{ + public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory + { + public Database CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("DesignSettings.json", optional: true) + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + var databaseConfiguration = configuration + .GetRequiredSection(DatabaseConfiguration.SectionName) + .Get() + ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); + + return new Database(databaseConfiguration); + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.Designer.cs b/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.Designer.cs new file mode 100644 index 0000000..53f54dc --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.Designer.cs @@ -0,0 +1,413 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ping.Infrastructure.Persistence; + +#nullable disable + +namespace Ping.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260916023908_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ping.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachment"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ServerId") + .HasColumnType("uuid"); + + b.Property("Topic") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b => + { + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("ChannelId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMembers"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("bigint"); + + b.Property("BlockedById") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("bigint"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId", "AddresseeId") + .IsUnique(); + + b.ToTable("Friendship"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplyToMessageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ChannelId", "CreatedAt"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OwnerId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Server"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b => + { + b.Property("ServerId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Nickname") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("ServerId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ServerMember"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Attachment", b => + { + b.HasOne("Ping.Domain.Entities.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.HasOne("Ping.Domain.Entities.Server", "ServerNavigation") + .WithMany("Channels") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ServerNavigation"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b => + { + b.HasOne("Ping.Domain.Entities.Channel", "Channel") + .WithMany("Members") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Friendship", b => + { + b.HasOne("Ping.Domain.Entities.User", "Addressee") + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.HasOne("Ping.Domain.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.HasOne("Ping.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b => + { + b.HasOne("Ping.Domain.Entities.Server", "Server") + .WithMany("Members") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "User") + .WithMany("ServerMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.Navigation("Channels"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.User", b => + { + b.Navigation("ServerMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.cs b/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.cs new file mode 100644 index 0000000..b639981 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/Migrations/20260916023908_InitialCreate.cs @@ -0,0 +1,283 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ping.Infrastructure.Persistence.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "User", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false), + Username = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + NormalizedUsername = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + DisplayName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + AvatarUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_User", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Friendship", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RequesterId = table.Column(type: "bigint", nullable: false), + AddresseeId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "integer", nullable: false), + BlockedById = table.Column(type: "bigint", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Friendship", x => x.Id); + table.ForeignKey( + name: "FK_Friendship_User_AddresseeId", + column: x => x.AddresseeId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Friendship_User_RequesterId", + column: x => x.RequesterId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Server", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + IconUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + OwnerId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Server", x => x.Id); + table.ForeignKey( + name: "FK_Server_User_OwnerId", + column: x => x.OwnerId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Channel", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ServerId = table.Column(type: "uuid", nullable: true), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Topic = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + Type = table.Column(type: "integer", nullable: false), + Position = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Channel", x => x.Id); + table.ForeignKey( + name: "FK_Channel_Server_ServerId", + column: x => x.ServerId, + principalTable: "Server", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ServerMember", + columns: table => new + { + ServerId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Nickname = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Role = table.Column(type: "integer", nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerMember", x => new { x.ServerId, x.UserId }); + table.ForeignKey( + name: "FK_ServerMember_Server_ServerId", + column: x => x.ServerId, + principalTable: "Server", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ServerMember_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChannelMembers", + columns: table => new + { + ChannelId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelMembers", x => new { x.ChannelId, x.UserId }); + table.ForeignKey( + name: "FK_ChannelMembers_Channel_ChannelId", + column: x => x.ChannelId, + principalTable: "Channel", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChannelMembers_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Message", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "bigint", nullable: false), + Content = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + ReplyToMessageId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + EditedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Message", x => x.Id); + table.ForeignKey( + name: "FK_Message_Channel_ChannelId", + column: x => x.ChannelId, + principalTable: "Channel", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Message_User_AuthorId", + column: x => x.AuthorId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Attachment", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MessageId = table.Column(type: "uuid", nullable: false), + Bucket = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Key = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + FileName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + ContentType = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + Kind = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Attachment", x => x.Id); + table.ForeignKey( + name: "FK_Attachment_Message_MessageId", + column: x => x.MessageId, + principalTable: "Message", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Attachment_MessageId", + table: "Attachment", + column: "MessageId"); + + migrationBuilder.CreateIndex( + name: "IX_Channel_ServerId", + table: "Channel", + column: "ServerId"); + + migrationBuilder.CreateIndex( + name: "IX_ChannelMembers_UserId", + table: "ChannelMembers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendship_AddresseeId", + table: "Friendship", + column: "AddresseeId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendship_RequesterId_AddresseeId", + table: "Friendship", + columns: new[] { "RequesterId", "AddresseeId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Message_AuthorId", + table: "Message", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Message_ChannelId_CreatedAt", + table: "Message", + columns: new[] { "ChannelId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Server_OwnerId", + table: "Server", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_ServerMember_UserId", + table: "ServerMember", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Attachment"); + + migrationBuilder.DropTable( + name: "ChannelMembers"); + + migrationBuilder.DropTable( + name: "Friendship"); + + migrationBuilder.DropTable( + name: "ServerMember"); + + migrationBuilder.DropTable( + name: "Message"); + + migrationBuilder.DropTable( + name: "Channel"); + + migrationBuilder.DropTable( + name: "Server"); + + migrationBuilder.DropTable( + name: "User"); + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Ping.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs new file mode 100644 index 0000000..eed3b57 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -0,0 +1,411 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ping.Infrastructure.Persistence; + +#nullable disable + +namespace Ping.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + partial class DatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ping.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachment"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ServerId") + .HasColumnType("uuid"); + + b.Property("Topic") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b => + { + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("ChannelId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMembers"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("bigint"); + + b.Property("BlockedById") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("bigint"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId", "AddresseeId") + .IsUnique(); + + b.ToTable("Friendship"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplyToMessageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ChannelId", "CreatedAt"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OwnerId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Server"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b => + { + b.Property("ServerId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Nickname") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("ServerId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ServerMember"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Attachment", b => + { + b.HasOne("Ping.Domain.Entities.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.HasOne("Ping.Domain.Entities.Server", "ServerNavigation") + .WithMany("Channels") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ServerNavigation"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b => + { + b.HasOne("Ping.Domain.Entities.Channel", "Channel") + .WithMany("Members") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Friendship", b => + { + b.HasOne("Ping.Domain.Entities.User", "Addressee") + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.HasOne("Ping.Domain.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.HasOne("Ping.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b => + { + b.HasOne("Ping.Domain.Entities.Server", "Server") + .WithMany("Members") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ping.Domain.Entities.User", "User") + .WithMany("ServerMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Channel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Message", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.Server", b => + { + b.Navigation("Channels"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Ping.Domain.Entities.User", b => + { + b.Navigation("ServerMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Ping.Infrastructure.csproj b/backend/Core/Ping.Infrastructure/Ping.Infrastructure.csproj new file mode 100644 index 0000000..b75fdd0 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Ping.Infrastructure.csproj @@ -0,0 +1,34 @@ + + + + Ping.Infrastructure + Ping.Infrastructure + 8baf35f2-9845-462f-a87a-570d56d13c58 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Core/Ping.Infrastructure/Services/CallTokenService.cs b/backend/Core/Ping.Infrastructure/Services/CallTokenService.cs new file mode 100644 index 0000000..efcbdb5 --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Services/CallTokenService.cs @@ -0,0 +1,27 @@ +using Livekit.Server.Sdk.Dotnet; +using Ping.Application.Contracts; +using Ping.Infrastructure.Configuration; + +namespace Ping.Infrastructure.Services +{ + public class CallTokenService(LiveKitConfiguration configuration) : ICallTokenService + { + public CallAccessToken CreateAccessToken(string roomName, string identity, string? displayName) + { + var token = new AccessToken(configuration.ApiKey, configuration.ApiSecret) + .WithIdentity(identity) + .WithName(displayName ?? identity) + .WithGrants(new VideoGrants + { + RoomJoin = true, + Room = roomName, + CanPublish = true, + CanSubscribe = true, + CanPublishData = true, + }) + .WithTtl(TimeSpan.FromMinutes(configuration.TokenTtlMinutes)); + + return new CallAccessToken(token.ToJwt(), roomName); + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Ping.Infrastructure/Services/ServicesDependencyInjection.cs new file mode 100644 index 0000000..08517cb --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Services/ServicesDependencyInjection.cs @@ -0,0 +1,52 @@ +using Amazon.S3; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Ping.Application.Contracts; +using Ping.Infrastructure.Configuration; + +namespace Ping.Infrastructure.Services +{ + public static class ServicesDependencyInjection + { + public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(serviceProvider => + { + var storageConfiguration = serviceProvider.GetRequiredService(); + var s3Configuration = new AmazonS3Config + { + ServiceURL = storageConfiguration.Address, + ForcePathStyle = storageConfiguration.ForcePathStyle, + UseHttp = storageConfiguration.UseHttp, + }; + return new AmazonS3Client(storageConfiguration.AccessKey, storageConfiguration.SecretAccessKey, s3Configuration); + }); + + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + + public static IServiceCollection AddCallTokenService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(LiveKitConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(); + return serviceDescriptors; + } + } +} diff --git a/backend/Core/Ping.Infrastructure/Services/StorageService.cs b/backend/Core/Ping.Infrastructure/Services/StorageService.cs new file mode 100644 index 0000000..64fed1e --- /dev/null +++ b/backend/Core/Ping.Infrastructure/Services/StorageService.cs @@ -0,0 +1,94 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; +using Ping.Application.Contracts; +using Ping.Infrastructure.Configuration; + +namespace Ping.Infrastructure.Services +{ + public class StorageService(IAmazonS3 amazonS3, ILogger logger, InfrastructureStorageConfiguration storageClientConfiguration) + : IStorageService + { + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + { + var request = new GetObjectRequest { BucketName = bucket, Key = key }; + + try + { + var response = await amazonS3.GetObjectAsync(request, cancellationToken); + logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); + return response.ResponseStream; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); + throw; + } + } + + public async Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) + { + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + }; + + logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } + + public async Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + CancellationToken cancellationToken, + bool preventOverwrite = true) + { + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + Verb = HttpVerb.PUT, + ContentType = contentType, + }; + + if (preventOverwrite) + request.Headers["If-None-Match"] = "*"; + + logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } + + public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) + { + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + catch (Exception exception) + { + logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); + throw; + } + } + + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) + { + var request = new DeleteObjectRequest { BucketName = bucket, Key = key }; + + logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); + await amazonS3.DeleteObjectAsync(request, cancellationToken); + } + } +} diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..b818461 --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..3627c30 --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,47 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Hosts/Ping.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Ping.Api/Authentication/AuthenticationConfiguration.cs new file mode 100644 index 0000000..972c1b2 --- /dev/null +++ b/backend/Hosts/Ping.Api/Authentication/AuthenticationConfiguration.cs @@ -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; +} diff --git a/backend/Hosts/Ping.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Ping.Api/Authentication/AuthenticationExtensions.cs new file mode 100644 index 0000000..dc81362 --- /dev/null +++ b/backend/Hosts/Ping.Api/Authentication/AuthenticationExtensions.cs @@ -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() + .Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddScoped(); + services.ConfigureOptions(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services.AddAuthorization(); + + return services; + } +} diff --git a/backend/Hosts/Ping.Api/Authentication/CurrentUser.cs b/backend/Hosts/Ping.Api/Authentication/CurrentUser.cs new file mode 100644 index 0000000..9351493 --- /dev/null +++ b/backend/Hosts/Ping.Api/Authentication/CurrentUser.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using System.Security.Claims; + +namespace Ping.Api.Authentication; + +public sealed class CurrentUser : IBindableFromHttpContext +{ + 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 BindAsync(HttpContext context, ParameterInfo parameter) => + ValueTask.FromResult(new CurrentUser(context.User)); +} diff --git a/backend/Hosts/Ping.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Ping.Api/Authentication/JwtBearerEventsHandler.cs new file mode 100644 index 0000000..7dca3f1 --- /dev/null +++ b/backend/Hosts/Ping.Api/Authentication/JwtBearerEventsHandler.cs @@ -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 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 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(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; + } +} diff --git a/backend/Hosts/Ping.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Ping.Api/Authentication/JwtBearerOptionsSetup.cs new file mode 100644 index 0000000..4f3c568 --- /dev/null +++ b/backend/Hosts/Ping.Api/Authentication/JwtBearerOptionsSetup.cs @@ -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 options) + : IConfigureNamedOptions +{ + 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); + } +} diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Attachments/RequestAttachmentUploadUrlRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Attachments/RequestAttachmentUploadUrlRequest.cs new file mode 100644 index 0000000..8ad0d81 --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Attachments/RequestAttachmentUploadUrlRequest.cs @@ -0,0 +1,3 @@ +namespace Ping.Api.DataTransferObjects.Attachments; + +public record RequestAttachmentUploadUrlRequest(string FileName, string ContentType); diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Channels/CreateChannelRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Channels/CreateChannelRequest.cs new file mode 100644 index 0000000..99ec945 --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Channels/CreateChannelRequest.cs @@ -0,0 +1,5 @@ +using Ping.Domain.ValueObjects; + +namespace Ping.Api.DataTransferObjects.Channels; + +public record CreateChannelRequest(string Name, ChannelType Type); diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Friends/SendFriendRequestRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Friends/SendFriendRequestRequest.cs new file mode 100644 index 0000000..0a9c13d --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Friends/SendFriendRequestRequest.cs @@ -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); diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Messages/EditMessageRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Messages/EditMessageRequest.cs new file mode 100644 index 0000000..4df7794 --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Messages/EditMessageRequest.cs @@ -0,0 +1,3 @@ +namespace Ping.Api.DataTransferObjects.Messages; + +public record EditMessageRequest(string Content); diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Messages/SendMessageRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Messages/SendMessageRequest.cs new file mode 100644 index 0000000..6cf7bcf --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Messages/SendMessageRequest.cs @@ -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? Attachments); diff --git a/backend/Hosts/Ping.Api/DataTransferObjects/Servers/CreateServerRequest.cs b/backend/Hosts/Ping.Api/DataTransferObjects/Servers/CreateServerRequest.cs new file mode 100644 index 0000000..9e1a2f8 --- /dev/null +++ b/backend/Hosts/Ping.Api/DataTransferObjects/Servers/CreateServerRequest.cs @@ -0,0 +1,3 @@ +namespace Ping.Api.DataTransferObjects.Servers; + +public record CreateServerRequest(string Name); diff --git a/backend/Hosts/Ping.Api/Endpoints/AttachmentEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/AttachmentEndpoints.cs new file mode 100644 index 0000000..9cc4d3a --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/AttachmentEndpoints.cs @@ -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>() + .Produces() + .ProducesValidationProblem(); + + return app; + } + + private static async Task RequestUploadUrl( + IMediator mediator, CurrentUser currentUser, RequestAttachmentUploadUrlRequest request, CancellationToken cancellationToken) => + await mediator.Send(new RequestAttachmentUploadUrlCommand(currentUser.RequiredId, request.FileName, request.ContentType), cancellationToken); +} diff --git a/backend/Hosts/Ping.Api/Endpoints/CallEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/CallEndpoints.cs new file mode 100644 index 0000000..aba63ab --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/CallEndpoints.cs @@ -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() + .ProducesValidationProblem() + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task RequestCallToken( + IMediator mediator, CurrentUser currentUser, Guid channelId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new RequestCallTokenCommand(currentUser.RequiredId, channelId), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Ping.Api/Endpoints/ChannelEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/ChannelEndpoints.cs new file mode 100644 index 0000000..f7c1d5c --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/ChannelEndpoints.cs @@ -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>() + .Produces(StatusCodes.Status404NotFound); + + servers.MapPost("/", CreateChannel) + .WithName("CreateChannel") + .WithSummary("Create A Text Or Voice Channel In A Server.") + .AddEndpointFilter>() + .Produces(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() + .Produces(StatusCodes.Status403Forbidden) + .ProducesValidationProblem(); + + return app; + } + + private static async Task 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 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 GetOrCreateDirectMessageChannel( + IMediator mediator, CurrentUser currentUser, long otherUserId, CancellationToken cancellationToken) + { + var result = await mediator.Send( + new GetOrCreateDirectMessageChannelCommand(currentUser.RequiredId, otherUserId), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Ping.Api/Endpoints/FriendEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/FriendEndpoints.cs new file mode 100644 index 0000000..2d64707 --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/FriendEndpoints.cs @@ -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>(); + + group.MapGet("/requests", GetPendingFriendRequests) + .WithName("GetPendingFriendRequests") + .WithSummary("Get Incoming And Outgoing Pending Friend Requests.") + .Produces>(); + + group.MapPost("/requests", SendFriendRequest) + .WithName("SendFriendRequest") + .WithSummary("Send A Friend Request.") + .AddEndpointFilter>() + .Produces(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() + .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> GetFriends( + IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) => + await mediator.Send(new GetFriendsQuery(currentUser.RequiredId), cancellationToken); + + private static async Task> GetPendingFriendRequests( + IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) => + await mediator.Send(new GetPendingFriendRequestsQuery(currentUser.RequiredId), cancellationToken); + + private static async Task 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 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 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 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 BlockUser( + IMediator mediator, CurrentUser currentUser, long userId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new BlockUserCommand(currentUser.RequiredId, userId), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Ping.Api/Endpoints/MessageEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/MessageEndpoints.cs new file mode 100644 index 0000000..f64ed9c --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/MessageEndpoints.cs @@ -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>() + .Produces(StatusCodes.Status404NotFound); + + channelMessages.MapPost("/", SendMessage) + .WithName("SendMessage") + .WithSummary("Send A Message To A Channel.") + .AddEndpointFilter>() + .Produces(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>() + .Produces() + .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 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 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 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 DeleteMessage( + IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteMessageCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Ping.Api/Endpoints/ServerEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/ServerEndpoints.cs new file mode 100644 index 0000000..1cd0353 --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/ServerEndpoints.cs @@ -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>(); + + group.MapPost("/", CreateServer) + .WithName("CreateServer") + .WithSummary("Create A New Server, With The Current User As Owner.") + .AddEndpointFilter>() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem(); + + return app; + } + + private static async Task> GetUserServers( + IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) => + await mediator.Send(new GetUserServersQuery(currentUser.RequiredId), cancellationToken); + + private static async Task 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); + } +} diff --git a/backend/Hosts/Ping.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Ping.Api/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..13415db --- /dev/null +++ b/backend/Hosts/Ping.Api/Endpoints/UserEndpoints.cs @@ -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() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id:long}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get A User By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/search", SearchUsers) + .WithName("SearchUsers") + .WithSummary("Search Users By Username, Excluding The Current User.") + .Produces>(); + + return app; + } + + private static async Task GetCurrentUser(IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(currentUser.RequiredId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetUserById(IMediator mediator, long id, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task> SearchUsers( + IMediator mediator, CurrentUser currentUser, string query, CancellationToken cancellationToken) => + await mediator.Send(new SearchUsersQuery(query, currentUser.RequiredId), cancellationToken); +} diff --git a/backend/Hosts/Ping.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Ping.Api/Extensions/ErrorOrHttpExtensions.cs new file mode 100644 index 0000000..12f98d2 --- /dev/null +++ b/backend/Hosts/Ping.Api/Extensions/ErrorOrHttpExtensions.cs @@ -0,0 +1,43 @@ +using ErrorOr; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Ping.Api.Extensions; + +public static class ErrorOrHttpExtensions +{ + public static IResult ToHttpResult(this ErrorOr result) + { + if (result.IsError) + return Problem(result.Errors); + + return result.Value is Success + ? Results.NoContent() + : Results.Ok(result.Value); + } + + public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => + result.IsError + ? Problem(result.Errors) + : Results.Created(locationFactory(result.Value), result.Value); + + private static IResult Problem(List 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 ToValidationDictionary(IEnumerable errors) => + errors + .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) + .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); +} diff --git a/backend/Hosts/Ping.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Ping.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6805fe1 --- /dev/null +++ b/backend/Hosts/Ping.Api/Extensions/ServiceCollectionExtensions.cs @@ -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(); + + services.AddSignalR(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/backend/Hosts/Ping.Api/Filters/ValidationFilter.cs b/backend/Hosts/Ping.Api/Filters/ValidationFilter.cs new file mode 100644 index 0000000..cb1b23f --- /dev/null +++ b/backend/Hosts/Ping.Api/Filters/ValidationFilter.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Ping.Api.Filters; + +public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var argument = context.Arguments.OfType().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()); + } +} diff --git a/backend/Hosts/Ping.Api/Hubs/ChatHub.cs b/backend/Hosts/Ping.Api/Hubs/ChatHub.cs new file mode 100644 index 0000000..759cbd0 --- /dev/null +++ b/backend/Hosts/Ping.Api/Hubs/ChatHub.cs @@ -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}"; +} diff --git a/backend/Hosts/Ping.Api/Hubs/PresenceTracker.cs b/backend/Hosts/Ping.Api/Hubs/PresenceTracker.cs new file mode 100644 index 0000000..2280fe7 --- /dev/null +++ b/backend/Hosts/Ping.Api/Hubs/PresenceTracker.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; +using Ping.Application.Contracts; + +namespace Ping.Api.Hubs; + +public sealed class PresenceTracker : IPresenceTracker +{ + private readonly ConcurrentDictionary _connectionCounts = new(); + private readonly ConcurrentDictionary _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; + } +} diff --git a/backend/Hosts/Ping.Api/Hubs/RealtimeNotifier.cs b/backend/Hosts/Ping.Api/Hubs/RealtimeNotifier.cs new file mode 100644 index 0000000..221c3ce --- /dev/null +++ b/backend/Hosts/Ping.Api/Hubs/RealtimeNotifier.cs @@ -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 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); +} diff --git a/backend/Hosts/Ping.Api/OpenApi/OpenApiExtensions.cs b/backend/Hosts/Ping.Api/OpenApi/OpenApiExtensions.cs new file mode 100644 index 0000000..c628601 --- /dev/null +++ b/backend/Hosts/Ping.Api/OpenApi/OpenApiExtensions.cs @@ -0,0 +1,12 @@ +namespace Ping.Api.OpenApi; + +public static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); + + return services; + } +} diff --git a/backend/Hosts/Ping.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Ping.Api/OpenApi/OpenApiOptionsSetup.cs new file mode 100644 index 0000000..775d165 --- /dev/null +++ b/backend/Hosts/Ping.Api/OpenApi/OpenApiOptionsSetup.cs @@ -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 options) + : IConfigureNamedOptions +{ + 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(); + 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() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + { + return Task.CompletedTask; + } + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + _configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } +} diff --git a/backend/Hosts/Ping.Api/Ping.Api.csproj b/backend/Hosts/Ping.Api/Ping.Api.csproj new file mode 100644 index 0000000..96b670e --- /dev/null +++ b/backend/Hosts/Ping.Api/Ping.Api.csproj @@ -0,0 +1,29 @@ + + + + Ping.Api + Ping.Api + ed416956-761d-4ded-8424-fc612ea6803c + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Hosts/Ping.Api/Ping.Api.http b/backend/Hosts/Ping.Api/Ping.Api.http new file mode 100644 index 0000000..34604e2 --- /dev/null +++ b/backend/Hosts/Ping.Api/Ping.Api.http @@ -0,0 +1,6 @@ +@Ping.Api_HostAddress = http://localhost:5089 + +GET {{Ping.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/backend/Hosts/Ping.Api/Program.cs b/backend/Hosts/Ping.Api/Program.cs new file mode 100644 index 0000000..7cbb12d --- /dev/null +++ b/backend/Hosts/Ping.Api/Program.cs @@ -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("/hubs/chat"); + +app.Run(); + +public partial class Program; diff --git a/backend/Hosts/Ping.Api/Properties/launchSettings.json b/backend/Hosts/Ping.Api/Properties/launchSettings.json new file mode 100644 index 0000000..b73ca00 --- /dev/null +++ b/backend/Hosts/Ping.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/backend/Hosts/Ping.Api/Scalar/ScalarExtensions.cs b/backend/Hosts/Ping.Api/Scalar/ScalarExtensions.cs new file mode 100644 index 0000000..5d4ef22 --- /dev/null +++ b/backend/Hosts/Ping.Api/Scalar/ScalarExtensions.cs @@ -0,0 +1,11 @@ +namespace Ping.Api.Scalar; + +public static class ScalarExtensions +{ + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); + + return services; + } +} diff --git a/backend/Hosts/Ping.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Ping.Api/Scalar/ScalarOptionsSetup.cs new file mode 100644 index 0000000..3d89ae3 --- /dev/null +++ b/backend/Hosts/Ping.Api/Scalar/ScalarOptionsSetup.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Options; +using Scalar.AspNetCore; +using Ping.Api.Authentication; + +namespace Ping.Api.Scalar; + +public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions +{ + 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(); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Attachments/RequestAttachmentUploadUrlRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Attachments/RequestAttachmentUploadUrlRequestValidator.cs new file mode 100644 index 0000000..80144bb --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Attachments/RequestAttachmentUploadUrlRequestValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using Ping.Api.DataTransferObjects.Attachments; + +namespace Ping.Api.Validators.Attachments; + +public sealed class RequestAttachmentUploadUrlRequestValidator : AbstractValidator +{ + public RequestAttachmentUploadUrlRequestValidator() + { + RuleFor(x => x.FileName) + .NotEmpty() + .MaximumLength(255); + + RuleFor(x => x.ContentType) + .NotEmpty() + .MaximumLength(255); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Channels/CreateChannelRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Channels/CreateChannelRequestValidator.cs new file mode 100644 index 0000000..efbb745 --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Channels/CreateChannelRequestValidator.cs @@ -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 +{ + public CreateChannelRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(x => x.Type) + .IsInEnum() + .NotEqual(ChannelType.DirectMessage); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Friends/SendFriendRequestRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Friends/SendFriendRequestRequestValidator.cs new file mode 100644 index 0000000..277d388 --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Friends/SendFriendRequestRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Ping.Api.DataTransferObjects.Friends; + +namespace Ping.Api.Validators.Friends; + +public sealed class SendFriendRequestRequestValidator : AbstractValidator +{ + public SendFriendRequestRequestValidator() + { + RuleFor(x => x.AddresseeId) + .GreaterThan(0); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Messages/EditMessageRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Messages/EditMessageRequestValidator.cs new file mode 100644 index 0000000..9b6a34c --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Messages/EditMessageRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Ping.Api.DataTransferObjects.Messages; + +namespace Ping.Api.Validators.Messages; + +public sealed class EditMessageRequestValidator : AbstractValidator +{ + public EditMessageRequestValidator() + { + RuleFor(x => x.Content) + .NotEmpty() + .MaximumLength(4000); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Messages/SendMessageRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Messages/SendMessageRequestValidator.cs new file mode 100644 index 0000000..cf915b1 --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Messages/SendMessageRequestValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Ping.Api.DataTransferObjects.Messages; + +namespace Ping.Api.Validators.Messages; + +public sealed class SendMessageRequestValidator : AbstractValidator +{ + 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"); + } +} diff --git a/backend/Hosts/Ping.Api/Validators/Servers/CreateServerRequestValidator.cs b/backend/Hosts/Ping.Api/Validators/Servers/CreateServerRequestValidator.cs new file mode 100644 index 0000000..61ad9e0 --- /dev/null +++ b/backend/Hosts/Ping.Api/Validators/Servers/CreateServerRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Ping.Api.DataTransferObjects.Servers; + +namespace Ping.Api.Validators.Servers; + +public sealed class CreateServerRequestValidator : AbstractValidator +{ + public CreateServerRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(100); + } +} diff --git a/backend/Hosts/Ping.Api/appsettings.Development.json b/backend/Hosts/Ping.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/Hosts/Ping.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/Hosts/Ping.Api/appsettings.json b/backend/Hosts/Ping.Api/appsettings.json new file mode 100644 index 0000000..0ca5266 --- /dev/null +++ b/backend/Hosts/Ping.Api/appsettings.json @@ -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 + } +} diff --git a/backend/Ping.Api.Dockerfile b/backend/Ping.Api.Dockerfile new file mode 100644 index 0000000..ad97c4d --- /dev/null +++ b/backend/Ping.Api.Dockerfile @@ -0,0 +1,21 @@ +# Build context: backend/ (docker compose -f deploy/compose.yml --profile apps build) +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore: copy central config + csproj files first to cache the layer. +COPY Directory.Build.props Directory.Packages.props nuget.config ./ +COPY Core/Ping.Domain/Ping.Domain.csproj Core/Ping.Domain/ +COPY Core/Ping.Application/Ping.Application.csproj Core/Ping.Application/ +COPY Core/Ping.Infrastructure/Ping.Infrastructure.csproj Core/Ping.Infrastructure/ +COPY Hosts/Ping.Api/Ping.Api.csproj Hosts/Ping.Api/ +RUN dotnet restore Hosts/Ping.Api/Ping.Api.csproj + +COPY . . +RUN dotnet publish Hosts/Ping.Api/Ping.Api.csproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app ./ +ENV ASPNETCORE_URLS=http://+:5211 +EXPOSE 5211 +ENTRYPOINT ["dotnet", "Ping.Api.dll"] diff --git a/backend/Ping.Migrator.Dockerfile b/backend/Ping.Migrator.Dockerfile new file mode 100644 index 0000000..e5db6a2 --- /dev/null +++ b/backend/Ping.Migrator.Dockerfile @@ -0,0 +1,24 @@ +# Build context: backend/ (docker compose -f deploy/compose.yml up migrate) +# +# Applies EF Core migrations, then exits. Ping.Infrastructure is both the migrations and the +# design-time startup project: it owns the IDesignTimeDbContextFactory and the EFCore.Design +# package. The connection string comes from the Database__ConnectionString env var (compose +# sets it), read via DatabaseDesignTimeFactory's AddEnvironmentVariables(). +FROM mcr.microsoft.com/dotnet/sdk:10.0 +WORKDIR /src + +COPY Directory.Build.props Directory.Packages.props nuget.config ./ +COPY Core/Ping.Domain/Ping.Domain.csproj Core/Ping.Domain/ +COPY Core/Ping.Application/Ping.Application.csproj Core/Ping.Application/ +COPY Core/Ping.Infrastructure/Ping.Infrastructure.csproj Core/Ping.Infrastructure/ +RUN dotnet restore Core/Ping.Infrastructure/Ping.Infrastructure.csproj + +COPY Core/Ping.Domain Core/Ping.Domain/ +COPY Core/Ping.Application Core/Ping.Application/ +COPY Core/Ping.Infrastructure Core/Ping.Infrastructure/ + +RUN dotnet tool install --global dotnet-ef --version 10.0.11 +ENV PATH="$PATH:/root/.dotnet/tools" + +WORKDIR /src/Core/Ping.Infrastructure +ENTRYPOINT ["dotnet", "ef", "database", "update", "--context", "Database"] diff --git a/backend/Ping.slnx b/backend/Ping.slnx new file mode 100644 index 0000000..48d72d7 --- /dev/null +++ b/backend/Ping.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/backend/deploy/.env.example b/backend/deploy/.env.example new file mode 100644 index 0000000..70246ce --- /dev/null +++ b/backend/deploy/.env.example @@ -0,0 +1,51 @@ +# Development configuration for deploy/compose.yml + compose.dev.yml. +# Copy to deploy/.env and adjust. Not versioned. +# +# cp deploy/.env.example deploy/.env + +DOTNET_ENVIRONMENT=Development + +# --- Public URLs ------------------------------------------------------------- +PUBLIC_WEB_URL=http://localhost:3100 +PUBLIC_API_URL=http://localhost:5211 + +# Ping reuses the Zitadel and SeaweedFS instances already running for Musify instead of +# spinning up its own, so this must match Musify's PUBLIC_AUTH_URL exactly. +PUBLIC_AUTH_URL=http://host.docker.internal:8080 + +# --- PostgreSQL (Ping's own database — Zitadel keeps using Musify's) -------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=ping_db +POSTGRES_HOST_PORT=59010 + +# --- Zitadel (shared with Musify) -------------------------------------------- +# zitadel-init needs Musify's admin service-account PAT to create Ping's own project/apps +# in that same Zitadel instance. Point this at Musify's deploy/zitadel/.output folder — +# adjust if your Musify checkout lives somewhere else relative to this one. +ZITADEL_ADMIN_PAT_DIR=../../../Musify/MusifyM/deploy/zitadel/.output +ZITADEL_DEV_MODE=true + +# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills these in +# automatically on `docker compose up -d`. +AUTH_CLIENT_ID= +WEB_CLIENT_ID= + +# --- SeaweedFS (S3, shared with Musify) -------------------------------------- +# Same SeaweedFS instance as Musify, so these must be the REAL values from Musify's own +# deploy/.env (S3_ACCESS_KEY / S3_SECRET_KEY) — not the placeholders below. +S3_ACCESS_KEY=admin_access_key +S3_SECRET_KEY=admin_secret_key +S3_BUCKET=ping-storage +S3_PUBLIC_URL=http://host.docker.internal:8333 +S3_USE_HTTP=true + +# --- LiveKit (voice/video SFU) ------------------------------------------------ +# Overrides --dev's built-in "devkey/secret" pair (see compose.yml's LIVEKIT_KEYS): the +# LiveKit .NET server SDK rejects secrets shorter than 256 bits. Generate your own with: +# $b = [byte[]]::new(32); [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); [Convert]::ToBase64String($b) +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=9U1eDtZx9eQrSWern4UcWa3lYIrfvrQqExLlkUXpP+o= + +# --- Misc -------------------------------------------------------------------- +AUTH_REQUIRE_HTTPS=false diff --git a/backend/deploy/README.md b/backend/deploy/README.md new file mode 100644 index 0000000..89c2857 --- /dev/null +++ b/backend/deploy/README.md @@ -0,0 +1,29 @@ +# Ping — deploy + +Reuses the Zitadel and SeaweedFS containers already running for Musify instead of starting new +ones. Musify's stack must be up first. + +```bash +cp deploy/.env.example deploy/.env +# edit deploy/.env: +# - ZITADEL_ADMIN_PAT_DIR must point at Musify's deploy/zitadel/.output folder +# - S3_ACCESS_KEY / S3_SECRET_KEY must match the REAL values in Musify's own deploy/.env + +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +``` + +This starts Postgres, LiveKit, and the one-shot jobs (EF Core migrations, the Zitadel +project/apps for Ping, the `ping-storage` S3 bucket) — the usual dev loop runs the API and +frontend from the IDE against these. + +To also build and run the API/frontend in Docker instead: + +```bash +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build +``` + +By then `zitadel-init` has already written `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` into `deploy/.env`. + +Ports (dev): API `5211`, web `3100`, Postgres `59010`, LiveKit `7880` (signaling) / `7881` (RTC +TCP) / `50000-50100/udp` (media). Zitadel (`8080`) and the SeaweedFS S3 API (`8333`) come from +Musify's own stack. diff --git a/backend/deploy/compose.dev.yml b/backend/deploy/compose.dev.yml new file mode 100644 index 0000000..d8f036e --- /dev/null +++ b/backend/deploy/compose.dev.yml @@ -0,0 +1,39 @@ +name: ping-dev + +# Development overlay: publishes the infrastructure ports to the host so the .NET apps and +# the web frontend can run from the IDE. +# +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build + +x-host-alias: &host-alias + - "host.docker.internal:host-gateway" + +services: + postgres: + ports: + - "${POSTGRES_HOST_PORT}:5432" + + livekit: + ports: + - "7880:7880" # HTTP / WebSocket signaling + - "7881:7881" # RTC TCP fallback + - "50000-50100:50000-50100/udp" # WebRTC media + + zitadel-init: + extra_hosts: *host-alias + volumes: + - ./.env:/workspace/.env + + seaweedfs-init: + extra_hosts: *host-alias + + api: + ports: + - "5211:5211" + extra_hosts: *host-alias + + web: + ports: + - "3100:3100" + extra_hosts: *host-alias diff --git a/backend/deploy/compose.yml b/backend/deploy/compose.yml new file mode 100644 index 0000000..fb6fd63 --- /dev/null +++ b/backend/deploy/compose.yml @@ -0,0 +1,271 @@ +# Ping — base stack, shared by development and production. +# +# This intentionally does NOT run Zitadel or SeaweedFS: Ping reuses the same instances +# already running for Musify (see deploy/.env.example — ZITADEL_ADMIN_PAT_DIR / S3_*). +# Bring Musify's stack up first, then this one. +# +# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# +# The `api` and `web` services sit behind the `apps` profile, so a plain `up` starts +# Postgres, LiveKit and the one-shot bootstrap jobs (migrations, Zitadel project/apps, +# SeaweedFS bucket) and stops there — the usual dev setup, with the apps running from the +# IDE. Add `--profile apps up -d --build` as a SECOND command to also build and run them in +# Docker: by then zitadel-init has already written the OIDC client ids into the env file. + +x-app-depends: &app-depends + postgres: + condition: service_healthy + +x-backend-env: &backend-env + Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" + # The public S3 URL, not the internal one: the same address is used to sign the + # presigned upload/download URLs the browser has to be able to reach. + InfrastructureStorage__Address: ${S3_PUBLIC_URL} + InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} + InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} + InfrastructureStorage__ForcePathStyle: "true" + InfrastructureStorage__UseHttp: ${S3_USE_HTTP} + ApplicationStorage__Bucket: ${S3_BUCKET} + +services: + # --- Infrastructure ------------------------------------------------------- + + postgres: + image: postgres:17-alpine + container_name: ping-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # Voice/video SFU. --dev keeps it single-node with in-memory state (fine for local + # development), but the key pair is overridden via LIVEKIT_KEYS: the LiveKit .NET server SDK + # rejects secrets shorter than 256 bits, and --dev's built-in "devkey/secret" pair is too short. + livekit: + image: livekit/livekit-server:latest + container_name: ping-livekit + command: --dev --bind 0.0.0.0 + environment: + LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + # Node IP is "automatically determined" by default, which inside a container means the + # Docker bridge network's internal address (e.g. 172.x.x.x) — unreachable from the + # browser, so ICE/media never connects even though the WebSocket signaling (which goes + # through the published port, not this address) succeeds. Every public URL in this dev + # stack is already hardcoded to localhost (see PUBLIC_WEB_URL, VITE_LIVEKIT_URL), so the + # browser and this container are always on the same machine — advertise that directly. + NODE_IP: 127.0.0.1 + restart: unless-stopped + + # One-shot: creates the Ping project and its two OIDC apps in Musify's Zitadel instance + # (or reuses them if they already exist), then writes AUTH_CLIENT_ID / WEB_CLIENT_ID back + # into the env file mounted at /workspace/.env — so a later `--profile apps up` picks up + # real values instead of empty ones. Both apps are public clients (PKCE, no secret): the + # Scalar docs UI and the React SPA. + zitadel-init: + image: alpine:3.20 + container_name: ping-zitadel-init + volumes: + - ${ZITADEL_ADMIN_PAT_DIR}:/zitadel-output:ro + - ./.env:/workspace/.env + environment: + PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL} + PUBLIC_API_URL: ${PUBLIC_API_URL} + PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} + ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + apk add --no-cache curl jq >/dev/null + + ENV_FILE=/workspace/.env + if [ ! -f "$$ENV_FILE" ]; then + echo "no env file mounted at $$ENV_FILE, skipping" + exit 0 + fi + + env_set() { + if grep -qE "^$$2=" "$$1"; then + awk -v k="$$2" -v v="$$3" '{ sub(/\r$$/, "") } index($$0,k "=")==1{print k "=" v; next} {print}' "$$1" >"$$1.tmp" && cat "$$1.tmp" >"$$1" && rm -f "$$1.tmp" + else + printf '%s=%s\n' "$$2" "$$3" >>"$$1" + fi + } + + if [ ! -f /zitadel-output/admin-sa.pat ]; then + echo "admin-sa.pat not found under ZITADEL_ADMIN_PAT_DIR — is Musify's Zitadel running?" >&2 + exit 1 + fi + PAT=$$(tr -d '\r\n' waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" + deadline=$$(( $$(date +%s) + 60 )) + until curl -fsS "$$PUBLIC_AUTH_URL/.well-known/openid-configuration" >/dev/null 2>&1; do + [ "$$(date +%s)" -lt "$$deadline" ] || { echo "Zitadel did not respond at $$PUBLIC_AUTH_URL" >&2; exit 1; } + sleep 3 + done + + api_call() { + if [ $$# -ge 3 ]; then + curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$3" + else + curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" + fi + } + name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } + + echo "==> project" + PROJECT_NAME=Ping + PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') + if [ -z "$$PROJECT_ID" ]; then + PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id') + echo " created (id $$PROJECT_ID)" + else + echo " reused (id $$PROJECT_ID)" + fi + + find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } + + echo "==> API app (Scalar)" + API_APP_NAME="Ping API (Scalar)" + API_APP_ID=$$(find_app "$$API_APP_NAME") + if [ -n "$$API_APP_ID" ]; then + API_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$API_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " reused (clientId $$API_CLIENT_ID)" + else + API_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$API_APP_NAME" --arg api "$$PUBLIC_API_URL" '{ + name: $$name, + redirectUris: [($$api + "/scalar/"), ($$api + "/scalar/oauth2-redirect.html")], + postLogoutRedirectUris: [($$api + "/scalar/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], + appType: "OIDC_APP_TYPE_USER_AGENT", + authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", + version: "OIDC_VERSION_1_0", + devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')" | jq -r '.clientId') + echo " created (clientId $$API_CLIENT_ID)" + fi + + echo "==> web app (React SPA — public client, PKCE)" + WEB_APP_NAME="Ping Web" + WEB_APP_ID=$$(find_app "$$WEB_APP_NAME") + if [ -n "$$WEB_APP_ID" ]; then + WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " reused (clientId $$WEB_CLIENT_ID)" + else + WEB_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$WEB_APP_NAME" --arg web "$$PUBLIC_WEB_URL" '{ + name: $$name, + redirectUris: [($$web + "/auth/callback")], + postLogoutRedirectUris: [($$web + "/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + appType: "OIDC_APP_TYPE_USER_AGENT", + authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", + version: "OIDC_VERSION_1_0", + devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')" | jq -r '.clientId') + echo " created (clientId $$WEB_CLIENT_ID)" + fi + + env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID" + env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID" + echo "==> wired into $$ENV_FILE" + restart: "no" + + # One-shot: SeaweedFS does not auto-create buckets. Reuses Musify's SeaweedFS instance + # (same S3 endpoint/credentials) but creates Ping's own, separate bucket. + seaweedfs-init: + image: amazon/aws-cli:latest + container_name: ping-seaweedfs-init + environment: + AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} + AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} + AWS_DEFAULT_REGION: us-east-1 + entrypoint: ["/bin/sh", "-c"] + command: + - | + aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true + aws --endpoint-url ${S3_PUBLIC_URL} s3 ls + restart: "no" + + # One-shot: applies the EF Core migrations, then exits. + migrate: + build: + context: .. + dockerfile: Ping.Migrator.Dockerfile + container_name: ping-migrate + depends_on: + postgres: + condition: service_healthy + environment: + Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" + restart: "no" + + # --- Applications (profile: apps) ----------------------------------------- + + api: + profiles: [apps] + build: + context: .. + dockerfile: Ping.Api.Dockerfile + container_name: ping-api + depends_on: + <<: *app-depends + migrate: + condition: service_completed_successfully + environment: + <<: *backend-env + ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + ASPNETCORE_URLS: http://+:5211 + # AUTH_CLIENT_ID is written to the env file by the zitadel-init service. + Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" + Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} + Authentication__ClientId: ${AUTH_CLIENT_ID} + Authentication__AudienceAddress: ${AUTH_CLIENT_ID} + Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login" + Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token" + Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/" + Authentication__Scopes__0: openid + Authentication__Scopes__1: profile + Authentication__Scopes__2: email + Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS} + LiveKit__ApiKey: ${LIVEKIT_API_KEY} + LiveKit__ApiSecret: ${LIVEKIT_API_SECRET} + restart: unless-stopped + + web: + profiles: [apps] + build: + context: ../../frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${PUBLIC_API_URL} + VITE_AUTH_AUTHORITY: ${PUBLIC_AUTH_URL} + VITE_AUTH_CLIENT_ID: ${WEB_CLIENT_ID} + VITE_AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback" + VITE_LIVEKIT_URL: ws://localhost:7880 + container_name: ping-web + depends_on: + - api + restart: unless-stopped + +volumes: + postgres_data: diff --git a/backend/global.json b/backend/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/backend/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/backend/nuget.config b/backend/nuget.config new file mode 100644 index 0000000..be7b17d --- /dev/null +++ b/backend/nuget.config @@ -0,0 +1,15 @@ + + + + + + + + + + +