Initial commit carried over from private repo. This is V2.
This commit is contained in:
13
code/MessengerApi/.config/dotnet-tools.json
Normal file
13
code/MessengerApi/.config/dotnet-tools.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
7
code/MessengerApi/Constants.cs
Normal file
7
code/MessengerApi/Constants.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace MessengerApi
|
||||
{
|
||||
public class Constants
|
||||
{
|
||||
public const string USERFILE_FILENAME = "/app/users.conf";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using MessengerApi.Db;
|
||||
|
||||
namespace MessengerApi.Contracts.Factories
|
||||
{
|
||||
public interface IDbContextFactory
|
||||
{
|
||||
MessengerDbContext CreateDbContext();
|
||||
}
|
||||
}
|
||||
15
code/MessengerApi/Contracts/Models/Scoped/IUnitOfWork.cs
Normal file
15
code/MessengerApi/Contracts/Models/Scoped/IUnitOfWork.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using MessengerApi.Db.Contracts.Repositories;
|
||||
|
||||
namespace MessengerApi.Contracts.Models.Scoped
|
||||
{
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
IUserRepository Users { get; }
|
||||
|
||||
IUserRouteRepository UserRoutes { get; }
|
||||
|
||||
IMessageRepository Messages { get; }
|
||||
|
||||
Task SaveChanges(CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
8
code/MessengerApi/Dockerfile
Normal file
8
code/MessengerApi/Dockerfile
Normal file
@ -0,0 +1,8 @@
|
||||
#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.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER app
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
COPY ./publish .
|
||||
ENTRYPOINT ["dotnet", "MessengerApi.dll"]
|
||||
34
code/MessengerApi/Factories/DbContextFactory.cs
Normal file
34
code/MessengerApi/Factories/DbContextFactory.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using MessengerApi.Configuration.Model;
|
||||
using MessengerApi.Configuration.Model.Persistence;
|
||||
using MessengerApi.Contracts.Factories;
|
||||
using MessengerApi.Db;
|
||||
using MessengerApi.Db.Npg;
|
||||
using MessengerApi.Db.Sql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MessengerApi.Factories
|
||||
{
|
||||
public class DbContextFactory : IDbContextFactory, IDbContextFactory<MessengerDbContext>
|
||||
{
|
||||
private readonly MessengerConfiguration configuration;
|
||||
|
||||
public DbContextFactory(MessengerConfiguration configuration)
|
||||
{
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public MessengerDbContext CreateDbContext()
|
||||
{
|
||||
if (this.configuration.PersistenceConfiguration.PersistenceType == Configuration.Enums.PersistenceTypes.Sql)
|
||||
{
|
||||
return new MessengerSqlDbContext((configuration.PersistenceConfiguration as SqlPersistenceConfiguration).ConnectionString);
|
||||
}
|
||||
else if (this.configuration.PersistenceConfiguration.PersistenceType == Configuration.Enums.PersistenceTypes.PostgreSql)
|
||||
{
|
||||
return new MessengerNpgDbContext((configuration.PersistenceConfiguration as NpgPersistenceConfiguration).ConnectionString);
|
||||
}
|
||||
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
32
code/MessengerApi/Factories/LoggerFactory.cs
Normal file
32
code/MessengerApi/Factories/LoggerFactory.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using MessengerApi.Configuration.Model;
|
||||
|
||||
namespace MessengerApi.Factories
|
||||
{
|
||||
public class LoggerFactory : IServiceProvider
|
||||
{
|
||||
private readonly MessengerConfiguration _configuration;
|
||||
|
||||
public LoggerFactory(MessengerConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger()
|
||||
{
|
||||
var logger = new ConsoleLogger()
|
||||
{
|
||||
IsDebugOutputEnabled = (this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Debug || this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Trace)
|
||||
? true : false,
|
||||
IsTraceOutputEnabled = this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Trace
|
||||
? true : false
|
||||
};
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
public object GetService(Type serviceType)
|
||||
{
|
||||
return this.CreateLogger();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
code/MessengerApi/GlobalUsings.cs
Normal file
2
code/MessengerApi/GlobalUsings.cs
Normal file
@ -0,0 +1,2 @@
|
||||
global using portaloggy;
|
||||
global using ILogger = portaloggy.ILogger;
|
||||
@ -0,0 +1,81 @@
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Models;
|
||||
using MessengerApi.Models.Scoped;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace MessengerApi.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates our permananet API keys sent over as Bearer tokens.
|
||||
/// </summary>
|
||||
public class CustomBearerAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly IMemoryCache memoryCache;
|
||||
|
||||
public CustomBearerAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
UrlEncoder encoder,
|
||||
IMemoryCache memoryCache)
|
||||
: base(options, loggerFactory, encoder)
|
||||
{
|
||||
this.memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
const string HEADER = "Authorization";
|
||||
const string PREFIX = "Bearer ";
|
||||
|
||||
Context.RequestServices.GetRequiredService<Timing>(); // creates the object in scope.
|
||||
|
||||
if (!Request.Headers.TryGetValue(HEADER, out var authHeader) ||
|
||||
!authHeader.ToString().StartsWith(PREFIX))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var token = authHeader.ToString().Substring(PREFIX.Length).Trim();
|
||||
|
||||
if(this.memoryCache.TryGetValue(token, out CachedIdentity oldCache))
|
||||
{
|
||||
var identity = Context.RequestServices.GetRequiredService<Identity>();
|
||||
identity.User = oldCache.User;
|
||||
identity.UserRoutes = oldCache.UserRoutes;
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(oldCache.ClaimsPrincipal, Scheme.Name)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var unitOfWork = Context.RequestServices.GetRequiredService<IUnitOfWork>();
|
||||
var user = unitOfWork.Users.SingleByApiKeyAndEnabled(Guid.Parse(token), true);
|
||||
var routes = unitOfWork.UserRoutes.GetAllByUser(user).ToArray();
|
||||
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Name),
|
||||
new Claim(ClaimTypes.Name, user.Name)
|
||||
}, Scheme.Name));
|
||||
|
||||
var cache = new CachedIdentity
|
||||
{
|
||||
ClaimsPrincipal = principal,
|
||||
User = user,
|
||||
UserRoutes = routes
|
||||
};
|
||||
|
||||
this.memoryCache.Set(token, cache, TimeSpan.FromMinutes(5));
|
||||
|
||||
var identity = Context.RequestServices.GetRequiredService<Identity>();
|
||||
identity.User = cache.User;
|
||||
identity.UserRoutes = cache.UserRoutes;
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(cache.ClaimsPrincipal, Scheme.Name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
code/MessengerApi/Handlers/Endpoint/AckEndpointHandler.cs
Normal file
40
code/MessengerApi/Handlers/Endpoint/AckEndpointHandler.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Models.Scoped;
|
||||
|
||||
namespace MessengerApi.Handlers.Endpoint
|
||||
{
|
||||
public class AckEndpointHandler
|
||||
{
|
||||
private readonly ILogger logger;
|
||||
private readonly IUnitOfWork unitOfWork;
|
||||
private readonly Identity identity;
|
||||
|
||||
public AckEndpointHandler(
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
Identity identity)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.unitOfWork = unitOfWork;
|
||||
this.identity = identity;
|
||||
}
|
||||
|
||||
public async Task AckMessage(Guid messageId)
|
||||
{
|
||||
var message = unitOfWork.Messages.GetById(messageId);
|
||||
|
||||
// Authorize.
|
||||
if (message.ToId != this.identity.User.Id)
|
||||
{
|
||||
throw new InvalidOperationException("It's not your message to ack.");
|
||||
}
|
||||
else if(!message.IsDelivered)
|
||||
{
|
||||
throw new InvalidOperationException("Can't ack undelivered message.");
|
||||
}
|
||||
|
||||
// Act.
|
||||
message.IsAcknowledged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
code/MessengerApi/Handlers/Endpoint/PeekEndpointHandler.cs
Normal file
35
code/MessengerApi/Handlers/Endpoint/PeekEndpointHandler.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Models.Scoped;
|
||||
|
||||
namespace MessengerApi.Handlers.Endpoint
|
||||
{
|
||||
public class PeekEndpointHandler
|
||||
{
|
||||
private readonly ILogger logger;
|
||||
|
||||
private readonly Timing timing;
|
||||
private readonly Identity identity;
|
||||
private readonly IUnitOfWork unitOfWork;
|
||||
|
||||
public PeekEndpointHandler(
|
||||
ILogger logger,
|
||||
Timing timing,
|
||||
Identity identity,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.timing = timing;
|
||||
this.identity = identity;
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public Task<int> Peek()
|
||||
{
|
||||
var pendingMessages = this.unitOfWork.Messages.GetPendingMessages(this.identity.User);
|
||||
|
||||
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is receiving {pendingMessages.Count()}.");
|
||||
|
||||
return Task.FromResult(pendingMessages.Count());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Db.Entities;
|
||||
using MessengerApi.Models.Scoped;
|
||||
|
||||
namespace MessengerApi.Handlers.Endpoint
|
||||
{
|
||||
public class ReceiveEndpointHandler
|
||||
{
|
||||
private readonly ILogger logger;
|
||||
|
||||
private readonly Timing timing;
|
||||
private readonly Identity identity;
|
||||
private readonly IUnitOfWork unitOfWork;
|
||||
|
||||
public ReceiveEndpointHandler(
|
||||
ILogger logger,
|
||||
Timing timing,
|
||||
Identity identity,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.timing = timing;
|
||||
this.identity = identity;
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public Task<Message[]> ReceiveMessages()
|
||||
{
|
||||
var pendingMessages = this.unitOfWork.Messages.GetPendingMessages(this.identity.User);
|
||||
|
||||
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is receiving {pendingMessages.Count()}.");
|
||||
|
||||
if (!pendingMessages.Any())
|
||||
{
|
||||
return Task.FromResult(new Message[0]);
|
||||
}
|
||||
|
||||
var messages = pendingMessages.ToList();
|
||||
messages.ForEach(x => x.IsDelivered = true);
|
||||
return Task.FromResult(messages.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
60
code/MessengerApi/Handlers/Endpoint/SendEndpointHandler.cs
Normal file
60
code/MessengerApi/Handlers/Endpoint/SendEndpointHandler.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using MessengerApi.Configuration.Model;
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Db.Entities;
|
||||
using MessengerApi.Models.Scoped;
|
||||
|
||||
namespace MessengerApi.Handlers.Endpoint
|
||||
{
|
||||
public class SendEndpointHandler
|
||||
{
|
||||
private readonly MessengerConfiguration configuration;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private readonly Timing timing;
|
||||
private readonly Identity identity;
|
||||
private readonly IUnitOfWork unitOfWork;
|
||||
|
||||
public SendEndpointHandler(
|
||||
MessengerConfiguration configuration,
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
Timing timing,
|
||||
Identity identity)
|
||||
{
|
||||
this.configuration = configuration;
|
||||
this.logger = logger;
|
||||
this.unitOfWork = unitOfWork;
|
||||
this.timing = timing;
|
||||
this.identity = identity;
|
||||
}
|
||||
|
||||
public Task<Message> SendMessage(
|
||||
Guid? toUserId,
|
||||
string payload,
|
||||
string payloadType,
|
||||
int? payloadLifespanInSeconds)
|
||||
{
|
||||
// Authorize.
|
||||
var targetRecipientId = toUserId.HasValue
|
||||
? this.identity.UserRoutes.Single(x => x.From.Id == this.identity.User.Id && x.To.Id == toUserId.Value).To.Id
|
||||
: this.identity.UserRoutes.Single().To.Id;
|
||||
|
||||
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is authorized to send message to {targetRecipientId}.");
|
||||
|
||||
// Act.
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedUtc = this.timing.Timestamp,
|
||||
FromId = this.identity.User.Id,
|
||||
ToId = targetRecipientId,
|
||||
Payload = payload,
|
||||
PayloadType = payloadType,
|
||||
PayloadLifespanInSeconds = payloadLifespanInSeconds ?? (this.configuration.DefaultMessageLifetimeInMinutes * 60)
|
||||
};
|
||||
|
||||
this.unitOfWork.Messages.Add(message);
|
||||
return Task.FromResult(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
code/MessengerApi/Handlers/HousekeepingHandler.cs
Normal file
47
code/MessengerApi/Handlers/HousekeepingHandler.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using MessengerApi.Configuration.Model;
|
||||
using MessengerApi.Contracts.Factories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MessengerApi.Handlers
|
||||
{
|
||||
public class HousekeepingHandler
|
||||
{
|
||||
private readonly ILogger logger;
|
||||
private readonly MessengerConfiguration configuration;
|
||||
private readonly IDbContextFactory dbContextFactory;
|
||||
|
||||
public HousekeepingHandler(
|
||||
ILogger logger,
|
||||
IDbContextFactory dbContextFactory,
|
||||
MessengerConfiguration configuration)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task RemoveOldMessages()
|
||||
{
|
||||
this.logger.Trace($"Executing {nameof(this.RemoveOldMessages)}.");
|
||||
|
||||
var timestamp = DateTime.UtcNow;
|
||||
var cutoff = timestamp.AddMinutes(-this.configuration.HousekeepingMessageAgeInMinutes);
|
||||
using var ctx = this.dbContextFactory.CreateDbContext();
|
||||
await ctx.Messages.Where(x => x.CreatedUtc < cutoff).ExecuteDeleteAsync();
|
||||
|
||||
if (this.configuration.HousekeepingMessageState != Configuration.Enums.HousekeepingMessageStates.None)
|
||||
{
|
||||
this.logger.Trace($"Executing additional message state cleaning in {nameof(this.RemoveOldMessages)}.");
|
||||
|
||||
if (this.configuration.HousekeepingMessageState == Configuration.Enums.HousekeepingMessageStates.Delivered)
|
||||
{
|
||||
await ctx.Messages.Where(x => x.IsDelivered).ExecuteDeleteAsync();
|
||||
}
|
||||
else if (this.configuration.HousekeepingMessageState == Configuration.Enums.HousekeepingMessageStates.Acknowledged)
|
||||
{
|
||||
await ctx.Messages.Where(x => x.IsAcknowledged).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
code/MessengerApi/Handlers/UserSetupHandler.cs
Normal file
99
code/MessengerApi/Handlers/UserSetupHandler.cs
Normal file
@ -0,0 +1,99 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
code/MessengerApi/MessengerApi.csproj
Normal file
30
code/MessengerApi/MessengerApi.csproj
Normal file
@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>85c81e87-1274-45ce-8b91-6d6619ffdfa2</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<BaseOutputPath>..\out\</BaseOutputPath>
|
||||
<StartupObject>MessengerApi.Api.Program</StartupObject>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="portaloggy" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
13
code/MessengerApi/Models/CachedIdentity.cs
Normal file
13
code/MessengerApi/Models/CachedIdentity.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MessengerApi.Models
|
||||
{
|
||||
public class CachedIdentity
|
||||
{
|
||||
public Db.Entities.User User { get; set; }
|
||||
|
||||
public Db.Entities.UserRoute[] UserRoutes { get; set; }
|
||||
|
||||
public ClaimsPrincipal ClaimsPrincipal { get; set; }
|
||||
}
|
||||
}
|
||||
7
code/MessengerApi/Models/Http/AckRequest.cs
Normal file
7
code/MessengerApi/Models/Http/AckRequest.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace MessengerApi.Models.Http
|
||||
{
|
||||
public class AckRequest
|
||||
{
|
||||
public Guid MessageId { get; set; }
|
||||
}
|
||||
}
|
||||
13
code/MessengerApi/Models/Http/SendRequest.cs
Normal file
13
code/MessengerApi/Models/Http/SendRequest.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace MessengerApi.Models.Http
|
||||
{
|
||||
public class SendRequest
|
||||
{
|
||||
public Guid? ToUserId { get; set; }
|
||||
|
||||
public string Payload { get; set; }
|
||||
|
||||
public string PayloadType { get; set; }
|
||||
|
||||
public int? PayloadLifetimeInSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
7
code/MessengerApi/Models/Http/VerifyRequest.cs
Normal file
7
code/MessengerApi/Models/Http/VerifyRequest.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace MessengerApi.Models.Http
|
||||
{
|
||||
public class VerifyRequest
|
||||
{
|
||||
public Guid MessageId { get; set; }
|
||||
}
|
||||
}
|
||||
9
code/MessengerApi/Models/Scoped/Identity.cs
Normal file
9
code/MessengerApi/Models/Scoped/Identity.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace MessengerApi.Models.Scoped
|
||||
{
|
||||
public class Identity
|
||||
{
|
||||
public Db.Entities.User User { get; set; }
|
||||
|
||||
public Db.Entities.UserRoute[] UserRoutes { get; set; }
|
||||
}
|
||||
}
|
||||
7
code/MessengerApi/Models/Scoped/Timing.cs
Normal file
7
code/MessengerApi/Models/Scoped/Timing.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace MessengerApi.Models.Scoped
|
||||
{
|
||||
public class Timing
|
||||
{
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
33
code/MessengerApi/Models/Scoped/UnitOfWork.cs
Normal file
33
code/MessengerApi/Models/Scoped/UnitOfWork.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using MessengerApi.Contracts.Factories;
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Db;
|
||||
using MessengerApi.Db.Contracts.Repositories;
|
||||
using MessengerApi.Db.Repositories;
|
||||
|
||||
namespace MessengerApi.Models.Scoped
|
||||
{
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private MessengerDbContext context;
|
||||
|
||||
public IUserRepository Users { get; }
|
||||
|
||||
public IUserRouteRepository UserRoutes { get; }
|
||||
|
||||
public IMessageRepository Messages { get; }
|
||||
|
||||
public UnitOfWork(
|
||||
IDbContextFactory dbContextFactory)
|
||||
{
|
||||
this.context = dbContextFactory.CreateDbContext();
|
||||
this.Users = new UserRepository(this.context.Users);
|
||||
this.UserRoutes = new UserRouteRepository(this.context.UserRoutes);
|
||||
this.Messages = new MessageRepository(this.context.Messages);
|
||||
}
|
||||
|
||||
public Task SaveChanges(CancellationToken ct = default)
|
||||
{
|
||||
return this.context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
code/MessengerApi/Models/UserSetupItem.cs
Normal file
11
code/MessengerApi/Models/UserSetupItem.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace MessengerApi.Models
|
||||
{
|
||||
public class UserSetupItem
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
public string[] CanSendToUserNames { get; set; }
|
||||
}
|
||||
}
|
||||
313
code/MessengerApi/Program.cs
Normal file
313
code/MessengerApi/Program.cs
Normal file
@ -0,0 +1,313 @@
|
||||
using MessengerApi.Configuration.Model;
|
||||
using MessengerApi.Configuration.Model.Persistence;
|
||||
using MessengerApi.Configuration.Sources.Environment;
|
||||
using MessengerApi.Contracts.Factories;
|
||||
using MessengerApi.Contracts.Models.Scoped;
|
||||
using MessengerApi.Db;
|
||||
using MessengerApi.Factories;
|
||||
using MessengerApi.Handlers;
|
||||
using MessengerApi.Handlers.Endpoint;
|
||||
using MessengerApi.Models.Http;
|
||||
using MessengerApi.Models.Scoped;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace MessengerApi.Api
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
MessengerConfiguration configuration = null;
|
||||
|
||||
try
|
||||
{
|
||||
configuration = new MessengerConfiguration(new EnvironmentConfigurationSource());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Can't load settings.", ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<MessengerConfiguration>(configuration);
|
||||
builder.Services.AddSingleton<ILogger>(new Factories.LoggerFactory(configuration).CreateLogger());
|
||||
builder.Services.AddSingleton<SendEndpointHandler>();
|
||||
builder.Services.AddSingleton<HousekeepingHandler>();
|
||||
builder.Services.AddSingleton<UserSetupHandler>();
|
||||
builder.Services.AddSingleton<IDbContextFactory, DbContextFactory>();
|
||||
|
||||
builder.Services.AddScoped<Timing>();
|
||||
builder.Services.AddScoped<Identity>();
|
||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
builder.Services.AddScoped<SendEndpointHandler>();
|
||||
builder.Services.AddScoped<ReceiveEndpointHandler>();
|
||||
builder.Services.AddScoped<AckEndpointHandler>();
|
||||
builder.Services.AddScoped<PeekEndpointHandler>();
|
||||
|
||||
// Authentication.
|
||||
builder.Services
|
||||
.AddAuthentication("Bearer")
|
||||
.AddScheme<AuthenticationSchemeOptions, CustomBearerAuthenticationHandler>("Bearer", null);
|
||||
|
||||
// CORS.
|
||||
builder.Services
|
||||
.AddCors(opt => opt.AddPolicy("originpolicy", builder =>
|
||||
{
|
||||
builder
|
||||
.WithOrigins(configuration.Origins.ToArray())
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
}));
|
||||
|
||||
// Ratelimiting
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
|
||||
{
|
||||
var key = httpContext.Request.Headers["Authorization"].FirstOrDefault()
|
||||
?? "anonymous";
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = configuration.RateLimitPerMinute,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
|
||||
options.RejectionStatusCode = 429;
|
||||
});
|
||||
|
||||
// Proxy registration to forward real client IPs.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
|
||||
foreach (var proxy in configuration.Proxies)
|
||||
{
|
||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||
}
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseDeveloperExceptionPage();
|
||||
|
||||
// DB Migrations
|
||||
using (var ctx = app.Services.GetRequiredService<IDbContextFactory>().CreateDbContext())
|
||||
{
|
||||
var migrationLogger = app.Services.GetRequiredService<ILogger>();
|
||||
|
||||
try
|
||||
{
|
||||
if (ctx.Database.GetPendingMigrations().Any())
|
||||
{
|
||||
migrationLogger.Info("Applying migrations.");
|
||||
ctx.Database.Migrate();
|
||||
}
|
||||
else
|
||||
{
|
||||
migrationLogger.Info("No migrations pending.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
migrationLogger.Error("Can't run migrations successfully.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Housekeeping.
|
||||
if (configuration.HousekeepingEnabled)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await app.Services.GetService<HousekeepingHandler>().RemoveOldMessages();
|
||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// User synchronization
|
||||
var userSetupHandler = app.Services.GetRequiredService<UserSetupHandler>();
|
||||
userSetupHandler.UpdateFromFile(new FileInfo(Constants.USERFILE_FILENAME)).GetAwaiter().GetResult();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseCors("originpolicy");
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Ray id logging.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var stamp = DateTime.UtcNow;
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger>();
|
||||
var ipa = context?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
var uid = context?.User?.Identity?.Name ?? "unknown";
|
||||
var una = context?.User?.Claims?.SingleOrDefault(x => x.Type == "UserName")?.Value ?? "unknown";
|
||||
var rid = context?.TraceIdentifier ?? "unknown";
|
||||
var endpoint = context?.GetEndpoint()?.DisplayName ?? "unknown";
|
||||
|
||||
logger.Info($"{endpoint} call {rid}; ip {ipa}; u {una}/{uid}");
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
// Endpoint registration.
|
||||
app.MapPost("/send", async (
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
SendEndpointHandler handler,
|
||||
[FromBody] SendRequest request) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await handler.SendMessage(request.ToUserId, request.Payload, request.PayloadType, request.PayloadLifetimeInSeconds);
|
||||
await unitOfWork.SaveChanges();
|
||||
return Results.Json(response.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't send.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/receive", async (
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
ReceiveEndpointHandler handler) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = await handler.ReceiveMessages();
|
||||
|
||||
if (messages?.Any() != true)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
else
|
||||
{
|
||||
await unitOfWork.SaveChanges();
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
Messages = messages.Select(x => new
|
||||
{
|
||||
Id = x.Id,
|
||||
TimestampUtc = x.CreatedUtc,
|
||||
Payload = x.Payload,
|
||||
PayloadType = x.PayloadType,
|
||||
Sender = x.FromId
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't send.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/ack", async (
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
AckEndpointHandler handler,
|
||||
AckRequest request) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.AckMessage(request.MessageId);
|
||||
await unitOfWork.SaveChanges();
|
||||
return Results.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't send.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/yellowpages", (
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
Identity identity) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var routes = unitOfWork.UserRoutes.GetByFrom(identity.User).ToList();
|
||||
return Results.Json(new
|
||||
{
|
||||
Users = routes.Select(x => new
|
||||
{
|
||||
Id = x.To.Id,
|
||||
Name = x.To.Name
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't yellowpages.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/peek", async (
|
||||
ILogger logger,
|
||||
PeekEndpointHandler handler) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pending = await handler.Peek();
|
||||
return Results.Json(pending);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't peek.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/verify", (
|
||||
ILogger logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
Guid messageId) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = unitOfWork.Messages.GetById(messageId);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
message.IsDelivered,
|
||||
message.IsAcknowledged
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("Can't verify.", ex);
|
||||
return Results.InternalServerError();
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
28
code/MessengerApi/Properties/launchSettings.json
Normal file
28
code/MessengerApi/Properties/launchSettings.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"PERSISTENCE_TYPE": "Sql",
|
||||
"CORS_ORIGINS": "",
|
||||
"PROXIES": "",
|
||||
"QUERY_RATE_PER_MINUTE": "100",
|
||||
"DEFAULT_MESSAGE_LIFETIME_IN_MINUTES": "60",
|
||||
"HOUSEKEEPING_ENABLED": "False",
|
||||
"LOGGING_VERBOSITY": "Trace"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5259"
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:55327",
|
||||
"sslPort": 44348
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user