First commit that builds.

This commit is contained in:
2025-06-29 14:43:35 +02:00
parent 3944764cb5
commit 6d15dcb985
24 changed files with 816 additions and 0 deletions

View File

@ -0,0 +1,29 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["MessengerBroker/MessengerBroker.csproj", "MessengerBroker/"]
RUN dotnet restore "./MessengerBroker/MessengerBroker.csproj"
COPY . .
WORKDIR "/src/MessengerBroker"
RUN dotnet build "./MessengerBroker.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./MessengerBroker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MessengerBroker.dll"]

View File

@ -0,0 +1,22 @@
using System.Linq.Expressions;
namespace MessengerBroker
{
public static class EntityExtensions
{
public static IQueryable<Db.Model.User> GetUsers(this IQueryable<Db.Model.User> source, Guid[] guids)
{
return source.Where(x => guids.Any(g => g == x.Id));
}
public static IQueryable<Db.Model.User> GetUsersExcept(this IQueryable<Db.Model.User> source, IEnumerable<Guid> guids)
{
return source.Where(x => !guids.Any(g => g == x.Id));
}
public static bool GetUserExists(this IQueryable<Db.Model.User> source, Guid id)
{
return source.Any(x=> x.Id == id);
}
}
}

View File

@ -0,0 +1,28 @@
namespace MessengerBroker.Handlers
{
public class AuthHandler
{
private readonly Settings settings;
public AuthHandler(Settings settings)
{
this.settings = settings;
}
public Guid? Auth(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].ToString();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
if (Guid.TryParse(token, out Guid brokerId) && this.settings.Slaves.Any(x => x.BrokerId == brokerId))
{
return brokerId;
}
}
return null;
}
}
}

View File

@ -0,0 +1,81 @@
using MessengerApi.Db;
using MessengerBroker.Db;
using Microsoft.EntityFrameworkCore;
namespace MessengerBroker.Handlers
{
public class DataHandler
{
private readonly Settings _settings;
public Task<Tuple<MessengerApi.Db.Entities.User[], MessengerApi.Db.Entities.UserRoute[]>> GetLocalUsersAndRoutes()
{
var foreignUserIds = (Guid[])null;
using(var broCtx = new BrokerDbContext(this._settings.MessengerBrokerDbConnectionString))
{
foreignUserIds = broCtx.Users.Select(x => x.Id).ToArray();
}
using (var apiCtx = new MessengerDbContext(this._settings.MessengerApiDbConnectionString))
{
var localUsers = apiCtx.Users
.Where(x => !foreignUserIds.Any(f => f == x.Id))
.ToArray();
var localRoutes = apiCtx.UserRoutes
.Include(x => x.From)
.Include(x => x.To)
.Where(x => localUsers.Any(l => l.Id == x.From.Id) && localUsers.Any(l => l.Id == x.To.Id))
.ToArray();
return Task.FromResult(new Tuple<MessengerApi.Db.Entities.User[], MessengerApi.Db.Entities.UserRoute[]>(
localUsers,
localRoutes));
}
}
private Task<MessengerApi.Db.Entities.User[]> GetForeignUsers(Guid brokerId)
{
var foreignUserIds = (Guid[])null;
using (var broCtx = new BrokerDbContext(this._settings.MessengerBrokerDbConnectionString))
{
foreignUserIds = broCtx.Users.Where(x=>x.BrokerId == brokerId).Select(x => x.Id).ToArray();
}
using (var apiCtx = new MessengerDbContext(this._settings.MessengerApiDbConnectionString))
{
var localUsers = apiCtx.Users
.Where(x => !foreignUserIds.Any(f => f == x.Id))
.ToArray();
return Task.FromResult(localUsers);
}
}
public async Task<MessengerApi.Db.Entities.Message[]> GetMessages(Guid brokerId, DateTime sinceUtc)
{
var userIds = (Guid[])null;
if(brokerId == this._settings.BrokerId)
{
// Our messages.
var users = await this.GetLocalUsersAndRoutes();
userIds = users.Item1.Select(x => x.Id).ToArray();
}
else
{
var users = await this.GetForeignUsers(brokerId);
userIds = users.Select(x => x.Id).ToArray();
}
using (var apiCtx = new MessengerDbContext(this._settings.MessengerApiDbConnectionString))
{
var messages = apiCtx.Messages
.Include(x => x.From).Include(x => x.To)
.Where(x => x.CreatedUtc >= sinceUtc && userIds.Contains(x.From.Id))
.ToArray();
return messages;
}
}
}
}

View File

@ -0,0 +1,196 @@
using MessengerApi.Db;
using MessengerBroker.Db;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace MessengerBroker.Handlers
{
public class MasterHandler
{
private readonly Settings settings;
private readonly HttpClient httpClient = new HttpClient();
public async Task BeginSyncingWithMaster(Settings.MasterServer masterServer, CancellationToken ct = default)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1));
var usersRequest = new HttpRequestMessage(HttpMethod.Get, $"{masterServer.BrokerApiUrl}/users");
usersRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", this.settings.BrokerId.ToString());
var usersResponseMessage = await this.httpClient.SendAsync(usersRequest, ct);
if (!usersResponseMessage.IsSuccessStatusCode)
{
continue;
}
var usersResponse = JsonSerializer.Deserialize<Model.Http.Users.UsersResponse>(await usersResponseMessage.Content.ReadAsStringAsync());
using (var broCtx = new BrokerDbContext(this.settings.MessengerBrokerDbConnectionString))
using (var apiCtx = new MessengerDbContext(this.settings.MessengerApiDbConnectionString))
{
var updatedUsers = usersResponse.Users.Where(x => broCtx.Users.GetUserExists(x.Id)).ToList();
updatedUsers.ForEach(async x => await this.UpdateUser(x, apiCtx));
var deletedUsers = broCtx.Users.GetUsersExcept(usersResponse.Users.Select(x => x.Id)).ToList();
deletedUsers.ForEach(async x => await this.RemoveUser(x.Id, broCtx, apiCtx));
var addedUsers = usersResponse.Users.Where(x => !broCtx.Users.GetUserExists(x.Id)).ToList();
addedUsers.ForEach(async x => await this.AddUser(x, masterServer.BrokerId, broCtx, apiCtx));
foreach (var route in usersResponse.UserRoutes
.Where(r => apiCtx.UserRoutes
.Include(x => x.From)
.Include(x => x.To)
.Any(apir => apir.Id == r.Id && (apir.From.Id != r.FromId || apir.To.Id != r.ToId))))
{
var existing = apiCtx.UserRoutes.Include(x => x.From).Include(x => x.To).Single(x => x.Id == route.Id);
existing.From = apiCtx.Users.Single(x => x.Id == route.FromId);
existing.To = apiCtx.Users.Single(x => x.Id == route.ToId);
}
foreach (var deletedRoute in apiCtx.UserRoutes
.Where(x => !usersResponse.UserRoutes.Any(r => r.Id == x.Id)))
{
apiCtx.UserRoutes.Remove(deletedRoute);
}
foreach (var addedRoute in usersResponse.UserRoutes
.Where(r => !apiCtx.UserRoutes.Any(x => x.Id == r.Id)))
{
apiCtx.UserRoutes.Add(new MessengerApi.Db.Entities.UserRoute
{
Id = addedRoute.Id,
From = apiCtx.Users.Single(x => x.Id == addedRoute.FromId),
To = apiCtx.Users.Single(x => x.Id == addedRoute.ToId)
});
}
broCtx.SaveChanges();
apiCtx.SaveChanges();
}
var messagesRequest = new HttpRequestMessage(HttpMethod.Get, $"{masterServer.BrokerApiUrl}/messages");
messagesRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", this.settings.BrokerId.ToString());
var messagesResponseMessage = await this.httpClient.SendAsync(messagesRequest, ct);
if (!messagesResponseMessage.IsSuccessStatusCode)
{
continue;
}
var messagesResponse = JsonSerializer.Deserialize<Model.Http.Sync.SyncResponse>(await messagesResponseMessage.Content.ReadAsStringAsync());
using (var broCtx = new BrokerDbContext(this.settings.MessengerBrokerDbConnectionString))
using (var apiCtx = new MessengerDbContext(this.settings.MessengerApiDbConnectionString))
{
var addedMessages = messagesResponse.Messages
.Where(m => broCtx.Messages.Any(x => x.BrokerId == masterServer.BrokerId && x.Id == m.Id) == false)
.ToList();
addedMessages.ForEach(x =>
{
broCtx.Messages.Add(new Db.Model.Message
{
BrokerId = masterServer.BrokerId,
Id = x.Id
});
apiCtx.Messages.Add(new MessengerApi.Db.Entities.Message
{
Id = x.Id,
CreatedUtc = x.CreatedUtc,
From = x.From != null ? (apiCtx.Users.SingleOrDefault(u => u.Id == x.From.Value)) ?? null : null,
To = x.To != null ? (apiCtx.Users.SingleOrDefault(u => u.Id == x.To.Value)) ?? null : null,
IsAcknowledged = x.IsAcknowledged,
IsDelivered = x.IsDelivered,
Payload = x.Payload,
PayloadId = x.PayloadId,
PayloadLifespanInSeconds = x.PayloadLifespanInSeconds,
PayloadTimestamp = x.PayloadTimestamp,
PayloadType = x.PayloadType,
});
});
var existingMessages = messagesResponse.Messages
.Except(addedMessages)
.ToList();
existingMessages.ForEach(x =>
{
var existing = apiCtx.Messages.SingleOrDefault(a => a.Id == x.Id);
if(existing != null && (existing.IsDelivered != x.IsDelivered || existing.IsAcknowledged != x.IsAcknowledged))
{
existing.IsDelivered = x.IsDelivered;
existing.IsAcknowledged = x.IsAcknowledged;
}
});
broCtx.SaveChanges();
apiCtx.SaveChanges();
}
}
}
private async Task RemoveUser(Guid id, BrokerDbContext broCtx, MessengerDbContext apiCtx)
{
var broUser = await broCtx.Users.SingleOrDefaultAsync(x => x.Id == id);
if (broUser != null)
{
broCtx.Users.Remove(broUser);
}
var apiUser = await apiCtx.Users.SingleOrDefaultAsync(x => x.Id == id);
if (apiUser != null)
{
apiCtx.Users.Remove(apiUser);
}
}
private async Task AddUser(Model.Http.Users.UsersResponse.User user, Guid brokerId, BrokerDbContext broCtx, MessengerDbContext apiCtx)
{
if (broCtx.Users.GetUserExists(user.Id) == false)
{
broCtx.Users.Add(new Db.Model.User
{
Id = user.Id,
BrokerId = brokerId
});
}
if (apiCtx.Users.Any(x => x.Id == user.Id))
{
await this.UpdateUser(user, apiCtx);
}
else
{
await apiCtx.Users.AddAsync(new MessengerApi.Db.Entities.User
{
Id = user.Id,
ApiKey = user.ApiKey,
CanReceive = user.CanReceive,
CanSend = user.CanSend,
IsEnabled = user.IsEnabled,
Name = user.Name
});
}
}
private async Task UpdateUser(Model.Http.Users.UsersResponse.User user, MessengerDbContext apiCtx)
{
var apiUser = await apiCtx.Users.SingleAsync(x => x.Id == user.Id);
apiUser.ApiKey = user.ApiKey;
apiUser.CanReceive = user.CanReceive;
apiUser.CanSend = user.CanSend;
apiUser.IsEnabled = user.IsEnabled;
apiUser.Name = user.Name;
}
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1-Preview.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\sub\messengerapi\code\MessengerApi.Db\MessengerApi.Db.csproj" />
<ProjectReference Include="..\MessengerBroker.Db\MessengerBroker.Db.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@MessengerBroker_HostAddress = http://localhost:5048
GET {{MessengerBroker_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,42 @@
namespace MessengerBroker.Model.Http
{
public class Sync
{
public class SyncRequest
{
public Guid BrokerId { get; set; }
public DateTime SinceUtc { get; set; }
}
public class SyncResponse
{
public Message[] Messages { get; set; }
public class Message
{
public Guid Id { get; set; }
public DateTime CreatedUtc { get; set; }
public Guid? From { get; set; }
public Guid? To { get; set; }
public bool IsDelivered { get; set; }
public bool IsAcknowledged { get; set; }
public string PayloadId { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
public DateTime? PayloadTimestamp { get; set; }
public int? PayloadLifespanInSeconds { get; set; }
}
}
}
}

View File

@ -0,0 +1,36 @@
namespace MessengerBroker.Model.Http
{
public class Users
{
public class UsersResponse
{
public User[] Users { get; set; }
public UserRoute[] UserRoutes { get; set; }
public class User
{
public Guid Id { get; set; }
public Guid ApiKey { get; set; }
public string Name { get; set; }
public bool IsEnabled { get; set; }
public bool CanSend { get; set; }
public bool CanReceive { get; set; }
}
public class UserRoute
{
public Guid Id { get; set; }
public Guid FromId { get; set; }
public Guid ToId { get; set; }
}
}
}
}

View File

@ -0,0 +1,102 @@
using MessengerBroker.Handlers;
using MessengerBroker.Model.Http;
using System.Runtime.CompilerServices;
namespace MessengerBroker
{
public class Program
{
public static void Main(string[] args)
{
var settings = (Settings)null;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/users", async (HttpContext httpContext) =>
{
var authHandler = app.Services.GetService<AuthHandler>();
var brokerId = authHandler.Auth(httpContext);
if(brokerId == null)
{
return Results.Unauthorized();
}
var dataHandler = app.Services.GetService<DataHandler>();
var usersAndRoutes = await dataHandler.GetLocalUsersAndRoutes();
var response = new Users.UsersResponse
{
Users = usersAndRoutes.Item1.Select(x => new Users.UsersResponse.User
{
Id = x.Id,
Name = x.Name,
ApiKey = x.ApiKey,
CanReceive = x.CanReceive,
CanSend = x.CanSend,
IsEnabled = x.IsEnabled,
}).ToArray(),
UserRoutes = usersAndRoutes.Item2.Select(x => new Users.UsersResponse.UserRoute
{
Id = x.Id,
FromId = x.From.Id,
ToId = x.To.Id
}).ToArray()
};
return Results.Json(response);
});
app.MapGet("/sync", async (HttpContext httpContext, [AsParameters] Sync.SyncRequest request) =>
{
var authHandler = app.Services.GetService<AuthHandler>();
var brokerId = authHandler.Auth(httpContext);
if(brokerId == null)
{
return Results.Unauthorized();
}
else if(request.BrokerId != brokerId && request.BrokerId != settings.BrokerId)
{
return Results.Unauthorized();
}
var dataHandler = app.Services.GetService<DataHandler>();
var messages = await dataHandler.GetMessages(request.BrokerId, request.SinceUtc);
var response = new Sync.SyncResponse
{
Messages = messages.Select(x => new Sync.SyncResponse.Message
{
Id = x.Id,
CreatedUtc = x.CreatedUtc,
From = x.From.Id,
To = x.To.Id,
IsAcknowledged = x.IsAcknowledged,
IsDelivered = x.IsDelivered,
Payload = x.Payload,
PayloadId = x.PayloadId,
PayloadLifespanInSeconds = x.PayloadLifespanInSeconds,
PayloadTimestamp = x.PayloadTimestamp,
PayloadType = x.PayloadType
}).ToArray()
};
return Results.Json(response);
});
_ = Task.Run(async () =>
{
var cts = new CancellationTokenSource();
var handler = app.Services.GetService<MasterHandler>();
foreach(var master in settings.Masters)
{
_ = handler.BeginSyncingWithMaster(master, cts.Token);
}
});
app.Run();
}
}
}

View File

@ -0,0 +1,22 @@
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5048"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": false
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@ -0,0 +1,36 @@
namespace MessengerBroker
{
public class Settings
{
/// <summary>
/// Connection string to Messenger API DB.
/// </summary>
public string MessengerApiDbConnectionString { get; set; }
public string MessengerBrokerDbConnectionString { get; set; }
public Guid BrokerId { get; set; }
public MasterServer[] Masters { get; set; }
public SlaveServer[] Slaves { get; set; }
/// <summary>
/// A server that we are a slave to. If this server goes down, their users will alternate to us and we have to provide service during outage. We pull data from this server.
/// </summary>
public class MasterServer
{
public string BrokerApiUrl { get; set; }
public Guid BrokerId { set; get; }
}
/// <summary>
/// A server that slaves to us in case of our own outage. They pull from us.
/// </summary>
public class SlaveServer
{
public Guid BrokerId { get; set; }
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}