Add Ping backend

.NET 10 solution for Ping, a Discord style chat app: Domain, Application
and Infrastructure projects plus the Api host. SignalR for chat and
presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible
storage for attachments, and OIDC auth shared with the Musify Zitadel
instance. Includes the Compose stack for local development.
This commit is contained in:
2026-09-21 19:32:55 +02:00
commit 0623875dfa
126 changed files with 5048 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Shared;
using Ping.Domain.ValueObjects;
namespace Ping.Api.Hubs;
[Authorize]
public sealed class ChatHub(IDatabase database, PresenceTracker presence) : Hub
{
public override async Task OnConnectedAsync()
{
if (presence.AddConnection(GetUserId()))
await BroadcastPresenceAsync(GetUserId());
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (presence.RemoveConnection(GetUserId()))
await BroadcastPresenceAsync(GetUserId());
await base.OnDisconnectedAsync(exception);
}
public async Task SetBusy(bool busy)
{
var userId = GetUserId();
presence.SetBusy(userId, busy);
await BroadcastPresenceAsync(userId);
}
private async Task BroadcastPresenceAsync(long userId)
{
var friendIds = await database.Friendships.AsNoTracking()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId))
.Select(f => f.RequesterId == userId ? f.AddresseeId : f.RequesterId)
.ToListAsync(CancellationToken.None);
if (friendIds.Count == 0)
return;
await Clients.Users(friendIds.Select(id => id.ToString()))
.SendAsync("PresenceChanged", userId.ToString(), presence.GetStatus(userId).ToString());
}
public async Task JoinChannel(Guid channelId)
{
var hasAccess = await database.HasAccessAsync(channelId, GetUserId(), Context.ConnectionAborted);
if (!hasAccess)
throw new HubException("Channel not found");
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(channelId));
}
public Task LeaveChannel(Guid channelId) =>
Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(channelId));
public Task Typing(Guid channelId) =>
Clients.OthersInGroup(GroupName(channelId)).SendAsync("Typing", GetUserId(), channelId);
public async Task RingUser(Guid channelId, string calleeId)
{
var callerId = GetUserId();
if (!long.TryParse(calleeId, out var calleeUserId))
throw new HubException("Invalid user id");
var callerHasAccess = await database.HasAccessAsync(channelId, callerId, Context.ConnectionAborted);
var calleeHasAccess = await database.HasAccessAsync(channelId, calleeUserId, Context.ConnectionAborted);
if (!callerHasAccess || !calleeHasAccess)
throw new HubException("Channel not found");
await Clients.User(calleeId).SendAsync("IncomingCall", channelId, callerId.ToString());
}
public Task DeclineCall(Guid channelId, string callerId) =>
Clients.User(callerId).SendAsync("CallDeclined", channelId);
public Task CancelCall(Guid channelId, string calleeId) =>
Clients.User(calleeId).SendAsync("CallCancelled", channelId);
private long GetUserId()
{
var rawId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(rawId, out var id) ? id : throw new HubException("User Id claim is missing or invalid");
}
internal static string GroupName(Guid channelId) => $"channel:{channelId}";
}
@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
using Ping.Application.Contracts;
namespace Ping.Api.Hubs;
public sealed class PresenceTracker : IPresenceTracker
{
private readonly ConcurrentDictionary<long, int> _connectionCounts = new();
private readonly ConcurrentDictionary<long, byte> _busyUserIds = new();
public bool AddConnection(long userId) => _connectionCounts.AddOrUpdate(userId, 1, (_, count) => count + 1) == 1;
public bool RemoveConnection(long userId)
{
while (_connectionCounts.TryGetValue(userId, out var count))
{
if (count <= 1)
{
if (!_connectionCounts.TryRemove(userId, out _))
continue;
_busyUserIds.TryRemove(userId, out _);
return true;
}
if (_connectionCounts.TryUpdate(userId, count - 1, count))
return false;
}
return false;
}
public void SetBusy(long userId, bool busy)
{
if (busy)
_busyUserIds[userId] = 0;
else
_busyUserIds.TryRemove(userId, out _);
}
public PresenceStatus GetStatus(long userId)
{
if (!_connectionCounts.ContainsKey(userId))
return PresenceStatus.Offline;
return _busyUserIds.ContainsKey(userId) ? PresenceStatus.Busy : PresenceStatus.Online;
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.SignalR;
using Ping.Application.Contracts;
using Ping.Application.Messages.Responses;
namespace Ping.Api.Hubs;
public sealed class RealtimeNotifier(IHubContext<ChatHub> hubContext) : IRealtimeNotifier
{
public Task MessageCreatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken) =>
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageCreated", message, cancellationToken);
public Task MessageUpdatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken) =>
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageUpdated", message, cancellationToken);
public Task MessageDeletedAsync(Guid channelId, Guid messageId, CancellationToken cancellationToken) =>
hubContext.Clients.Group(ChatHub.GroupName(channelId)).SendAsync("MessageDeleted", messageId, cancellationToken);
public Task FriendRequestReceivedAsync(long addresseeId, Guid friendshipId, CancellationToken cancellationToken) =>
hubContext.Clients.User(addresseeId.ToString()).SendAsync("FriendRequestReceived", friendshipId, cancellationToken);
public Task FriendshipUpdatedAsync(long userId, Guid friendshipId, CancellationToken cancellationToken) =>
hubContext.Clients.User(userId.ToString()).SendAsync("FriendshipUpdated", friendshipId, cancellationToken);
}