User synchronization revamped.

This commit is contained in:
2025-07-04 23:33:33 +02:00
parent 4393977389
commit c775e3a25e
14 changed files with 348 additions and 139 deletions

View File

@ -53,16 +53,22 @@ namespace MessengerApi.Configuration.Model
/// </summary>
public HousekeepingMessageStates HousekeepingMessageState { get; set; }
/// <summary>
/// File containing all user credentials.
/// </summary>
public FileInfo UsersConfig { get; set; }
public MessengerConfiguration() { }
public MessengerConfiguration(string[] origins, PersistenceConfiguration persistenceConfiguration)
{
if(persistenceConfiguration == null)
if (persistenceConfiguration == null)
{
throw new ArgumentNullException(nameof(persistenceConfiguration));
}
this.PersistenceConfiguration = persistenceConfiguration;
this.UsersConfig = new FileInfo("./users.conf");
this.Origins = origins ?? [];
this.Proxies = [];
this.RateLimitPerMinute = 120;
@ -84,6 +90,7 @@ namespace MessengerApi.Configuration.Model
Populate<int>(config, Env.HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES, x => this.HousekeepingMessageAgeInMinutes = x);
Populate<string>(config, Env.HOUSEKEEPING_MESSAGE_STATE, x => this.HousekeepingMessageState = HousekeepingMessageStateParser.Parse(x));
Populate<string>(config, Env.LOGGING_VERBOSITY, x => this.Verbosity = LoggingVerbosityParser.Parse(x));
Populate<string>(config, Env.USERSCONFIG_FILE_PATH, x => this.UsersConfig = new FileInfo(x));
void Populate<T>(IEnvironmentConfigurationSource config, string key, Action<T> set)
{

View File

@ -15,6 +15,7 @@
public const string HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES = nameof(HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES);
public const string HOUSEKEEPING_MESSAGE_STATE = nameof(HOUSEKEEPING_MESSAGE_STATE);
public const string LOGGING_VERBOSITY = nameof(LOGGING_VERBOSITY);
public const string USERSCONFIG_FILE_PATH = nameof(USERSCONFIG_FILE_PATH);
}
}
}

View File

@ -3,12 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35312.102
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Contracts", "MessengerApi.Contracts\MessengerApi.Contracts.csproj", "{833ED77F-A4E9-4FB3-BB84-4E55898B726A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.SubscriptionClient", "MessengerApi.SubscriptionClient\MessengerApi.SubscriptionClient.csproj", "{127D24B0-47F3-40E9-9136-899AFF206F19}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.QueryClient", "MessengerApi.QueryClient\MessengerApi.QueryClient.csproj", "{6441673B-2621-4E2C-A9A0-971E83C3F80A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{60B75400-A315-4B57-AFCF-5B4094785A62}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.PhonebookClient", "MessengerApi.Examples.PhonebookClient\MessengerApi.Examples.PhonebookClient.csproj", "{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}"
@ -36,6 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
..\Directory.Packages.props = ..\Directory.Packages.props
..\docker-compose.yml = ..\docker-compose.yml
..\Dockerfile = ..\Dockerfile
..\assets\example-users.config = ..\assets\example-users.config
..\NuGet.config = ..\NuGet.config
EndProjectSection
EndProject
@ -63,18 +58,6 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.Build.0 = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.Build.0 = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.Build.0 = Release|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@ -0,0 +1,263 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db.Entities;
using MessengerApi.Models;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace MessengerApi.Handlers
{
public class UserConfigHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly IDbContextFactory dbContextFactory;
public UserConfigHandler(
MessengerConfiguration configuration,
ILogger logger,
IDbContextFactory dbContextFactory)
{
this.configuration = configuration;
this.logger = logger;
this.dbContextFactory = dbContextFactory;
}
public async Task<UserIngestionItem[]> GetItemsFromFile(FileInfo file)
{
if (file == null)
{
throw new InvalidOperationException("No file provided.");
}
else if (File.Exists(file.FullName) == false)
{
throw new FileNotFoundException($"Userfile doesn't exist: {this.configuration.UsersConfig.FullName}.");
}
var lines = await File.ReadAllLinesAsync(file.FullName, Encoding.UTF8);
var collection = new List<Tuple<UserIngestionItem, string>>();
foreach (var line in lines)
{
var columns = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var item = new UserIngestionItem();
item.UserName = columns[0];
item.IsEnabled = bool.Parse(columns[1]);
item.Id = Guid.Parse(columns[2]);
item.ApiKey = Guid.Parse(columns[3]);
var recipients = columns.Length == 5
? columns[4]
: null;
collection.Add(new Tuple<UserIngestionItem, string>(item, recipients));
this.logger.Trace($"Reading user {item.UserName} with ID {item.Id}");
}
foreach (var item in collection)
{
if(item.Item2 == null)
{
item.Item1.CanSendTo = [];
continue;
}
var names = item.Item2.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
item.Item1.CanSendTo = names
.Select(x => collection.Single(c => c.Item1.UserName == x).Item1)
.ToArray();
this.logger.Trace($"User {item.Item1.UserName} has buddies {item.Item2}.");
}
return collection.Select(x => x.Item1).ToArray();
}
public Task<UserIngestionOperation[]> GetOperationsFromItems(UserIngestionItem[] fileItems)
{
if(fileItems == null)
{
throw new ArgumentNullException(nameof(fileItems));
}
using var context = this.dbContextFactory.CreateDbContext();
var existingUsers = context.Users.ToList();
var existingRoutes = context.UserRoutes.Include(x => x.From).Include(x => x.To).ToList();
var collection = new List<UserIngestionOperation>();
foreach (var item in fileItems)
{
var dbItem = context.Users.SingleOrDefault(x => x.Id == item.Id);
if (dbItem == null)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Create,
Subtype = UserIngestionOperationSubtypes.AddUser,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Local.Add(new User
{
Id = item.Id,
ApiKey = item.ApiKey,
IsEnabled = item.IsEnabled,
Name = item.UserName,
});
}
});
}
else
{
if(dbItem.ApiKey != item.ApiKey)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserApiKey,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).ApiKey = item.ApiKey;
}
});
}
if(dbItem.IsEnabled != item.IsEnabled)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserIsEnabled,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).IsEnabled = item.IsEnabled;
}
});
}
if (dbItem.Name != item.UserName)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserName,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).Name = item.UserName;
}
});
}
}
this.logger.Trace($"Processed line with {item.UserName}.");
}
this.logger.Debug("Finished evaluating add/update users, doing routes.");
foreach(var item in fileItems)
{
foreach(var com in item.CanSendTo)
{
this.logger.Trace($"Validating if {item.UserName} can send to {com.UserName}.");
var dbRoute = context.UserRoutes
.Include(x => x.From)
.Include(x => x.To)
.SingleOrDefault(x => x.From.Id == item.Id && x.To.Id == com.Id);
if(dbRoute == null)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Create,
Subtype = UserIngestionOperationSubtypes.AddUserRoute,
Username = item.UserName,
Operation = (c) =>
{
var from = c.Users
.SingleOrDefault(x => x.Id == item.Id) ??
c.Users.Local.Single(x => x.Id == item.Id);
var to = c.Users
.SingleOrDefault(x => x.Id == com.Id) ??
c.Users.Local.Single(x => x.Id == com.Id);
c.Add(new UserRoute
{
From = from,
To = to
});
}
});
}
}
}
this.logger.Debug("Finished evaluating add userroutes, doing user deletes.");
foreach(var existingDbUser in existingUsers)
{
if(fileItems.Any(x=>x.Id == existingDbUser.Id) == false)
{
collection.Add(new UserIngestionOperation
{
Username = existingDbUser.Name,
Type = UserIngestionOperationTypes.Delete,
Subtype = UserIngestionOperationSubtypes.RemoveUser,
Operation = (c) =>
{
c.Remove(c.Users.Single(x => x.Id == existingDbUser.Id));
}
});
}
}
foreach (var existingRoute in existingRoutes)
{
if (fileItems.Any(fi => fi.CanSendTo.Any(fic => fi.Id == existingRoute.From.Id && fic.Id == existingRoute.To.Id)) == false)
{
collection.Add(new UserIngestionOperation
{
Username = existingRoute.From.Name,
Type = UserIngestionOperationTypes.Delete,
Subtype = UserIngestionOperationSubtypes.RemoveUserRoute,
Operation = (c) =>
{
c.Remove(c.UserRoutes.Single(x => x.Id == existingRoute.Id));
}
});
}
}
return Task.FromResult(collection.ToArray());
}
public async Task UpdateFromFile(FileInfo file)
{
var fileItems = await this.GetItemsFromFile(file);
var operations = await this.GetOperationsFromItems(fileItems);
using var context = this.dbContextFactory.CreateDbContext();
foreach(var operation in operations)
{
this.logger.Info(operation.ToString());
operation.Operation(context);
}
if(operations.Any())
{
await context.SaveChangesAsync();
}
}
}
}

View File

@ -1,99 +0,0 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db;
using MessengerApi.Models;
using System.Text;
namespace MessengerApi.Handlers
{
// TODO: This needs to be redone, because at every run, it wipes users and creates new ones. This makes
// all existing DB messages unassignable.
public class UserSetupHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly IDbContextFactory dbContextFactory;
public UserSetupHandler(
MessengerConfiguration configuration,
ILogger logger,
IDbContextFactory dbContextFactory)
{
this.configuration = configuration;
this.logger = logger;
this.dbContextFactory = dbContextFactory;
}
public async Task UpdateFromFile(FileInfo file)
{
if(file.Exists)
{
var lines = await File.ReadAllLinesAsync(file.FullName, Encoding.UTF8);
var items = await this.ReadLines(lines);
await this.SynchronizeUsers(items);
}
}
private async Task<UserSetupItem[]> ReadLines(string[] lines)
{
var items = new List<UserSetupItem>();
foreach (var line in lines)
{
var values = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var item = new UserSetupItem
{
UserName = values[0],
ApiKey = values[1],
};
if(values.Length > 2)
{
item.CanSendToUserNames = values[2].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
items.Add(item);
}
if (items.GroupBy(x => x.UserName).Any(x => x.Count() > 1))
{
throw new InvalidOperationException("Usernames are not unique. One username per line.");
}
else if(items.GroupBy(x=>x.ApiKey).Any(x=>x.Count() > 1))
{
throw new InvalidOperationException("API keys are not unique. One API key per line.");
}
return items.ToArray();
}
private Task SynchronizeUsers(IEnumerable<UserSetupItem> users)
{
using var db = this.dbContextFactory.CreateDbContext();
db.RemoveRange(db.Users);
db.RemoveRange(db.UserRoutes);
var dbUsers = users.Select(x => new Db.Entities.User
{
Id = new Guid(),
Name = x.UserName,
ApiKey = Guid.Parse(x.ApiKey),
IsEnabled = true
});
var dbRoutes = users.SelectMany(x => x.CanSendToUserNames.Select(cs => new Db.Entities.UserRoute
{
Id = new Guid(),
From = dbUsers.Single(dbu => dbu.Name == x.UserName),
To = dbUsers.Single(dbu => dbu.Name == x.UserName)
}));
db.AddRange(dbUsers);
db.AddRange(dbRoutes);
db.SaveChanges();
return Task.CompletedTask;
}
}
}

View File

@ -21,7 +21,6 @@
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Configuration\MessengerApi.Configuration.csproj" />
<ProjectReference Include="..\MessengerApi.Contracts\MessengerApi.Contracts.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj" />
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Models
{
public class UserIngestionItem
{
public string UserName { get; set; }
public Guid Id { get; set; }
public Guid ApiKey { get; set; }
public bool IsEnabled { get; set; }
public UserIngestionItem[] CanSendTo { get; set; }
}
}

View File

@ -0,0 +1,21 @@
using MessengerApi.Db;
namespace MessengerApi.Models
{
public class UserIngestionOperation
{
public UserIngestionOperationTypes Type { get; set; }
public UserIngestionOperationSubtypes Subtype { get; set; }
public string Username { get; set; }
public Action<MessengerDbContext> Operation { get; set; }
public override string ToString()
{
var value = $"Operation: {this.Type}/{this.Subtype} for {this.Username}.";
return value;
}
}
}

View File

@ -0,0 +1,14 @@
namespace MessengerApi.Models
{
public enum UserIngestionOperationSubtypes
{
Unknown,
ChangeUserName,
ChangeUserApiKey,
ChangeUserIsEnabled,
AddUser,
AddUserRoute,
RemoveUser,
RemoveUserRoute
}
}

View File

@ -0,0 +1,10 @@
namespace MessengerApi.Models
{
public enum UserIngestionOperationTypes
{
Unknown,
Create,
Update,
Delete
}
}

View File

@ -1,11 +0,0 @@
namespace MessengerApi.Models
{
public class UserSetupItem
{
public string UserName { get; set; }
public string ApiKey { get; set; }
public string[] CanSendToUserNames { get; set; }
}
}

View File

@ -14,6 +14,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading.RateLimiting;
namespace MessengerApi.Api
@ -43,7 +44,7 @@ namespace MessengerApi.Api
builder.Services.AddSingleton<ILogger>(new Factories.LoggerFactory(configuration).CreateLogger());
builder.Services.AddSingleton<SendEndpointHandler>();
builder.Services.AddSingleton<HousekeepingHandler>();
builder.Services.AddSingleton<UserSetupHandler>();
builder.Services.AddSingleton<UserConfigHandler>();
builder.Services.AddSingleton<IDbContextFactory, DbContextFactory>();
builder.Services.AddScoped<Timing>();
@ -142,9 +143,13 @@ namespace MessengerApi.Api
}
// User synchronization
var userSetupHandler = app.Services.GetRequiredService<UserSetupHandler>();
userSetupHandler.UpdateFromFile(new FileInfo(Constants.USERFILE_FILENAME)).GetAwaiter().GetResult();
if (configuration.UsersConfig != null)
{
app.Services.GetRequiredService<ILogger>().Info($"Running userconfig synchronization for {configuration.UsersConfig.FullName}.");
var handler = app.Services.GetRequiredService<UserConfigHandler>();
handler.UpdateFromFile(configuration.UsersConfig).GetAwaiter().GetResult();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();

View File

@ -5,6 +5,7 @@
"launchBrowser": true,
"environmentVariables": {
"PERSISTENCE_TYPE": "Sql",
"USERSCONFIG_FILE_PATH": "./../../assets/example-users.config",
"CORS_ORIGINS": "",
"PROXIES": "",
"QUERY_RATE_PER_MINUTE": "100",