Initial commit carried over from private repo. This is V2.
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m3s
Build and Push Docker Image / docker (push) Successful in 43s

This commit is contained in:
2025-07-04 21:24:12 +02:00
parent 7715816029
commit 4393977389
96 changed files with 3223 additions and 0 deletions

View File

@ -0,0 +1,26 @@
name: Build and Push Docker Image
on:
push:
jobs:
build:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/dotnet/sdk:9.0
steps:
- name: Install Node.js and dependencies
run: |
apt-get update
apt-get install -y curl gnupg
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
apt-get install -y nodejs git
- name: Checkout
uses: actions/checkout@v3
- name: Restore dependencies
run: dotnet restore ./code/MessengerApi/MessengerApi.csproj
- name: Build project
run: dotnet build ./code/MessengerApi/MessengerApi.csproj -c Release

View File

@ -0,0 +1,23 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v3
- name: Docker login
run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login https://gitea.masita.net -u mc --password-stdin
- name: Build and push Docker image
run: |
IMAGE=gitea.masita.net/mc/messengerapi:latest
docker build -t $IMAGE .
docker push $IMAGE

12
Directory.Packages.props Normal file
View File

@ -0,0 +1,12 @@
<Project>
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.6" />
<PackageVersion Include="portaloggy" Version="1.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.1.0" />
</ItemGroup>
</Project>

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Base image
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy and restore with custom config
COPY NuGet.config ./
COPY Directory.Packages.props ./
COPY code/ ./code/
WORKDIR /src/code/MessengerApi
RUN dotnet restore MessengerApi.csproj
# Build and publish
RUN dotnet publish MessengerApi.csproj -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MessengerApi.dll"]

10
LICENSE Normal file
View File

@ -0,0 +1,10 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

15
NuGet.config Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="mc" value="https://gitea.masita.net/api/packages/mc/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="mc">
<package pattern="portaloggy" />
</packageSource>
</packageSourceMapping>
</configuration>

129
README.md Normal file
View File

@ -0,0 +1,129 @@
# messengerapi
[!["send me a tip"](https://img.shields.io/badge/give%20me%20a%20-tip-FFD200)](https://paypal.me/emsicz) [!["see my github"](https://img.shields.io/badge/see%20my%20-github-1F2328)](https://github.com/masiton?tab=repositories)
Lightweight, maintenance-free, stateless .NET REST API message broker for sending and receiving messages in deliver-once fashion. Trivial HTTP request/response consumption. Works with Sql or Npg.
## Why
Existing messaging solutions are not atractive (to me). They are complex, hard to set up, understand and maintain. Even if happy-day scenarios work well, or day-one setups seem easy, basic functionality is often hidden under obscure layer of arguments, parameters, settings and limitations put in place for reasons not quickly apparent. I needed a messaging system that is really simple, but sufficiently reliable and performant. I want to turn this on and forget about it forever.
FOSS project for REST API over HTTP does not exist (or I didn't find it, [ChatGPT concurs](https://gitea.masita.net/mc/messengerapi/raw/branch/main/assets/why.jpg)). So here is my attempt.
## How
Messages and user credentials are kept in database. There are no usernames and passwords, only API keys. User can only message other users if corresponding route is set up between them. Manage users, routes and api keys directly in the DB. Clients authenticate and authorize their queries by using their API keys as Bearer tokens in Authorization headers permanently.
## Containerization
MessengerApi is built to be run as a container. See [image registry](https://gitea.masita.net/mc/-/packages/container/messengerapi/latest).
## Auth & Security
Senders and receivers send Bearer tokens with their HTTP request header: `Bearer ba53e34b-0163-40bc-9216-4ffa1fe3efb8`. *Users do not request their tokens, they are assigned tokens during registration and use them permanently.* This is a design choice done on purpose to put least possible amount of requirements on clients. A valid `route` must exist in DB between sender and receiver. Receivers can't ack someone else's message, and can't ack a message before it's delivered through `/receive` call. Access leak and password reset is handled through api key rotation when necessary.
## Setup
Mandatory tunables are super-simple:
- `SQL_CONNECTIONSTRING`
- Must be provided.
- _Example: `"Persist Security Info=False;User ID=*****;Password=*****;Initial Catalog=AdventureWorks;Server=192.168.1.2"`_
- `CORS_ORIGINS`
- Must be provided, if you're consuming the API from browser. Comma separated.
- _Example: `www.mydomain.com,mydomain.com,anotherdomain.eu`_
### PostgreSQL
To run MessengerApi against postgres, omit the `SQL_CONNECTIONSTRING` and use this:
- `PERSISTENCE_TYPE: PostgreSql`
- This tells Messenger to use PostgreSql DB Context.
- `NPG_CONNECTIONSTRING`
- Must be provided.
- _Example: `"Host=localhost;Port=5432;Database=myDatabase;Username=postgres;Password=********`_
Additional tunables, with their sustainable default values:
- `QUERY_RATE_PER_MINUTE: 100`
- Sets maximum allowed client query rate per minute for all endpoints. Anonymous users share same limit pool.
- If send rate is exceeded, client receives a `HTTP 429` with explanation.
- `DEFAULT_MESSAGE_LIFETIME_IN_MINUTES: 1`
- Message will wait for delivery for set amount of time. After the time passes, a call to `/receive` will not consider it for delivery anymore.
- Override this in message content by setting _optional_ `lifespanInSeconds` value inside the request.
- There will be no indication to the sender or to client that there was a missed message. Once it's gone, it's gone.
- `HOUSEKEEPING_ENABLED: true`
- Turns on housekeeping job that periodically removes stale, delivered and/or acknowledged messages. You can tune this further, see below. By default, it only removes messages that are 2 hours old, regardless of their delivery or acknowledgement state.
- `HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES: 120`
- Housekeeping job will delete any message older than set amount of time, regardless of it's current state.
- `HOUSEKEEPING_MESSAGE_STATE: NONE`
- Allowed values: `NONE`, `DELIVERED`, `ACKNOWLEDGED`.
- `NONE`: Housekeeping will not delete messages based on their state.
- `DELIVERED`: Housekeeping will delete messages that have been delivered.
- `ACKNOWLEDGED`: Housekeeping will delete messages that have been acknowledged.
- Works in addition to `HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES`, so messages can be removed earlier than after their age expires.
### User management
Manage users and credentials in DB manually.
_Alternatively_, mount a config to the container's root dir with name of `users.conf` with structure that contains `username;id;apikey;comma_separated_allowed_recipients_if_any` per line:
user1;90ddab90-0b73-4c6c-8dcb-2d8d1ec3c0b8;81ccf737-d424-4f83-929c-92d20491abfa;user2,user3,user4
user2;8f5971c3-5e19-4b5c-88a7-e0ec4856ce44;f480568f-8884-47e5-a6d7-82480f1ffb3b;user1
user3;f253a157-f336-4029-b90e-80a9f64b453b;46b882b7-4b96-4fa2-ba1b-4955a9500c36
user4;5f20ec92-3168-4df5-b20d-5441d08b3f9a;51d11e51-efb2-43e9-beb8-52fb8e879bee;user2
Upon launch, Messenger will synchronize contents of the file with the database. Synchronization uses `id` as primary identifier to make it easy to rotate API keys and change names. Synchronization is done `users.conf => db` and treats the config file as single source of truth, meaning data present in the file but not in db will be added to db, and data not present in file, but present in db will be deleted from db. Editing the file and restarting the service will then update the data accordingly.
## Integration
Tunnel on port `80` to send traffic. Consume HTTP endpoints:
### `POST /send`
Sends message and returns it's ID. Minimal message body where `user2` sends a text message to `user1`, and since `user2` can only message to `user1` and nobody else, they don't even have to specify recipient, the system infers it automatically:
http /send (post) header: Authorization: Bearer f480568f-8884-47e5-a6d7-82480f1ffb3b
{
"payload": "This is a message."
}
Response
http (json):
"5f33b4bd-dc2a-4ace-947a-1aadc6045995"
Optionally, messages can be complex. Here is a message from `user1` to `user3`, both `payload` and `payloadType` are `nvarchar` fields and their content can be whatever:
http /send (post) header: Authorization: Bearer 81ccf737-d424-4f83-929c-92d20491abfa
{
"payloadType": "STATUS",
"payload": "{\n \"system\": \"OK\",\n}",
"toUserId": "46b882b7-4b96-4fa2-ba1b-4955a9500c36",
"lifespanInSeconds": "3600"
}
### `GET /receive`
Receives all waiting messages.
http /receive (get) header: Authorization: Bearer 81ccf737-d424-4f83-929c-92d20491abfa
Response
http (json):
"messages": [
{
"id": "5f33b4bd-dc2a-4ace-947a-1aadc6045995",
"timestampUtc": "2025-07-04T16:32:12.4586339Z",
"payload": "{\n \"system\": \"OK\",\n}",
"payloadType": "STATUS",
"sender": "7bdea193-dce5-486e-ba7b-ec323d22bf90"
}
]
## Testing
See postman collection to generate code for your desired language or test the API instance. The collection also works as test suite, just fill out the environment variables for URL and api keys of your users and it will run and test all endpoints, with expected outputs.

BIN
assets/why.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Enums
{
public enum HousekeepingMessageStates
{
None,
Delivered,
Acknowledged
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Enums
{
public enum LoggingVerbosity
{
Normal,
Debug,
Trace
}
}

View File

@ -0,0 +1,8 @@
namespace MessengerApi.Configuration.Enums
{
public enum PersistenceTypes
{
Sql,
PostgreSql
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,98 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Parsers;
using MessengerApi.Configuration.Sources.Environment;
using Env = MessengerApi.Configuration.Constants.EnvironmentVariables;
namespace MessengerApi.Configuration.Model
{
public class MessengerConfiguration
{
/// <summary>
/// CORS origins.
/// </summary>
public string[] Origins { get; set; }
/// <summary>
/// List of proxies that are trusted to provide forwarding headers.
/// </summary>
public string[] Proxies { get; set; }
/// <summary>
/// Persistence layer configs (database).
/// </summary>
public PersistenceConfiguration PersistenceConfiguration { get; set; }
/// <summary>
/// Limits rate of user calls to not DoS the service.
/// </summary>
public int RateLimitPerMinute { get; set; }
/// <summary>
/// Message lifetime unless set differently in message body.
/// </summary>
public int DefaultMessageLifetimeInMinutes { get; set; }
/// <summary>
/// If true, messages are periodically wiped to free up space.
/// </summary>
public bool HousekeepingEnabled { get; set; }
/// <summary>
/// Messages older than value set will be deleted regardless of their delivery state.
/// </summary>
public int HousekeepingMessageAgeInMinutes { get; set; }
/// <summary>
/// Determines level of log messages displayed.
/// </summary>
public LoggingVerbosity Verbosity { get; set; }
/// <summary>
/// In addition to <see cref="HousekeepingMessageAgeInMinutes"/> messages of certain state can also be deleted, increasing storage efficiency.
/// </summary>
public HousekeepingMessageStates HousekeepingMessageState { get; set; }
public MessengerConfiguration() { }
public MessengerConfiguration(string[] origins, PersistenceConfiguration persistenceConfiguration)
{
if(persistenceConfiguration == null)
{
throw new ArgumentNullException(nameof(persistenceConfiguration));
}
this.PersistenceConfiguration = persistenceConfiguration;
this.Origins = origins ?? [];
this.Proxies = [];
this.RateLimitPerMinute = 120;
this.DefaultMessageLifetimeInMinutes = 1;
this.HousekeepingEnabled = true;
this.HousekeepingMessageAgeInMinutes = 120;
this.HousekeepingMessageState = HousekeepingMessageStates.None;
this.Verbosity = LoggingVerbosity.Normal;
}
public MessengerConfiguration(IEnvironmentConfigurationSource config) : this(
CorsParser.Parse(config.GetValue<string>(Env.CORS_ORIGINS)),
EnvironmentPersistenceConfigurationParser.Parse(config))
{
Populate<string>(config, Env.PROXIES, x => this.Proxies = ProxiesParser.Parse(x));
Populate<int>(config, Env.QUERY_RATE_PER_MINUTE, x => this.RateLimitPerMinute = x);
Populate<int>(config, Env.DEFAULT_MESSAGE_LIFETIME_IN_MINUTES, x => this.DefaultMessageLifetimeInMinutes = x);
Populate<bool>(config, Env.HOUSEKEEPING_ENABLED, x => this.HousekeepingEnabled = x);
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));
void Populate<T>(IEnvironmentConfigurationSource config, string key, Action<T> set)
{
if (config.HasKey(key))
{
var value = config.GetValue<T>(key);
set(value);
}
}
}
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Model.Persistence.Base
{
public abstract class PersistenceConfiguration
{
public abstract PersistenceTypes PersistenceType { get; }
}
}

View File

@ -0,0 +1,20 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Model.Persistence
{
public class NpgPersistenceConfiguration : PersistenceConfiguration
{
public override PersistenceTypes PersistenceType => PersistenceTypes.PostgreSql;
public string ConnectionString { get; }
public NpgPersistenceConfiguration(string connectionString)
{
ConnectionString = connectionString;
}
public NpgPersistenceConfiguration(IEnvironmentConfigurationSource config) : this(config.GetValue<string>(Constants.EnvironmentVariables.NPG_CONNECTIONSTRING)) { }
}
}

View File

@ -0,0 +1,20 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Model.Persistence
{
public class SqlPersistenceConfiguration : PersistenceConfiguration
{
public override PersistenceTypes PersistenceType => PersistenceTypes.Sql;
public string ConnectionString { get; }
public SqlPersistenceConfiguration(string connectionString)
{
ConnectionString = connectionString;
}
public SqlPersistenceConfiguration(IEnvironmentConfigurationSource config) : this(config.GetValue<string>(Constants.EnvironmentVariables.SQL_CONNECTIONSTRING)) { }
}
}

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Configuration.Parsers
{
public static class CorsParser
{
public static string[] Parse(string value)
{
if (string.IsNullOrWhiteSpace(value)) return [];
return value.Trim().Split(",", StringSplitOptions.RemoveEmptyEntries);
}
}
}

View File

@ -0,0 +1,25 @@
using MessengerApi.Configuration.Model.Persistence;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Parsers
{
public static class EnvironmentPersistenceConfigurationParser
{
public static PersistenceConfiguration Parse(IEnvironmentConfigurationSource config)
{
var type = PersistenceTypeParser.Parse(config.GetValue<string>(Constants.EnvironmentVariables.PERSISTENCE_TYPE));
if(type == Enums.PersistenceTypes.Sql)
{
return new SqlPersistenceConfiguration(config);
}
else if(type == Enums.PersistenceTypes.PostgreSql)
{
return new NpgPersistenceConfiguration(config);
}
throw new InvalidOperationException("Unrecognized persistence type.");
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class HousekeepingMessageStateParser
{
public static HousekeepingMessageStates Parse(string input)
{
return (HousekeepingMessageStates)Enum.Parse(typeof(HousekeepingMessageStates), input.Trim(), true);
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class LoggingVerbosityParser
{
public static LoggingVerbosity Parse(string value)
{
return (LoggingVerbosity)Enum.Parse(typeof(LoggingVerbosity), value.Trim(), true);
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class PersistenceTypeParser
{
public static PersistenceTypes Parse(string value)
{
return (PersistenceTypes)Enum.Parse(typeof(PersistenceTypes), value, true);
}
}
}

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Configuration.Parsers
{
public static class ProxiesParser
{
public static string[] Parse(string value)
{
if (string.IsNullOrWhiteSpace(value)) return [];
return value.Trim().Split(",", StringSplitOptions.RemoveEmptyEntries);
}
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Configuration
{
public static partial class Constants
{
public static class EnvironmentVariables
{
public const string SQL_CONNECTIONSTRING = nameof(SQL_CONNECTIONSTRING);
public const string NPG_CONNECTIONSTRING = nameof(NPG_CONNECTIONSTRING);
public const string PERSISTENCE_TYPE = nameof(PERSISTENCE_TYPE);
public const string CORS_ORIGINS = nameof(CORS_ORIGINS);
public const string PROXIES = nameof(PROXIES);
public const string QUERY_RATE_PER_MINUTE = nameof(QUERY_RATE_PER_MINUTE);
public const string DEFAULT_MESSAGE_LIFETIME_IN_MINUTES = nameof(DEFAULT_MESSAGE_LIFETIME_IN_MINUTES);
public const string HOUSEKEEPING_ENABLED = nameof(HOUSEKEEPING_ENABLED);
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);
}
}
}

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Configuration.Sources.Environment
{
public class EnvironmentConfigurationSource : IEnvironmentConfigurationSource
{
public bool HasKey(string key)
{
return !string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(key));
}
public T GetValue<T>(string key)
{
return (T)Convert.ChangeType(System.Environment.GetEnvironmentVariable(key), typeof(T));
}
}
}

View File

@ -0,0 +1,6 @@
namespace MessengerApi.Configuration.Sources.Environment
{
public interface IEnvironmentConfigurationSource : IConfigurationSource
{
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Sources
{
public interface IConfigurationSource
{
bool HasKey(string key);
T GetValue<T>(string key);
}
}

View File

@ -0,0 +1,22 @@
namespace MessengerApi.Contracts.MessageParser
{
/// <summary>
/// A tool that helps converting POCO request/response models into <see cref="InboxMessage"/>, <see cref="OutboxMessage"/> and back.
/// </summary>
/// <remarks>
/// If you implement this, it's gonna be a lot easier for you to translate
/// dumb request class into <see cref="OutboxMessage"/>, then convert
/// <see cref="InboxMessage"/> back to request at server-side, and do the
/// same with the response all the way down to the client.
/// </remarks>
public interface IMessageParser<TRequest, TResponse>
{
OutboxMessage GetMessageFromRequest(TRequest request, int targetUserId);
TRequest GetRequestFromMessage(InboxMessage message);
OutboxMessage GetMessageFromResponse(TResponse response, string apiKey, int targetUserId, InboxMessage requestOrigin = null);
TResponse GetResponseFromMessage(InboxMessage message);
}
}

View File

@ -0,0 +1,50 @@
using Newtonsoft.Json;
namespace MessengerApi.Contracts.MessageParser
{
public class MessageParser<TRequest, TResponse> : IMessageParser<TRequest, TResponse>
{
public OutboxMessage GetMessageFromRequest(
TRequest request,
int targetUserId)
{
var message = new OutboxMessage
{
}
apikey,
targetUserId,
typeof(TRequest).Name,
JsonConvert.SerializeObject(request));
return message;
}
public OutboxMessage GetMessageFromResponse(
TResponse response,
string apiKey,
int targetUserId,
InboxMessage requestOrigin = null)
{
var message = new OutboxMessage(
apiKey,
targetUserId,
requestOrigin.PayloadType,
JsonConvert.SerializeObject(response));
return message;
}
public TRequest GetRequestFromMessage(InboxMessage message)
{
var request = JsonConvert.DeserializeObject<TRequest>(message.Payload);
return request;
}
public TResponse GetResponseFromMessage(InboxMessage message)
{
var request = JsonConvert.DeserializeObject<TResponse>(message.Payload);
return request;
}
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</AssemblyVersion>
<PackageVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</PackageVersion>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<BaseOutputPath>..\out\</BaseOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessengerApi.Contracts" Version="2025.6.28.2202" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,30 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Exists for mocking reason. This is implemented by <see cref="MessengerClient"/>.
/// </summary>
public interface IMessengerClient
{
/// <summary>
/// Receives pending messages from the messenger API.
/// </summary>
/// <param name="credentials">Credentials to the API.</param>
IEnumerable<InboxMessage> GetMessages();
/// <summary>
/// Acknowledges message reception to the server.
/// </summary>
void AckMessage(InboxMessage message);
/// <summary>
/// Sends a message.
/// </summary>
/// <param name="credentials">Credentials to the API.</param>
void SendMessage(OutboxMessage outboxMessage);
/// <summary>
/// Returns user ids for allowed message recipients.
/// </summary>
Contact[] GetYellowPages();
}
}

View File

@ -0,0 +1,150 @@
using portaloggy;
using System.Text;
using System.Text.Json.Nodes;
namespace MessengerApi.Contracts
{
public class MessengerClient : IMessengerClient
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private DateTime _lastReceivedUtc;
private Credentials _credentials;
public MessengerClient(Credentials credentials, HttpClient httpClient = null, ILogger logger = null)
{
_credentials = credentials;
_httpClient = httpClient ?? new HttpClient();
_logger = logger ?? new ConsoleLogger();
_lastReceivedUtc = DateTime.MinValue.ToUniversalTime();
}
public IEnumerable<InboxMessage> GetMessages()
{
var since = Uri.EscapeDataString(this._lastReceivedUtc.ToString("o"));
var url = $"{_credentials.ApiUrl}/receive?sinceUtc={since}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {request.ToString()} to obtain messages.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't receive.", null, response.StatusCode);
}
var responseContent = response.Content
.ReadAsStringAsync()
.GetAwaiter()
.GetResult();
if (!string.IsNullOrWhiteSpace(responseContent))
{
_logger.Debug($"Received response of {responseContent}.");
}
if (string.IsNullOrWhiteSpace(responseContent))
{
return Enumerable.Empty<InboxMessage>().ToArray();
}
var json = JsonNode.Parse(responseContent);
var messages = new List<InboxMessage>();
foreach (var item in json["messages"].AsArray())
{
if (item["id"].GetValue<int>() == -1)
{
continue;
}
messages.Add(new InboxMessage
{
Id = item["id"].GetValue<Guid>(),
Payload = item["payload"].ToJsonString(),
PayloadId = item["payloadId"].ToJsonString(),
PayloadType = item["payloadType"].ToJsonString(),
Sender = item["sender"].GetValue<Guid>(),
SenderTimestamp = item["senderTimestamp"].GetValue<DateTime>()
});
}
_lastReceivedUtc = DateTime.UtcNow.Subtract(TimeSpan.FromSeconds(10));
_logger.Debug($"Received {messages.Count} messages and last check timestamp is set to {_lastReceivedUtc.ToString("s")}.");
return messages.ToArray();
}
public void SendMessage(OutboxMessage outboxMessage)
{
var body = new JsonObject();
if(outboxMessage.ToUserId.HasValue)
{
body.Add("toUserId", JsonValue.Create<Guid>(outboxMessage.ToUserId.Value));
}
if(outboxMessage.Payload != null)
{
body.Add("payload", JsonValue.Create<string>(outboxMessage.Payload));
}
var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
var url = $"{_credentials.ApiUrl}/send";
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {body.ToString()} to obtain messages.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't send.", null, response.StatusCode);
}
}
public Contact[] GetYellowPages()
{
var url = $"{_credentials.ApiUrl}/yellowpages";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {request.ToString()}.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't receive.", null, response.StatusCode);
}
var responseContent = response.Content
.ReadAsStringAsync()
.GetAwaiter()
.GetResult();
var json = JsonNode.Parse(responseContent);
var contacts = json["users"].AsArray().Select(x => new Contact
{
Id = x["id"].GetValue<Guid>(),
Name = x["name"].GetValue<string>()
}).ToArray();
return contacts;
}
public void AckMessage(InboxMessage message)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Contracts
{
public class Contact
{
public Guid Id { get; set; }
public string Name { get; set; }
}
}

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Contracts
{
public class Credentials
{
public string ApiKey { get; private set; }
public string ApiUrl { get; private set; }
public Credentials(string apiKey, string apiUrl)
{
ApiKey = apiKey;
ApiUrl = apiUrl;
}
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Message when received is inbox. For server apps, this is request-type of message. For clients, this is a response-type of message.
/// </summary>
public class InboxMessage
{
public Guid Id { get; set; }
public Guid Sender { get; set; }
public DateTime? SenderTimestamp { get; set; }
public string PayloadId { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Outbox type of message. A server-app will treat this as a response. A client app will treat this as a request.
/// </summary>
public class OutboxMessage
{
public Guid? ToUserId { 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,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<PackageOutputPath>$(OutputPath)</PackageOutputPath>
<AssemblyVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</AssemblyVersion>
<PackageVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</PackageVersion>
<BaseOutputPath>..\out\</BaseOutputPath>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="portaloggy" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Db.Contracts.Entities
{
public interface IEntity
{
Guid Id { get; }
}
public interface IEntity<T> : IEntity where T : class, IEntity
{
}
}

View File

@ -0,0 +1,25 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
public class Message : IEntity<Message>
{
public Guid Id { get; set; }
public DateTime CreatedUtc { get; set; }
public Guid FromId { get; set; }
public Guid ToId { get; set; }
public bool IsDelivered { get; set; }
public bool IsAcknowledged { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
public int? PayloadLifespanInSeconds { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
public class User : IEntity<User>
{
public Guid Id { get; set; }
public Guid ApiKey { get; set; }
public string Name { get; set; }
public bool IsEnabled { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
/// <summary>
/// Describes allowed message route (who can message whom).
/// </summary>
public class UserRoute : IEntity<UserRoute>
{
public Guid Id { get; set; }
public User From { get; set; }
public User To { get; set; }
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,9 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IMessageRepository : IRepository<Message>
{
IEnumerable<Message> GetPendingMessages(User user);
}
}

View File

@ -0,0 +1,15 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IRepository
{
}
public interface IRepository<T> : IRepository where T : class, IEntity<T>
{
void Add(T entity);
T GetById(Guid id);
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IUserRepository : IRepository<User>
{
User SingleByApiKeyAndEnabled(Guid id, bool enabled);
}
}

View File

@ -0,0 +1,17 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IUserRouteRepository:IRepository<UserRoute>
{
/// <summary>
/// Returns all routes for given user.
/// </summary>
IEnumerable<UserRoute> GetAllByUser(User sender);
/// <summary>
/// Returns routes where given user is sender.
/// </summary>
IEnumerable<UserRoute> GetByFrom(User user);
}
}

View File

@ -0,0 +1,13 @@
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore.Design;
namespace MessengerApi.Db.Sql.Migrator
{
public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<MessengerNpgDbContext>
{
public MessengerNpgDbContext CreateDbContext(string[] args)
{
return new MessengerNpgDbContext(this.ConnectionString);
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
namespace MessengerApi.Db.Npg.Migrator
{
internal class Program
{
static void Main(string[] args)
{
// You can use empty string to build the context when adding a migration - adding a migration does not hit the DB.
// DesignTimeDbFactory.ConnectionString.cs is not versioned on purposed. Add missing property file for this partial class and then run this command:
// Add-Migration YourMigration -Project MessengerApi.Db.Npg -StartupProject MessengerApi.Db.Npg.Migrator -Verbose -Context MessengerNpgDbContext
// To update the database, make sure your connection string is correct and run this command:
// Update-Database -Project MessengerApi.Db.Npg -StartupProject MessengerApi.Db.Npg.Migrator -Verbose -Context MessengerNpgDbContext
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Npg
{
public class MessengerNpgDbContext : MessengerDbContext
{
private readonly string connectionString;
public MessengerNpgDbContext(string connectionString)
{
this.connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseNpgsql(this.connectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// https://stackoverflow.com/questions/26464357/what-is-the-purpose-of-non-unique-indexes-in-a-database
// https://stackoverflow.com/questions/40767980/generate-a-composite-unique-constraint-index-in-ef-core
// https://www.geeksforgeeks.org/difference-between-clustered-and-non-clustered-index/
modelBuilder.Entity<Message>().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false);
}
}
}

View File

@ -0,0 +1,123 @@
// <auto-generated />
using System;
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
[DbContext(typeof(MessengerNpgDbContext))]
[Migration("20250704170425_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromId")
.HasColumnType("uuid");
b.Property<bool>("IsAcknowledged")
.HasColumnType("boolean");
b.Property<bool>("IsDelivered")
.HasColumnType("boolean");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("integer");
b.Property<string>("PayloadType")
.HasColumnType("text");
b.Property<Guid>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiKey")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("FromId")
.HasColumnType("uuid");
b.Property<Guid?>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
FromId = table.Column<Guid>(type: "uuid", nullable: false),
ToId = table.Column<Guid>(type: "uuid", nullable: false),
IsDelivered = table.Column<bool>(type: "boolean", nullable: false),
IsAcknowledged = table.Column<bool>(type: "boolean", nullable: false),
PayloadType = table.Column<string>(type: "text", nullable: true),
Payload = table.Column<string>(type: "text", nullable: true),
PayloadLifespanInSeconds = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ApiKey = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRoutes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FromId = table.Column<Guid>(type: "uuid", nullable: true),
ToId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoutes", x => x.Id);
table.ForeignKey(
name: "FK_UserRoutes_Users_FromId",
column: x => x.FromId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_UserRoutes_Users_ToId",
column: x => x.ToId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Messages_ToId_IsDelivered",
table: "Messages",
columns: new[] { "ToId", "IsDelivered" });
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_FromId",
table: "UserRoutes",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_ToId",
table: "UserRoutes",
column: "ToId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "UserRoutes");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,120 @@
// <auto-generated />
using System;
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
[DbContext(typeof(MessengerNpgDbContext))]
partial class MessengerNpgDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromId")
.HasColumnType("uuid");
b.Property<bool>("IsAcknowledged")
.HasColumnType("boolean");
b.Property<bool>("IsDelivered")
.HasColumnType("boolean");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("integer");
b.Property<string>("PayloadType")
.HasColumnType("text");
b.Property<Guid>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiKey")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("FromId")
.HasColumnType("uuid");
b.Property<Guid?>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Design;
namespace MessengerApi.Db.Sql.Migrator
{
public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<MessengerSqlDbContext>
{
public MessengerSqlDbContext CreateDbContext(string[] args)
{
return new MessengerSqlDbContext(this.ConnectionString);
}
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
namespace MessengerApi.Db.Sql.Migrator
{
internal class Program
{
static void Main(string[] args)
{
// You can use empty string to build the context when adding a migration - adding a migration does not hit the DB.
// DesignTimeDbFactory.ConnectionString.cs is not versioned on purposed. Add missing property file for this partial class and then run this command:
// Add-Migration YourMigration -Project MessengerApi.Db.Sql -StartupProject MessengerApi.Db.Sql.Migrator -Verbose -Context MessengerSqlDbContext
// To update the database, make sure your connection string is correct and run this command:
// Update-Database -Project MessengerApi.Db.Sql -StartupProject MessengerApi.Db.Sql.Migrator -Verbose -Context MessengerSqlDbContext
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Sql
{
public class MessengerSqlDbContext : MessengerDbContext
{
private readonly string connectionString;
public MessengerSqlDbContext(string connectionString)
{
this.connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer(this.connectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// https://stackoverflow.com/questions/26464357/what-is-the-purpose-of-non-unique-indexes-in-a-database
// https://stackoverflow.com/questions/40767980/generate-a-composite-unique-constraint-index-in-ef-core
// https://www.geeksforgeeks.org/difference-between-clustered-and-non-clustered-index/
modelBuilder.Entity<Message>().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false).IsClustered(false);
}
}
}

View File

@ -0,0 +1,125 @@
// <auto-generated />
using System;
using MessengerApi.Db.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
[DbContext(typeof(MessengerSqlDbContext))]
[Migration("20250704165018_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("datetime2");
b.Property<Guid>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsAcknowledged")
.HasColumnType("bit");
b.Property<bool>("IsDelivered")
.HasColumnType("bit");
b.Property<string>("Payload")
.HasColumnType("nvarchar(max)");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("int");
b.Property<string>("PayloadType")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("ToId", "IsDelivered"), false);
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApiKey")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
FromId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ToId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsDelivered = table.Column<bool>(type: "bit", nullable: false),
IsAcknowledged = table.Column<bool>(type: "bit", nullable: false),
PayloadType = table.Column<string>(type: "nvarchar(max)", nullable: true),
Payload = table.Column<string>(type: "nvarchar(max)", nullable: true),
PayloadLifespanInSeconds = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApiKey = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsEnabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRoutes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ToId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoutes", x => x.Id);
table.ForeignKey(
name: "FK_UserRoutes_Users_FromId",
column: x => x.FromId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_UserRoutes_Users_ToId",
column: x => x.ToId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Messages_ToId_IsDelivered",
table: "Messages",
columns: new[] { "ToId", "IsDelivered" })
.Annotation("SqlServer:Clustered", false);
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_FromId",
table: "UserRoutes",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_ToId",
table: "UserRoutes",
column: "ToId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "UserRoutes");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,122 @@
// <auto-generated />
using System;
using MessengerApi.Db.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
[DbContext(typeof(MessengerSqlDbContext))]
partial class MessengerSqlDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("datetime2");
b.Property<Guid>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsAcknowledged")
.HasColumnType("bit");
b.Property<bool>("IsDelivered")
.HasColumnType("bit");
b.Property<string>("Payload")
.HasColumnType("nvarchar(max)");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("int");
b.Property<string>("PayloadType")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("ToId", "IsDelivered"), false);
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApiKey")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MessengerApi.Db.Converters
{
public sealed class DateTimeAsUtcValueConverter()
: ValueConverter<DateTime, DateTime>(
v => v, v => new DateTime(v.Ticks, DateTimeKind.Utc));
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<BaseOutputPath>..\out\</BaseOutputPath>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Contracts\MessengerApi.Db.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,26 @@
using MessengerApi.Db.Converters;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db
{
public abstract class MessengerDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Message> Messages { get; set; }
public DbSet<UserRoute> UserRoutes { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>().HasKey(e => e.Id);
modelBuilder.Entity<Message>().HasKey(e => e.Id);
modelBuilder.Entity<Message>().Property(e => e.CreatedUtc).HasConversion<DateTimeAsUtcValueConverter>();
modelBuilder.Entity<Message>().Property(e => e.PayloadLifespanInSeconds).IsRequired();
modelBuilder.Entity<UserRoute>().HasKey(e => e.Id);
}
}
}

View File

@ -0,0 +1,23 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class MessageRepository : Repository<Message>, IMessageRepository
{
public MessageRepository(DbSet<Message> db) : base(db)
{
}
public IEnumerable<Message> GetPendingMessages(User user)
{
var timestamp = DateTime.UtcNow;
return this.db
.Where(x => x.ToId == user.Id && x.IsDelivered == false)
.Where(x => x.PayloadLifespanInSeconds == null || x.CreatedUtc.AddSeconds(x.PayloadLifespanInSeconds.Value) >= timestamp)
.OrderBy(x => x.CreatedUtc);
}
}
}

View File

@ -0,0 +1,26 @@
using MessengerApi.Db.Contracts.Entities;
using MessengerApi.Db.Contracts.Repositories;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public abstract class Repository<T> : IRepository<T> where T : class, IEntity<T>
{
protected readonly DbSet<T> db;
public Repository(DbSet<T> db)
{
this.db = db;
}
public void Add(T entity)
{
this.db.Add(entity);
}
public T GetById(Guid id)
{
return this.db.Single(x => x.Id == id);
}
}
}

View File

@ -0,0 +1,18 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class UserRepository : Repository<User>, IUserRepository
{
public UserRepository(DbSet<User> db) : base(db)
{
}
public User SingleByApiKeyAndEnabled(Guid id, bool enabled)
{
return this.db.Single(x => x.ApiKey == id && x.IsEnabled == enabled);
}
}
}

View File

@ -0,0 +1,23 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class UserRouteRepository : Repository<UserRoute>, IUserRouteRepository
{
public UserRouteRepository(DbSet<UserRoute> db) : base(db)
{
}
public IEnumerable<UserRoute> GetAllByUser(User sender)
{
return this.db.Include(x => x.From).Include(x => x.To).Where(x => x.From.Id == sender.Id || x.To.Id == sender.Id);
}
public IEnumerable<UserRoute> GetByFrom(User user)
{
return this.db.Include(x => x.From).Include(x => x.To).Where(x => x.From.Id == user.Id);
}
}
}

156
code/MessengerApi.sln Normal file
View File

@ -0,0 +1,156 @@

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}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.SnapshotSubscriptionClient", "MessengerApi.Examples.SnapshotSubscriptionClient\MessengerApi.Examples.SnapshotSubscriptionClient.csproj", "{A57429EB-3929-4E8B-B427-9B77D14CC486}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.Constants", "MessengerApi.Examples.Constants\MessengerApi.Examples.Constants.csproj", "{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.QueryClient", "MessengerApi.Examples.QueryClient\MessengerApi.Examples.QueryClient.csproj", "{09DEF168-FD5C-47C3-81DF-077BE6219089}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Db", "MessengerApi.Db\MessengerApi.Db.csproj", "{64B33C4B-4B04-4F48-8620-4CA2AB641934}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi", "MessengerApi\MessengerApi.csproj", "{BA717183-65C4-4568-8ACD-DEDBD2B77322}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Db", "Db", "{F318E6F5-0343-491B-9264-CFFB4CCF1241}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Tests.LongTermSendingClient", "MessengerApi.Tests.LongTermSendingClient\MessengerApi.Tests.LongTermSendingClient.csproj", "{BAFCEB19-4FFC-44DF-8240-93172191080F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Tests.LongTermReceivingClient", "MessengerApi.Tests.LongTermReceivingClient\MessengerApi.Tests.LongTermReceivingClient.csproj", "{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
..\Directory.Packages.props = ..\Directory.Packages.props
..\docker-compose.yml = ..\docker-compose.yml
..\Dockerfile = ..\Dockerfile
..\NuGet.config = ..\NuGet.config
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject
..\.gitea\workflows\build.yml = ..\.gitea\workflows\build.yml
..\.gitea\workflows\docker-build-and-push.yml = ..\.gitea\workflows\docker-build-and-push.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Configuration", "MessengerApi.Configuration\MessengerApi.Configuration.csproj", "{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Sql", "MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj", "{22755F3D-C55D-436C-9C9F-C564001B976B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Npg", "MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj", "{8199D547-23AC-4B10-9BD1-2996A6C35B1D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Contracts", "MessengerApi.Db.Contracts\MessengerApi.Db.Contracts.csproj", "{062ADC2E-EF77-4319-9269-D60D39E31C0E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Sql.Migrator", "MessengerApi.Db.Sql.Migrator\MessengerApi.Db.Sql.Migrator.csproj", "{65C395EC-81E9-4919-9721-72CAA3E4780D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Npg.Migrator", "MessengerApi.Db.Npg.Migrator\MessengerApi.Db.Npg.Migrator.csproj", "{DF751DD1-9869-4916-B946-A8513A7CE706}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
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
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Release|Any CPU.Build.0 = Release|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Release|Any CPU.Build.0 = Release|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Release|Any CPU.Build.0 = Release|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Release|Any CPU.Build.0 = Release|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Release|Any CPU.Build.0 = Release|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Release|Any CPU.Build.0 = Release|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Release|Any CPU.Build.0 = Release|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Release|Any CPU.Build.0 = Release|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Release|Any CPU.Build.0 = Release|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Release|Any CPU.Build.0 = Release|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Release|Any CPU.Build.0 = Release|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Release|Any CPU.Build.0 = Release|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Release|Any CPU.Build.0 = Release|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{A57429EB-3929-4E8B-B427-9B77D14CC486} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{09DEF168-FD5C-47C3-81DF-077BE6219089} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{64B33C4B-4B04-4F48-8620-4CA2AB641934} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{BAFCEB19-4FFC-44DF-8240-93172191080F} = {6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB} = {6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}
{22755F3D-C55D-436C-9C9F-C564001B976B} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{8199D547-23AC-4B10-9BD1-2996A6C35B1D} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{062ADC2E-EF77-4319-9269-D60D39E31C0E} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{65C395EC-81E9-4919-9721-72CAA3E4780D} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{DF751DD1-9869-4916-B946-A8513A7CE706} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {61948E36-4C2B-4BC9-80B6-9E155CE9F7DE}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi
{
public class Constants
{
public const string USERFILE_FILENAME = "/app/users.conf";
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Db;
namespace MessengerApi.Contracts.Factories
{
public interface IDbContextFactory
{
MessengerDbContext CreateDbContext();
}
}

View 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);
}
}

View 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"]

View 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();
}
}
}

View 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();
}
}
}

View File

@ -0,0 +1,2 @@
global using portaloggy;
global using ILogger = portaloggy.ILogger;

View File

@ -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)));
}
}
}
}

View 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;
}
}
}

View 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());
}
}
}

View File

@ -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());
}
}
}

View 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);
}
}
}

View 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();
}
}
}
}
}

View 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;
}
}
}

View 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>

View 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; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Http
{
public class AckRequest
{
public Guid MessageId { get; set; }
}
}

View 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; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Http
{
public class VerifyRequest
{
public Guid MessageId { get; set; }
}
}

View 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; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Scoped
{
public class Timing
{
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
}

View 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);
}
}
}

View 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; }
}
}

View 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();
}
}
}

View 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
}
}
}

7
docker-compose.yml Normal file
View File

@ -0,0 +1,7 @@
services:
messengerapi:
image: https://gitea.masita.net/mc/messengerapi:latest
container_name: messengerapi
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Production