diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..b550051
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -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
\ No newline at end of file
diff --git a/.gitea/workflows/docker-build-and-push.yml b/.gitea/workflows/docker-build-and-push.yml
new file mode 100644
index 0000000..66f00f1
--- /dev/null
+++ b/.gitea/workflows/docker-build-and-push.yml
@@ -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
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..d7f66d9
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..63b948b
--- /dev/null
+++ b/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..cde4ac6
--- /dev/null
+++ b/LICENSE
@@ -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
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..c197527
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da3f13d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,129 @@
+# messengerapi
+
+[](https://paypal.me/emsicz) [](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.
\ No newline at end of file
diff --git a/assets/why.jpg b/assets/why.jpg
new file mode 100644
index 0000000..b7b7df9
Binary files /dev/null and b/assets/why.jpg differ
diff --git a/code/MessengerApi.Configuration/Enums/HousekeepingMessageStates.cs b/code/MessengerApi.Configuration/Enums/HousekeepingMessageStates.cs
new file mode 100644
index 0000000..c2b1f13
--- /dev/null
+++ b/code/MessengerApi.Configuration/Enums/HousekeepingMessageStates.cs
@@ -0,0 +1,9 @@
+namespace MessengerApi.Configuration.Enums
+{
+ public enum HousekeepingMessageStates
+ {
+ None,
+ Delivered,
+ Acknowledged
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Enums/LoggingVerbosity.cs b/code/MessengerApi.Configuration/Enums/LoggingVerbosity.cs
new file mode 100644
index 0000000..402647e
--- /dev/null
+++ b/code/MessengerApi.Configuration/Enums/LoggingVerbosity.cs
@@ -0,0 +1,9 @@
+namespace MessengerApi.Configuration.Enums
+{
+ public enum LoggingVerbosity
+ {
+ Normal,
+ Debug,
+ Trace
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Enums/PersistenceTypes.cs b/code/MessengerApi.Configuration/Enums/PersistenceTypes.cs
new file mode 100644
index 0000000..a1e8a8c
--- /dev/null
+++ b/code/MessengerApi.Configuration/Enums/PersistenceTypes.cs
@@ -0,0 +1,8 @@
+namespace MessengerApi.Configuration.Enums
+{
+ public enum PersistenceTypes
+ {
+ Sql,
+ PostgreSql
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/MessengerApi.Configuration.csproj b/code/MessengerApi.Configuration/MessengerApi.Configuration.csproj
new file mode 100644
index 0000000..ef83a76
--- /dev/null
+++ b/code/MessengerApi.Configuration/MessengerApi.Configuration.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ disable
+
+
+
diff --git a/code/MessengerApi.Configuration/Model/MessengerConfiguration.cs b/code/MessengerApi.Configuration/Model/MessengerConfiguration.cs
new file mode 100644
index 0000000..2709c3c
--- /dev/null
+++ b/code/MessengerApi.Configuration/Model/MessengerConfiguration.cs
@@ -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
+ {
+ ///
+ /// CORS origins.
+ ///
+ public string[] Origins { get; set; }
+
+ ///
+ /// List of proxies that are trusted to provide forwarding headers.
+ ///
+ public string[] Proxies { get; set; }
+
+ ///
+ /// Persistence layer configs (database).
+ ///
+ public PersistenceConfiguration PersistenceConfiguration { get; set; }
+
+ ///
+ /// Limits rate of user calls to not DoS the service.
+ ///
+ public int RateLimitPerMinute { get; set; }
+
+ ///
+ /// Message lifetime unless set differently in message body.
+ ///
+ public int DefaultMessageLifetimeInMinutes { get; set; }
+
+ ///
+ /// If true, messages are periodically wiped to free up space.
+ ///
+ public bool HousekeepingEnabled { get; set; }
+
+ ///
+ /// Messages older than value set will be deleted regardless of their delivery state.
+ ///
+ public int HousekeepingMessageAgeInMinutes { get; set; }
+
+ ///
+ /// Determines level of log messages displayed.
+ ///
+ public LoggingVerbosity Verbosity { get; set; }
+
+ ///
+ /// In addition to messages of certain state can also be deleted, increasing storage efficiency.
+ ///
+ 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(Env.CORS_ORIGINS)),
+ EnvironmentPersistenceConfigurationParser.Parse(config))
+ {
+ Populate(config, Env.PROXIES, x => this.Proxies = ProxiesParser.Parse(x));
+ Populate(config, Env.QUERY_RATE_PER_MINUTE, x => this.RateLimitPerMinute = x);
+ Populate(config, Env.DEFAULT_MESSAGE_LIFETIME_IN_MINUTES, x => this.DefaultMessageLifetimeInMinutes = x);
+ Populate(config, Env.HOUSEKEEPING_ENABLED, x => this.HousekeepingEnabled = x);
+ Populate(config, Env.HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES, x => this.HousekeepingMessageAgeInMinutes = x);
+ Populate(config, Env.HOUSEKEEPING_MESSAGE_STATE, x => this.HousekeepingMessageState = HousekeepingMessageStateParser.Parse(x));
+ Populate(config, Env.LOGGING_VERBOSITY, x => this.Verbosity = LoggingVerbosityParser.Parse(x));
+
+ void Populate(IEnvironmentConfigurationSource config, string key, Action set)
+ {
+ if (config.HasKey(key))
+ {
+ var value = config.GetValue(key);
+ set(value);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Model/Persistence/Base/PersistenceConfiguration.cs b/code/MessengerApi.Configuration/Model/Persistence/Base/PersistenceConfiguration.cs
new file mode 100644
index 0000000..361e153
--- /dev/null
+++ b/code/MessengerApi.Configuration/Model/Persistence/Base/PersistenceConfiguration.cs
@@ -0,0 +1,9 @@
+using MessengerApi.Configuration.Enums;
+
+namespace MessengerApi.Configuration.Model.Persistence.Base
+{
+ public abstract class PersistenceConfiguration
+ {
+ public abstract PersistenceTypes PersistenceType { get; }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Model/Persistence/NpgPersistenceConfiguration.cs b/code/MessengerApi.Configuration/Model/Persistence/NpgPersistenceConfiguration.cs
new file mode 100644
index 0000000..664ad53
--- /dev/null
+++ b/code/MessengerApi.Configuration/Model/Persistence/NpgPersistenceConfiguration.cs
@@ -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(Constants.EnvironmentVariables.NPG_CONNECTIONSTRING)) { }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Model/Persistence/SqlPersistenceConfiguration.cs b/code/MessengerApi.Configuration/Model/Persistence/SqlPersistenceConfiguration.cs
new file mode 100644
index 0000000..f7a3dfa
--- /dev/null
+++ b/code/MessengerApi.Configuration/Model/Persistence/SqlPersistenceConfiguration.cs
@@ -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(Constants.EnvironmentVariables.SQL_CONNECTIONSTRING)) { }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Parsers/CorsParser.cs b/code/MessengerApi.Configuration/Parsers/CorsParser.cs
new file mode 100644
index 0000000..8a97da4
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/CorsParser.cs
@@ -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);
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Parsers/EnvironmentPersistenceConfigurationParser.cs b/code/MessengerApi.Configuration/Parsers/EnvironmentPersistenceConfigurationParser.cs
new file mode 100644
index 0000000..29d1332
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/EnvironmentPersistenceConfigurationParser.cs
@@ -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(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.");
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Parsers/HousekeepingMessageStateParser.cs b/code/MessengerApi.Configuration/Parsers/HousekeepingMessageStateParser.cs
new file mode 100644
index 0000000..34fd988
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/HousekeepingMessageStateParser.cs
@@ -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);
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Parsers/LoggingVerbosityParser.cs b/code/MessengerApi.Configuration/Parsers/LoggingVerbosityParser.cs
new file mode 100644
index 0000000..92ce81a
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/LoggingVerbosityParser.cs
@@ -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);
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Parsers/PersistenceTypeParser.cs b/code/MessengerApi.Configuration/Parsers/PersistenceTypeParser.cs
new file mode 100644
index 0000000..37ea3e0
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/PersistenceTypeParser.cs
@@ -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);
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Parsers/ProxiesParser.cs b/code/MessengerApi.Configuration/Parsers/ProxiesParser.cs
new file mode 100644
index 0000000..8397847
--- /dev/null
+++ b/code/MessengerApi.Configuration/Parsers/ProxiesParser.cs
@@ -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);
+ }
+ }
+}
diff --git a/code/MessengerApi.Configuration/Sources/Environment/Constants.EnvironmentVariables.cs b/code/MessengerApi.Configuration/Sources/Environment/Constants.EnvironmentVariables.cs
new file mode 100644
index 0000000..438dfc7
--- /dev/null
+++ b/code/MessengerApi.Configuration/Sources/Environment/Constants.EnvironmentVariables.cs
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Sources/Environment/EnvironmentConfigurationSource.cs b/code/MessengerApi.Configuration/Sources/Environment/EnvironmentConfigurationSource.cs
new file mode 100644
index 0000000..ca63b81
--- /dev/null
+++ b/code/MessengerApi.Configuration/Sources/Environment/EnvironmentConfigurationSource.cs
@@ -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(string key)
+ {
+ return (T)Convert.ChangeType(System.Environment.GetEnvironmentVariable(key), typeof(T));
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Sources/Environment/IEnvironmentConfigurationSource.cs b/code/MessengerApi.Configuration/Sources/Environment/IEnvironmentConfigurationSource.cs
new file mode 100644
index 0000000..16e5972
--- /dev/null
+++ b/code/MessengerApi.Configuration/Sources/Environment/IEnvironmentConfigurationSource.cs
@@ -0,0 +1,6 @@
+namespace MessengerApi.Configuration.Sources.Environment
+{
+ public interface IEnvironmentConfigurationSource : IConfigurationSource
+ {
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Configuration/Sources/IConfigurationSource.cs b/code/MessengerApi.Configuration/Sources/IConfigurationSource.cs
new file mode 100644
index 0000000..9d090af
--- /dev/null
+++ b/code/MessengerApi.Configuration/Sources/IConfigurationSource.cs
@@ -0,0 +1,9 @@
+namespace MessengerApi.Configuration.Sources
+{
+ public interface IConfigurationSource
+ {
+ bool HasKey(string key);
+
+ T GetValue(string key);
+ }
+}
diff --git a/code/MessengerApi.Contracts.MessageParser/IMessageParser.cs b/code/MessengerApi.Contracts.MessageParser/IMessageParser.cs
new file mode 100644
index 0000000..514a8ca
--- /dev/null
+++ b/code/MessengerApi.Contracts.MessageParser/IMessageParser.cs
@@ -0,0 +1,22 @@
+namespace MessengerApi.Contracts.MessageParser
+{
+ ///
+ /// A tool that helps converting POCO request/response models into , and back.
+ ///
+ ///
+ /// If you implement this, it's gonna be a lot easier for you to translate
+ /// dumb request class into , then convert
+ /// back to request at server-side, and do the
+ /// same with the response all the way down to the client.
+ ///
+ public interface IMessageParser
+ {
+ 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);
+ }
+}
diff --git a/code/MessengerApi.Contracts.MessageParser/MessageParser.cs b/code/MessengerApi.Contracts.MessageParser/MessageParser.cs
new file mode 100644
index 0000000..8bcdf3c
--- /dev/null
+++ b/code/MessengerApi.Contracts.MessageParser/MessageParser.cs
@@ -0,0 +1,50 @@
+using Newtonsoft.Json;
+
+namespace MessengerApi.Contracts.MessageParser
+{
+ public class MessageParser : IMessageParser
+ {
+ 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(message.Payload);
+ return request;
+ }
+
+ public TResponse GetResponseFromMessage(InboxMessage message)
+ {
+ var request = JsonConvert.DeserializeObject(message.Payload);
+ return request;
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts.MessageParser/MessengerApi.Contracts.MessageParser.csproj b/code/MessengerApi.Contracts.MessageParser/MessengerApi.Contracts.MessageParser.csproj
new file mode 100644
index 0000000..e2e019c
--- /dev/null
+++ b/code/MessengerApi.Contracts.MessageParser/MessengerApi.Contracts.MessageParser.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ disable
+ $([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))
+ $([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))
+ True
+ ..\out\
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Contracts/Client/IMessengerClient.cs b/code/MessengerApi.Contracts/Client/IMessengerClient.cs
new file mode 100644
index 0000000..a55c73e
--- /dev/null
+++ b/code/MessengerApi.Contracts/Client/IMessengerClient.cs
@@ -0,0 +1,30 @@
+namespace MessengerApi.Contracts
+{
+ ///
+ /// Exists for mocking reason. This is implemented by .
+ ///
+ public interface IMessengerClient
+ {
+ ///
+ /// Receives pending messages from the messenger API.
+ ///
+ /// Credentials to the API.
+ IEnumerable GetMessages();
+
+ ///
+ /// Acknowledges message reception to the server.
+ ///
+ void AckMessage(InboxMessage message);
+
+ ///
+ /// Sends a message.
+ ///
+ /// Credentials to the API.
+ void SendMessage(OutboxMessage outboxMessage);
+
+ ///
+ /// Returns user ids for allowed message recipients.
+ ///
+ Contact[] GetYellowPages();
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts/Client/MessengerClient.cs b/code/MessengerApi.Contracts/Client/MessengerClient.cs
new file mode 100644
index 0000000..5d0a12d
--- /dev/null
+++ b/code/MessengerApi.Contracts/Client/MessengerClient.cs
@@ -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 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().ToArray();
+ }
+
+ var json = JsonNode.Parse(responseContent);
+ var messages = new List();
+
+ foreach (var item in json["messages"].AsArray())
+ {
+ if (item["id"].GetValue() == -1)
+ {
+ continue;
+ }
+
+ messages.Add(new InboxMessage
+ {
+ Id = item["id"].GetValue(),
+ Payload = item["payload"].ToJsonString(),
+ PayloadId = item["payloadId"].ToJsonString(),
+ PayloadType = item["payloadType"].ToJsonString(),
+ Sender = item["sender"].GetValue(),
+ SenderTimestamp = item["senderTimestamp"].GetValue()
+ });
+ }
+
+ _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(outboxMessage.ToUserId.Value));
+ }
+
+ if(outboxMessage.Payload != null)
+ {
+ body.Add("payload", JsonValue.Create(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(),
+ Name = x["name"].GetValue()
+ }).ToArray();
+
+ return contacts;
+ }
+
+ public void AckMessage(InboxMessage message)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/code/MessengerApi.Contracts/Contact.cs b/code/MessengerApi.Contracts/Contact.cs
new file mode 100644
index 0000000..1b232de
--- /dev/null
+++ b/code/MessengerApi.Contracts/Contact.cs
@@ -0,0 +1,9 @@
+namespace MessengerApi.Contracts
+{
+ public class Contact
+ {
+ public Guid Id { get; set; }
+
+ public string Name { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts/Credentials.cs b/code/MessengerApi.Contracts/Credentials.cs
new file mode 100644
index 0000000..483e43d
--- /dev/null
+++ b/code/MessengerApi.Contracts/Credentials.cs
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts/Messages/InboxMessage.cs b/code/MessengerApi.Contracts/Messages/InboxMessage.cs
new file mode 100644
index 0000000..c48d621
--- /dev/null
+++ b/code/MessengerApi.Contracts/Messages/InboxMessage.cs
@@ -0,0 +1,20 @@
+namespace MessengerApi.Contracts
+{
+ ///
+ /// Message when received is inbox. For server apps, this is request-type of message. For clients, this is a response-type of message.
+ ///
+ 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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts/Messages/OutboxMessage.cs b/code/MessengerApi.Contracts/Messages/OutboxMessage.cs
new file mode 100644
index 0000000..b2ea665
--- /dev/null
+++ b/code/MessengerApi.Contracts/Messages/OutboxMessage.cs
@@ -0,0 +1,20 @@
+namespace MessengerApi.Contracts
+{
+ ///
+ /// Outbox type of message. A server-app will treat this as a response. A client app will treat this as a request.
+ ///
+ 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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Contracts/MessengerApi.Contracts.csproj b/code/MessengerApi.Contracts/MessengerApi.Contracts.csproj
new file mode 100644
index 0000000..3b1f1a3
--- /dev/null
+++ b/code/MessengerApi.Contracts/MessengerApi.Contracts.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ disable
+ True
+ $(OutputPath)
+ $([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))
+ $([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))
+ ..\out\
+ true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Entities/IEntity.cs b/code/MessengerApi.Db.Contracts/Entities/IEntity.cs
new file mode 100644
index 0000000..40cacb7
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Entities/IEntity.cs
@@ -0,0 +1,11 @@
+namespace MessengerApi.Db.Contracts.Entities
+{
+ public interface IEntity
+ {
+ Guid Id { get; }
+ }
+
+ public interface IEntity : IEntity where T : class, IEntity
+ {
+ }
+}
diff --git a/code/MessengerApi.Db.Contracts/Entities/Message.cs b/code/MessengerApi.Db.Contracts/Entities/Message.cs
new file mode 100644
index 0000000..aa8b9a9
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Entities/Message.cs
@@ -0,0 +1,25 @@
+using MessengerApi.Db.Contracts.Entities;
+
+namespace MessengerApi.Db.Entities
+{
+ public class Message : IEntity
+ {
+ 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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Entities/User.cs b/code/MessengerApi.Db.Contracts/Entities/User.cs
new file mode 100644
index 0000000..2f81a3f
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Entities/User.cs
@@ -0,0 +1,15 @@
+using MessengerApi.Db.Contracts.Entities;
+
+namespace MessengerApi.Db.Entities
+{
+ public class User : IEntity
+ {
+ public Guid Id { get; set; }
+
+ public Guid ApiKey { get; set; }
+
+ public string Name { get; set; }
+
+ public bool IsEnabled { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Entities/UserRoute.cs b/code/MessengerApi.Db.Contracts/Entities/UserRoute.cs
new file mode 100644
index 0000000..0e0f70b
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Entities/UserRoute.cs
@@ -0,0 +1,16 @@
+using MessengerApi.Db.Contracts.Entities;
+
+namespace MessengerApi.Db.Entities
+{
+ ///
+ /// Describes allowed message route (who can message whom).
+ ///
+ public class UserRoute : IEntity
+ {
+ public Guid Id { get; set; }
+
+ public User From { get; set; }
+
+ public User To { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/MessengerApi.Db.Contracts.csproj b/code/MessengerApi.Db.Contracts/MessengerApi.Db.Contracts.csproj
new file mode 100644
index 0000000..ef83a76
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/MessengerApi.Db.Contracts.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ disable
+
+
+
diff --git a/code/MessengerApi.Db.Contracts/Repositories/IMessageRepository.cs b/code/MessengerApi.Db.Contracts/Repositories/IMessageRepository.cs
new file mode 100644
index 0000000..a249593
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Repositories/IMessageRepository.cs
@@ -0,0 +1,9 @@
+using MessengerApi.Db.Entities;
+
+namespace MessengerApi.Db.Contracts.Repositories
+{
+ public interface IMessageRepository : IRepository
+ {
+ IEnumerable GetPendingMessages(User user);
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Repositories/IRepository.cs b/code/MessengerApi.Db.Contracts/Repositories/IRepository.cs
new file mode 100644
index 0000000..4e7df01
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Repositories/IRepository.cs
@@ -0,0 +1,15 @@
+using MessengerApi.Db.Contracts.Entities;
+
+namespace MessengerApi.Db.Contracts.Repositories
+{
+ public interface IRepository
+ {
+ }
+
+ public interface IRepository : IRepository where T : class, IEntity
+ {
+ void Add(T entity);
+
+ T GetById(Guid id);
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Repositories/IUserRepository.cs b/code/MessengerApi.Db.Contracts/Repositories/IUserRepository.cs
new file mode 100644
index 0000000..f36d0e2
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Repositories/IUserRepository.cs
@@ -0,0 +1,9 @@
+using MessengerApi.Db.Entities;
+
+namespace MessengerApi.Db.Contracts.Repositories
+{
+ public interface IUserRepository : IRepository
+ {
+ User SingleByApiKeyAndEnabled(Guid id, bool enabled);
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Contracts/Repositories/IUserRouteRepository.cs b/code/MessengerApi.Db.Contracts/Repositories/IUserRouteRepository.cs
new file mode 100644
index 0000000..8e55911
--- /dev/null
+++ b/code/MessengerApi.Db.Contracts/Repositories/IUserRouteRepository.cs
@@ -0,0 +1,17 @@
+using MessengerApi.Db.Entities;
+
+namespace MessengerApi.Db.Contracts.Repositories
+{
+ public interface IUserRouteRepository:IRepository
+ {
+ ///
+ /// Returns all routes for given user.
+ ///
+ IEnumerable GetAllByUser(User sender);
+
+ ///
+ /// Returns routes where given user is sender.
+ ///
+ IEnumerable GetByFrom(User user);
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Npg.Migrator/DesignTimeDbContextFactory.cs b/code/MessengerApi.Db.Npg.Migrator/DesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..09e982f
--- /dev/null
+++ b/code/MessengerApi.Db.Npg.Migrator/DesignTimeDbContextFactory.cs
@@ -0,0 +1,13 @@
+using MessengerApi.Db.Npg;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace MessengerApi.Db.Sql.Migrator
+{
+ public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory
+ {
+ public MessengerNpgDbContext CreateDbContext(string[] args)
+ {
+ return new MessengerNpgDbContext(this.ConnectionString);
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Npg.Migrator/MessengerApi.Db.Npg.Migrator.csproj b/code/MessengerApi.Db.Npg.Migrator/MessengerApi.Db.Npg.Migrator.csproj
new file mode 100644
index 0000000..cd92fc0
--- /dev/null
+++ b/code/MessengerApi.Db.Npg.Migrator/MessengerApi.Db.Npg.Migrator.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Db.Npg.Migrator/Program.cs b/code/MessengerApi.Db.Npg.Migrator/Program.cs
new file mode 100644
index 0000000..6f016db
--- /dev/null
+++ b/code/MessengerApi.Db.Npg.Migrator/Program.cs
@@ -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
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Npg/MessengerApi.Db.Npg.csproj b/code/MessengerApi.Db.Npg/MessengerApi.Db.Npg.csproj
new file mode 100644
index 0000000..e6f0e7b
--- /dev/null
+++ b/code/MessengerApi.Db.Npg/MessengerApi.Db.Npg.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Db.Npg/MessengerNpgDbContext.cs b/code/MessengerApi.Db.Npg/MessengerNpgDbContext.cs
new file mode 100644
index 0000000..aa95d7a
--- /dev/null
+++ b/code/MessengerApi.Db.Npg/MessengerNpgDbContext.cs
@@ -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().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false);
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.Designer.cs b/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.Designer.cs
new file mode 100644
index 0000000..b8c2870
--- /dev/null
+++ b/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.Designer.cs
@@ -0,0 +1,123 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FromId")
+ .HasColumnType("uuid");
+
+ b.Property("IsAcknowledged")
+ .HasColumnType("boolean");
+
+ b.Property("IsDelivered")
+ .HasColumnType("boolean");
+
+ b.Property("Payload")
+ .HasColumnType("text");
+
+ b.Property("PayloadLifespanInSeconds")
+ .HasColumnType("integer");
+
+ b.Property("PayloadType")
+ .HasColumnType("text");
+
+ b.Property("ToId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ToId", "IsDelivered");
+
+ b.ToTable("Messages");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiKey")
+ .HasColumnType("uuid");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("FromId")
+ .HasColumnType("uuid");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.cs b/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.cs
new file mode 100644
index 0000000..521e562
--- /dev/null
+++ b/code/MessengerApi.Db.Npg/Migrations/20250704170425_Initial.cs
@@ -0,0 +1,99 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace MessengerApi.Db.Npg.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Messages",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ FromId = table.Column(type: "uuid", nullable: false),
+ ToId = table.Column(type: "uuid", nullable: false),
+ IsDelivered = table.Column(type: "boolean", nullable: false),
+ IsAcknowledged = table.Column(type: "boolean", nullable: false),
+ PayloadType = table.Column(type: "text", nullable: true),
+ Payload = table.Column(type: "text", nullable: true),
+ PayloadLifespanInSeconds = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Messages", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ ApiKey = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "text", nullable: true),
+ IsEnabled = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserRoutes",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ FromId = table.Column(type: "uuid", nullable: true),
+ ToId = table.Column(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");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Messages");
+
+ migrationBuilder.DropTable(
+ name: "UserRoutes");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Npg/Migrations/MessengerNpgDbContextModelSnapshot.cs b/code/MessengerApi.Db.Npg/Migrations/MessengerNpgDbContextModelSnapshot.cs
new file mode 100644
index 0000000..66307cb
--- /dev/null
+++ b/code/MessengerApi.Db.Npg/Migrations/MessengerNpgDbContextModelSnapshot.cs
@@ -0,0 +1,120 @@
+//
+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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FromId")
+ .HasColumnType("uuid");
+
+ b.Property("IsAcknowledged")
+ .HasColumnType("boolean");
+
+ b.Property("IsDelivered")
+ .HasColumnType("boolean");
+
+ b.Property("Payload")
+ .HasColumnType("text");
+
+ b.Property("PayloadLifespanInSeconds")
+ .HasColumnType("integer");
+
+ b.Property("PayloadType")
+ .HasColumnType("text");
+
+ b.Property("ToId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ToId", "IsDelivered");
+
+ b.ToTable("Messages");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiKey")
+ .HasColumnType("uuid");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("FromId")
+ .HasColumnType("uuid");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Sql.Migrator/DesignTimeDbContextFactory.cs b/code/MessengerApi.Db.Sql.Migrator/DesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..b0edeb4
--- /dev/null
+++ b/code/MessengerApi.Db.Sql.Migrator/DesignTimeDbContextFactory.cs
@@ -0,0 +1,12 @@
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace MessengerApi.Db.Sql.Migrator
+{
+ public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory
+ {
+ public MessengerSqlDbContext CreateDbContext(string[] args)
+ {
+ return new MessengerSqlDbContext(this.ConnectionString);
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Sql.Migrator/MessengerApi.Db.Sql.Migrator.csproj b/code/MessengerApi.Db.Sql.Migrator/MessengerApi.Db.Sql.Migrator.csproj
new file mode 100644
index 0000000..dc203c9
--- /dev/null
+++ b/code/MessengerApi.Db.Sql.Migrator/MessengerApi.Db.Sql.Migrator.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Db.Sql.Migrator/Program.cs b/code/MessengerApi.Db.Sql.Migrator/Program.cs
new file mode 100644
index 0000000..4882f97
--- /dev/null
+++ b/code/MessengerApi.Db.Sql.Migrator/Program.cs
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Sql/MessengerApi.Db.Sql.csproj b/code/MessengerApi.Db.Sql/MessengerApi.Db.Sql.csproj
new file mode 100644
index 0000000..0372cd2
--- /dev/null
+++ b/code/MessengerApi.Db.Sql/MessengerApi.Db.Sql.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Db.Sql/MessengerSqlDbContext.cs b/code/MessengerApi.Db.Sql/MessengerSqlDbContext.cs
new file mode 100644
index 0000000..6c69b6d
--- /dev/null
+++ b/code/MessengerApi.Db.Sql/MessengerSqlDbContext.cs
@@ -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().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false).IsClustered(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.Designer.cs b/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.Designer.cs
new file mode 100644
index 0000000..4ae31a3
--- /dev/null
+++ b/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.Designer.cs
@@ -0,0 +1,125 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("FromId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsAcknowledged")
+ .HasColumnType("bit");
+
+ b.Property("IsDelivered")
+ .HasColumnType("bit");
+
+ b.Property("Payload")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PayloadLifespanInSeconds")
+ .HasColumnType("int");
+
+ b.Property("PayloadType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApiKey")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FromId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.cs b/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.cs
new file mode 100644
index 0000000..e84ea41
--- /dev/null
+++ b/code/MessengerApi.Db.Sql/Migrations/20250704165018_Initial.cs
@@ -0,0 +1,100 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace MessengerApi.Db.Sql.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Messages",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ CreatedUtc = table.Column(type: "datetime2", nullable: false),
+ FromId = table.Column(type: "uniqueidentifier", nullable: false),
+ ToId = table.Column(type: "uniqueidentifier", nullable: false),
+ IsDelivered = table.Column(type: "bit", nullable: false),
+ IsAcknowledged = table.Column(type: "bit", nullable: false),
+ PayloadType = table.Column(type: "nvarchar(max)", nullable: true),
+ Payload = table.Column(type: "nvarchar(max)", nullable: true),
+ PayloadLifespanInSeconds = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Messages", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ ApiKey = table.Column(type: "uniqueidentifier", nullable: false),
+ Name = table.Column(type: "nvarchar(max)", nullable: true),
+ IsEnabled = table.Column(type: "bit", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserRoutes",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ FromId = table.Column(type: "uniqueidentifier", nullable: true),
+ ToId = table.Column(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");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Messages");
+
+ migrationBuilder.DropTable(
+ name: "UserRoutes");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/code/MessengerApi.Db.Sql/Migrations/MessengerSqlDbContextModelSnapshot.cs b/code/MessengerApi.Db.Sql/Migrations/MessengerSqlDbContextModelSnapshot.cs
new file mode 100644
index 0000000..6faf21c
--- /dev/null
+++ b/code/MessengerApi.Db.Sql/Migrations/MessengerSqlDbContextModelSnapshot.cs
@@ -0,0 +1,122 @@
+//
+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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("FromId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsAcknowledged")
+ .HasColumnType("bit");
+
+ b.Property("IsDelivered")
+ .HasColumnType("bit");
+
+ b.Property("Payload")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PayloadLifespanInSeconds")
+ .HasColumnType("int");
+
+ b.Property("PayloadType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApiKey")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FromId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/code/MessengerApi.Db/Converters/DateTimeAsUtcValueConverter.cs b/code/MessengerApi.Db/Converters/DateTimeAsUtcValueConverter.cs
new file mode 100644
index 0000000..3e62e6a
--- /dev/null
+++ b/code/MessengerApi.Db/Converters/DateTimeAsUtcValueConverter.cs
@@ -0,0 +1,8 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace MessengerApi.Db.Converters
+{
+ public sealed class DateTimeAsUtcValueConverter()
+ : ValueConverter(
+ v => v, v => new DateTime(v.Ticks, DateTimeKind.Utc));
+}
diff --git a/code/MessengerApi.Db/MessengerApi.Db.csproj b/code/MessengerApi.Db/MessengerApi.Db.csproj
new file mode 100644
index 0000000..b052c4c
--- /dev/null
+++ b/code/MessengerApi.Db/MessengerApi.Db.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ disable
+ ..\out\
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi.Db/MessengerDbContext.cs b/code/MessengerApi.Db/MessengerDbContext.cs
new file mode 100644
index 0000000..9c598b1
--- /dev/null
+++ b/code/MessengerApi.Db/MessengerDbContext.cs
@@ -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 Users { get; set; }
+
+ public DbSet Messages { get; set; }
+
+ public DbSet UserRoutes { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity().HasKey(e => e.Id);
+ modelBuilder.Entity().HasKey(e => e.Id);
+ modelBuilder.Entity().Property(e => e.CreatedUtc).HasConversion();
+ modelBuilder.Entity().Property(e => e.PayloadLifespanInSeconds).IsRequired();
+ modelBuilder.Entity().HasKey(e => e.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db/Repositories/MessageRepository.cs b/code/MessengerApi.Db/Repositories/MessageRepository.cs
new file mode 100644
index 0000000..bd305a1
--- /dev/null
+++ b/code/MessengerApi.Db/Repositories/MessageRepository.cs
@@ -0,0 +1,23 @@
+using MessengerApi.Db.Contracts.Repositories;
+using MessengerApi.Db.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MessengerApi.Db.Repositories
+{
+ public class MessageRepository : Repository, IMessageRepository
+ {
+ public MessageRepository(DbSet db) : base(db)
+ {
+ }
+
+ public IEnumerable 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db/Repositories/Repository.cs b/code/MessengerApi.Db/Repositories/Repository.cs
new file mode 100644
index 0000000..5fa6b8d
--- /dev/null
+++ b/code/MessengerApi.Db/Repositories/Repository.cs
@@ -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 : IRepository where T : class, IEntity
+ {
+ protected readonly DbSet db;
+
+ public Repository(DbSet 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db/Repositories/UserRepository.cs b/code/MessengerApi.Db/Repositories/UserRepository.cs
new file mode 100644
index 0000000..23d299e
--- /dev/null
+++ b/code/MessengerApi.Db/Repositories/UserRepository.cs
@@ -0,0 +1,18 @@
+using MessengerApi.Db.Contracts.Repositories;
+using MessengerApi.Db.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MessengerApi.Db.Repositories
+{
+ public class UserRepository : Repository, IUserRepository
+ {
+ public UserRepository(DbSet db) : base(db)
+ {
+ }
+
+ public User SingleByApiKeyAndEnabled(Guid id, bool enabled)
+ {
+ return this.db.Single(x => x.ApiKey == id && x.IsEnabled == enabled);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.Db/Repositories/UserRouteRepository.cs b/code/MessengerApi.Db/Repositories/UserRouteRepository.cs
new file mode 100644
index 0000000..9403ce6
--- /dev/null
+++ b/code/MessengerApi.Db/Repositories/UserRouteRepository.cs
@@ -0,0 +1,23 @@
+using MessengerApi.Db.Contracts.Repositories;
+using MessengerApi.Db.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MessengerApi.Db.Repositories
+{
+ public class UserRouteRepository : Repository, IUserRouteRepository
+ {
+ public UserRouteRepository(DbSet db) : base(db)
+ {
+ }
+
+ public IEnumerable 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 GetByFrom(User user)
+ {
+ return this.db.Include(x => x.From).Include(x => x.To).Where(x => x.From.Id == user.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi.sln b/code/MessengerApi.sln
new file mode 100644
index 0000000..85a6f9a
--- /dev/null
+++ b/code/MessengerApi.sln
@@ -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
diff --git a/code/MessengerApi/.config/dotnet-tools.json b/code/MessengerApi/.config/dotnet-tools.json
new file mode 100644
index 0000000..76ca931
--- /dev/null
+++ b/code/MessengerApi/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "8.0.10",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Constants.cs b/code/MessengerApi/Constants.cs
new file mode 100644
index 0000000..a3f5f2f
--- /dev/null
+++ b/code/MessengerApi/Constants.cs
@@ -0,0 +1,7 @@
+namespace MessengerApi
+{
+ public class Constants
+ {
+ public const string USERFILE_FILENAME = "/app/users.conf";
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Contracts/Factories/IDbContextFactory.cs b/code/MessengerApi/Contracts/Factories/IDbContextFactory.cs
new file mode 100644
index 0000000..0b16e89
--- /dev/null
+++ b/code/MessengerApi/Contracts/Factories/IDbContextFactory.cs
@@ -0,0 +1,9 @@
+using MessengerApi.Db;
+
+namespace MessengerApi.Contracts.Factories
+{
+ public interface IDbContextFactory
+ {
+ MessengerDbContext CreateDbContext();
+ }
+}
diff --git a/code/MessengerApi/Contracts/Models/Scoped/IUnitOfWork.cs b/code/MessengerApi/Contracts/Models/Scoped/IUnitOfWork.cs
new file mode 100644
index 0000000..0992bf1
--- /dev/null
+++ b/code/MessengerApi/Contracts/Models/Scoped/IUnitOfWork.cs
@@ -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);
+ }
+}
diff --git a/code/MessengerApi/Dockerfile b/code/MessengerApi/Dockerfile
new file mode 100644
index 0000000..b1b00f4
--- /dev/null
+++ b/code/MessengerApi/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/code/MessengerApi/Factories/DbContextFactory.cs b/code/MessengerApi/Factories/DbContextFactory.cs
new file mode 100644
index 0000000..7959e53
--- /dev/null
+++ b/code/MessengerApi/Factories/DbContextFactory.cs
@@ -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
+ {
+ 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Factories/LoggerFactory.cs b/code/MessengerApi/Factories/LoggerFactory.cs
new file mode 100644
index 0000000..8a27428
--- /dev/null
+++ b/code/MessengerApi/Factories/LoggerFactory.cs
@@ -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();
+ }
+ }
+}
diff --git a/code/MessengerApi/GlobalUsings.cs b/code/MessengerApi/GlobalUsings.cs
new file mode 100644
index 0000000..978d27f
--- /dev/null
+++ b/code/MessengerApi/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using portaloggy;
+global using ILogger = portaloggy.ILogger;
\ No newline at end of file
diff --git a/code/MessengerApi/Handlers/CustomBearerAuthenticationHandler.cs b/code/MessengerApi/Handlers/CustomBearerAuthenticationHandler.cs
new file mode 100644
index 0000000..b67d99a
--- /dev/null
+++ b/code/MessengerApi/Handlers/CustomBearerAuthenticationHandler.cs
@@ -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
+{
+ ///
+ /// Validates our permananet API keys sent over as Bearer tokens.
+ ///
+ public class CustomBearerAuthenticationHandler : AuthenticationHandler
+ {
+ private readonly IMemoryCache memoryCache;
+
+ public CustomBearerAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory loggerFactory,
+ UrlEncoder encoder,
+ IMemoryCache memoryCache)
+ : base(options, loggerFactory, encoder)
+ {
+ this.memoryCache = memoryCache;
+ }
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ const string HEADER = "Authorization";
+ const string PREFIX = "Bearer ";
+
+ Context.RequestServices.GetRequiredService(); // 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.User = oldCache.User;
+ identity.UserRoutes = oldCache.UserRoutes;
+ return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(oldCache.ClaimsPrincipal, Scheme.Name)));
+ }
+ else
+ {
+ var unitOfWork = Context.RequestServices.GetRequiredService();
+ var user = unitOfWork.Users.SingleByApiKeyAndEnabled(Guid.Parse(token), true);
+ var routes = unitOfWork.UserRoutes.GetAllByUser(user).ToArray();
+
+ var principal = new ClaimsPrincipal(
+ new ClaimsIdentity(
+ new List
+ {
+ 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.User = cache.User;
+ identity.UserRoutes = cache.UserRoutes;
+ return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(cache.ClaimsPrincipal, Scheme.Name)));
+ }
+ }
+ }
+}
diff --git a/code/MessengerApi/Handlers/Endpoint/AckEndpointHandler.cs b/code/MessengerApi/Handlers/Endpoint/AckEndpointHandler.cs
new file mode 100644
index 0000000..a161f9f
--- /dev/null
+++ b/code/MessengerApi/Handlers/Endpoint/AckEndpointHandler.cs
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Handlers/Endpoint/PeekEndpointHandler.cs b/code/MessengerApi/Handlers/Endpoint/PeekEndpointHandler.cs
new file mode 100644
index 0000000..b3f08cf
--- /dev/null
+++ b/code/MessengerApi/Handlers/Endpoint/PeekEndpointHandler.cs
@@ -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 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());
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Handlers/Endpoint/ReceiveEndpointHandler.cs b/code/MessengerApi/Handlers/Endpoint/ReceiveEndpointHandler.cs
new file mode 100644
index 0000000..08f0f38
--- /dev/null
+++ b/code/MessengerApi/Handlers/Endpoint/ReceiveEndpointHandler.cs
@@ -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 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());
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Handlers/Endpoint/SendEndpointHandler.cs b/code/MessengerApi/Handlers/Endpoint/SendEndpointHandler.cs
new file mode 100644
index 0000000..f6e786a
--- /dev/null
+++ b/code/MessengerApi/Handlers/Endpoint/SendEndpointHandler.cs
@@ -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 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Handlers/HousekeepingHandler.cs b/code/MessengerApi/Handlers/HousekeepingHandler.cs
new file mode 100644
index 0000000..4c48f56
--- /dev/null
+++ b/code/MessengerApi/Handlers/HousekeepingHandler.cs
@@ -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();
+ }
+ }
+ }
+ }
+}
diff --git a/code/MessengerApi/Handlers/UserSetupHandler.cs b/code/MessengerApi/Handlers/UserSetupHandler.cs
new file mode 100644
index 0000000..4148995
--- /dev/null
+++ b/code/MessengerApi/Handlers/UserSetupHandler.cs
@@ -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 ReadLines(string[] lines)
+ {
+ var items = new List();
+
+ 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 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;
+ }
+ }
+}
diff --git a/code/MessengerApi/MessengerApi.csproj b/code/MessengerApi/MessengerApi.csproj
new file mode 100644
index 0000000..16f652b
--- /dev/null
+++ b/code/MessengerApi/MessengerApi.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net9.0
+ disable
+ enable
+ 85c81e87-1274-45ce-8b91-6d6619ffdfa2
+ Linux
+ ..\out\
+ MessengerApi.Api.Program
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/MessengerApi/Models/CachedIdentity.cs b/code/MessengerApi/Models/CachedIdentity.cs
new file mode 100644
index 0000000..adc676d
--- /dev/null
+++ b/code/MessengerApi/Models/CachedIdentity.cs
@@ -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; }
+ }
+}
diff --git a/code/MessengerApi/Models/Http/AckRequest.cs b/code/MessengerApi/Models/Http/AckRequest.cs
new file mode 100644
index 0000000..ccc7aa9
--- /dev/null
+++ b/code/MessengerApi/Models/Http/AckRequest.cs
@@ -0,0 +1,7 @@
+namespace MessengerApi.Models.Http
+{
+ public class AckRequest
+ {
+ public Guid MessageId { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Models/Http/SendRequest.cs b/code/MessengerApi/Models/Http/SendRequest.cs
new file mode 100644
index 0000000..42896d1
--- /dev/null
+++ b/code/MessengerApi/Models/Http/SendRequest.cs
@@ -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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Models/Http/VerifyRequest.cs b/code/MessengerApi/Models/Http/VerifyRequest.cs
new file mode 100644
index 0000000..b0c6f72
--- /dev/null
+++ b/code/MessengerApi/Models/Http/VerifyRequest.cs
@@ -0,0 +1,7 @@
+namespace MessengerApi.Models.Http
+{
+ public class VerifyRequest
+ {
+ public Guid MessageId { get; set; }
+ }
+}
diff --git a/code/MessengerApi/Models/Scoped/Identity.cs b/code/MessengerApi/Models/Scoped/Identity.cs
new file mode 100644
index 0000000..0c8370b
--- /dev/null
+++ b/code/MessengerApi/Models/Scoped/Identity.cs
@@ -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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Models/Scoped/Timing.cs b/code/MessengerApi/Models/Scoped/Timing.cs
new file mode 100644
index 0000000..faecb26
--- /dev/null
+++ b/code/MessengerApi/Models/Scoped/Timing.cs
@@ -0,0 +1,7 @@
+namespace MessengerApi.Models.Scoped
+{
+ public class Timing
+ {
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+ }
+}
diff --git a/code/MessengerApi/Models/Scoped/UnitOfWork.cs b/code/MessengerApi/Models/Scoped/UnitOfWork.cs
new file mode 100644
index 0000000..abfe242
--- /dev/null
+++ b/code/MessengerApi/Models/Scoped/UnitOfWork.cs
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Models/UserSetupItem.cs b/code/MessengerApi/Models/UserSetupItem.cs
new file mode 100644
index 0000000..2aad706
--- /dev/null
+++ b/code/MessengerApi/Models/UserSetupItem.cs
@@ -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; }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Program.cs b/code/MessengerApi/Program.cs
new file mode 100644
index 0000000..f76f268
--- /dev/null
+++ b/code/MessengerApi/Program.cs
@@ -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(configuration);
+ builder.Services.AddSingleton(new Factories.LoggerFactory(configuration).CreateLogger());
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // Authentication.
+ builder.Services
+ .AddAuthentication("Bearer")
+ .AddScheme("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 =>
+ {
+ 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(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().CreateDbContext())
+ {
+ var migrationLogger = app.Services.GetRequiredService();
+
+ 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().RemoveOldMessages();
+ await Task.Delay(TimeSpan.FromMinutes(1));
+ }
+ });
+ }
+
+ // User synchronization
+ var userSetupHandler = app.Services.GetRequiredService();
+ 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();
+ 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/MessengerApi/Properties/launchSettings.json b/code/MessengerApi/Properties/launchSettings.json
new file mode 100644
index 0000000..c29747b
--- /dev/null
+++ b/code/MessengerApi/Properties/launchSettings.json
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0922b77
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,7 @@
+services:
+ messengerapi:
+ image: https://gitea.masita.net/mc/messengerapi:latest
+ container_name: messengerapi
+ restart: unless-stopped
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Production
\ No newline at end of file