Add project files

This commit is contained in:
Sean Greenawalt 2025-05-10 05:16:40 -04:00
commit 8cf01ead74
Signed by: seang96
GPG key ID: 504F02B511005571
40 changed files with 3967 additions and 0 deletions

26
.dockerignore Normal file
View file

@ -0,0 +1,26 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
/StalwartSDK/StalwartClient.cs

View file

@ -0,0 +1,40 @@
on:
push:
branches:
- "**"
pull_request:
types: [ opened, synchronize, reopened ]
jobs:
publish_debug:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
git.spgrn.com/${{ env.GITHUB_REPOSITORY }}-web
tags: |
type=ref,event=pr
type=ref,event=branch
type=sha
- name: Checkout
uses: actions/checkout@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: git.spgrn.com
username: seang96
password: ${{ secrets.TOKEN }}
- name: Build Web Application
uses: docker/build-push-action@v5
with:
context: .
file: ./StalwartSimpleLoginMiddleware/Dockerfile
platforms: linux/amd64
push: true
BUILD_CONFIGURATION=Debug
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -0,0 +1,40 @@
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
git.spgrn.com/${{ env.GITHUB_REPOSITORY }}-web
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
type=sha
- name: Checkout
uses: actions/checkout@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: git.spgrn.com
username: seang96
password: ${{ secrets.TOKEN }}
- name: Build Web Application
uses: docker/build-push-action@v5
with:
context: .
file: ./StalwartSimpleLoginMiddleware/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

7
.gitignore vendored
View file

@ -77,3 +77,10 @@ fabric.properties
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/StalwartSDK/StalwartClient.cs
launchSettings.json

View file

@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StalwartSimpleLoginMiddleware", "StalwartSimpleLoginMiddleware\StalwartSimpleLoginMiddleware.csproj", "{AFC6627E-CA72-440D-B405-384AB62F12C2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StalwartSDK", "StalwartSDK\StalwartSDK.csproj", "{AD805E73-59A5-402C-8EE3-41113DDE86E7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AFC6627E-CA72-440D-B405-384AB62F12C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFC6627E-CA72-440D-B405-384AB62F12C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFC6627E-CA72-440D-B405-384AB62F12C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFC6627E-CA72-440D-B405-384AB62F12C2}.Release|Any CPU.Build.0 = Release|Any CPU
{AD805E73-59A5-402C-8EE3-41113DDE86E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD805E73-59A5-402C-8EE3-41113DDE86E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD805E73-59A5-402C-8EE3-41113DDE86E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD805E73-59A5-402C-8EE3-41113DDE86E7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,23 @@
using System.Net.Http.Headers;
namespace AdOrbitSDK;
public partial class Client
{
private readonly string _apiKey;
public Client(HttpClient httpClient, string apiKey)
: this(httpClient)
{
_apiKey = apiKey;
}
partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)
{
if (client.BaseAddress == null) throw new NullReferenceException("Base address cannot be null");
var authHeader = new AuthenticationHeaderValue("Bearer", _apiKey);
request.Headers.Authorization = authHeader;
}
}

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NSwag.MSBuild" Version="14.2.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<Target Name="NSwag" AfterTargets="CoreCompile">
<Exec WorkingDirectory="$(ProjectDir)"
Command="$(NSwagExe_Net80) run nswag.json"
ContinueOnError="false"
/>
</Target>
<Target Name="CleanGeneratedFiles" AfterTargets="CoreClean">
<Delete Files="$(ProjectDir)\StalwartClient.cs"/>
</Target>
</Project>

103
StalwartSDK/nswag.json Normal file
View file

@ -0,0 +1,103 @@
{
"runtime": "Net80",
"defaultVariables": null,
"documentGenerator": {
"fromDocument": {
"url": "openapi.yml",
"output": null,
"newLineBehavior": "Auto"
}
},
"codeGenerators": {
"openApiToCSharpClient": {
"clientBaseClass": null,
"configurationClass": null,
"generateClientClasses": true,
"suppressClientClassesOutput": false,
"generateClientInterfaces": false,
"suppressClientInterfacesOutput": false,
"clientBaseInterface": null,
"injectHttpClient": true,
"disposeHttpClient": true,
"protectedMethods": [],
"generateExceptionClasses": true,
"exceptionClass": "ApiException",
"wrapDtoExceptions": true,
"useHttpClientCreationMethod": false,
"httpClientType": "System.Net.Http.HttpClient",
"useHttpRequestMessageCreationMethod": false,
"useBaseUrl": false,
"generateBaseUrlProperty": true,
"generateSyncMethods": false,
"generatePrepareRequestAndProcessResponseAsAsyncMethods": false,
"exposeJsonSerializerSettings": false,
"clientClassAccessModifier": "public",
"typeAccessModifier": "public",
"propertySetterAccessModifier": "",
"generateNativeRecords": false,
"generateContractsOutput": false,
"contractsNamespace": null,
"contractsOutputFilePath": null,
"parameterDateTimeFormat": "yyyy-MM-dd HH:mm:ss",
"parameterDateFormat": "yyyy-MM-dd",
"generateUpdateJsonSerializerSettingsMethod": true,
"useRequestAndResponseSerializationSettings": false,
"serializeTypeInformation": false,
"queryNullValue": "",
"className": "{controller}Client",
"operationGenerationMode": "MultipleClientsFromOperationId",
"additionalNamespaceUsages": [],
"additionalContractNamespaceUsages": [],
"generateOptionalParameters": true,
"generateJsonMethods": false,
"enforceFlagEnums": false,
"parameterArrayType": "System.Collections.Generic.IEnumerable",
"parameterDictionaryType": "System.Collections.Generic.IDictionary",
"responseArrayType": "System.Collections.Generic.ICollection",
"responseDictionaryType": "System.Collections.Generic.IDictionary",
"wrapResponses": false,
"wrapResponseMethods": [],
"generateResponseClasses": true,
"responseClass": "SwaggerResponse",
"namespace": "AdOrbitSDK",
"requiredPropertiesMustBeDefined": true,
"dateType": "System.DateTimeOffset",
"jsonConverters": null,
"anyType": "object",
"dateTimeType": "System.DateTimeOffset",
"timeType": "System.TimeSpan",
"timeSpanType": "System.TimeSpan",
"arrayType": "System.Collections.Generic.ICollection",
"arrayInstanceType": "System.Collections.ObjectModel.Collection",
"dictionaryType": "System.Collections.Generic.IDictionary",
"dictionaryInstanceType": "System.Collections.Generic.Dictionary",
"arrayBaseType": "System.Collections.ObjectModel.Collection",
"dictionaryBaseType": "System.Collections.Generic.Dictionary",
"classStyle": "Poco",
"jsonLibrary": "NewtonsoftJson",
"generateDefaultValues": true,
"generateDataAnnotations": true,
"excludedTypeNames": [],
"excludedParameterNames": [
"Accept"
],
"handleReferences": false,
"generateImmutableArrayProperties": false,
"generateImmutableDictionaryProperties": false,
"jsonSerializerSettingsTransformationMethod": null,
"inlineNamedArrays": false,
"inlineNamedDictionaries": false,
"inlineNamedTuples": true,
"inlineNamedAny": false,
"generateDtoTypes": true,
"generateOptionalPropertiesAsNullable": true,
"generateNullableReferenceTypes": false,
"templateDirectory": null,
"serviceHost": null,
"serviceSchemes": null,
"output": "StalwartClient.cs",
"newLineBehavior": "Auto",
"AllowAdditionalProperties": true
}
}
}

2590
StalwartSDK/openapi.yml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
namespace StalwartSimpleLoginMiddleware.Constants;
public class EnvironmentVariable
{
public const string MailServerUri = "MAIL_SERVER_URI";
public const string MailServerApiKey = "MAIL_SERVER_API_KEY";
public const string PostgresUrl = "POSTGRES_URL";
public const string PostgresMinPoolSize = "POSTGRES_MIN_POOL_SIZE";
public const string PostgresMaxPoolSize = "POSTGRES_MAX_POOL_SIZE";
}

View file

@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StalwartSimpleLoginMiddleware.Constants;
using StalwartSimpleLoginMiddleware.Entities;
using StalwartSimpleLoginMiddleware.Utilities;
namespace StalwartSimpleLoginMiddleware.Contexts;
public class ApiKeyContext : DbContext
{
public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Member> Members { get; set; }
public ApiKeyContext()
{
}
public ApiKeyContext(DbContextOptions<ApiKeyContext> options) : base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
ConfigureOptions(optionsBuilder);
}
public static DbContextOptionsBuilder ConfigureOptions(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured) return optionsBuilder;
var connectionString = Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresUrl);
if (string.IsNullOrEmpty(connectionString))
{
// Default connection
var defaultConnectionString = "Host=localhost;Port=5432;Database=postgres;";
optionsBuilder.UseNpgsql(defaultConnectionString,
nsqlOptions => nsqlOptions.MigrationsAssembly("StalwartSimpleLoginMiddleware"));
return optionsBuilder;
}
var pgConnectionString = ConnectionHelper.GetPostgresConnectionString(connectionString);
// Configure pooling
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(pgConnectionString)
{
MinPoolSize = 10
};
if (int.TryParse(Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresMinPoolSize),
out var minPoolSize))
{
connectionStringBuilder.MinPoolSize = minPoolSize;
}
if (int.TryParse(Environment.GetEnvironmentVariable(EnvironmentVariable.PostgresMaxPoolSize),
out var maxPoolSize))
{
connectionStringBuilder.MaxPoolSize = maxPoolSize;
}
var pooledConnectionString = connectionStringBuilder.ToString();
optionsBuilder.UseLazyLoadingProxies()
.UseNpgsql(pooledConnectionString,
nsqlOptions => nsqlOptions.MigrationsAssembly("StalwartSimpleLoginMiddleware"));
return optionsBuilder;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApiKey>(entity =>
{
entity.HasKey(a => a.Key); // Primary key
// Members navigation
entity.HasMany(a => a.Members)
.WithOne(m => m.ApiKey)
.HasForeignKey(m => m.ApiKeyId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
});
// Configure Member entity
modelBuilder.Entity<Member>(entity =>
{
// Composite key: ApiKeyId + Email
entity.HasKey(m => new { m.ApiKeyId, m.Email });
// Configure foreign key relationship with ApiKey
entity.HasOne(m => m.ApiKey)
.WithMany(a => a.Members)
.HasForeignKey(m => m.ApiKeyId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
});
}
// Method to check and apply migrations
public async Task EnsureDatabaseMigrated(ILogger logger)
{
logger.LogInformation("Checking available migrations...");
// Get all migrations and log them
var migrations = Database.GetMigrations().ToList();
logger.LogInformation($"Total migrations found: {migrations.Count}");
foreach (var migration in migrations)
{
logger.LogInformation($"Migration: {migration}");
}
// Check applied migrations
var pendingMigrations = (await Database.GetPendingMigrationsAsync()).ToArray();
var appliedMigrations = (await Database.GetAppliedMigrationsAsync()).ToArray();
logger.LogInformation($"Applied migrations: {string.Join(", ", appliedMigrations)}");
if (pendingMigrations.Any())
{
logger.LogInformation($"Applying migrations: {string.Join(", ", pendingMigrations)}");
await Database.MigrateAsync();
logger.LogInformation("Database migrated.");
if (appliedMigrations.Length == 0)
{
// Create Admin key on first migration
var apiKey = ApiKeyHelper.GenerateKey();
ApiKeys.Add(new ApiKey
{
Key = apiKey,
OwnerEmail = "admin@domain.tld",
IsAdmin = true
});
await SaveChangesAsync();
Console.WriteLine($"Generated Admin API Key: {apiKey}");
}
}
else
{
logger.LogInformation("No pending migrations found.");
}
}
}

View file

@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StalwartSimpleLoginMiddleware.Contexts;
using StalwartSimpleLoginMiddleware.Entities;
using StalwartSimpleLoginMiddleware.Models;
using StalwartSimpleLoginMiddleware.Utilities;
namespace StalwartSimpleLoginMiddleware.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]/[action]")]
public class AdminController : ControllerBase
{
private readonly ApiKeyContext context;
public AdminController(ApiKeyContext context)
{
this.context = context;
}
[HttpGet]
public async Task<ApiKey[]> ListApiKeys([FromQuery] int page = 0, [FromQuery] int limit = 100)
{
return await context.ApiKeys
.Include(apiKey => apiKey.Members)
.Skip(page * limit)
.Take(limit).ToArrayAsync();
}
[HttpGet]
public async Task<ApiKey> GetApiKey([FromQuery] string key)
{
var apiKey = await context.ApiKeys
.Include(apiKey => apiKey.Members)
.Select(apiKey => new ApiKey
{
Key = apiKey.Key,
OwnerEmail = apiKey.OwnerEmail,
IsAdmin = apiKey.IsAdmin,
Members = apiKey.Members.ToArray()
})
.FirstOrDefaultAsync(apiKey => apiKey.Key == key);
if (apiKey == null) throw new BadHttpRequestException("API Key is invalid.");
return apiKey;
}
[HttpPost]
public async Task<ActionResult> UpdateApiKeyOwnerEmail([FromBody] UpdateOwnerEmailInput input)
{
var rows = await context.ApiKeys.Where(apiKey => apiKey.Key == input.ApiKey)
.ExecuteUpdateAsync(apiKey => apiKey.SetProperty(p => p.OwnerEmail, input.OwnerEmail));
if (rows == 0) return NotFound();
return Ok();
}
[HttpPost]
public async Task<ActionResult> CreateApiKey([FromBody] ApiKeyInput newApiKeyInput)
{
if (string.IsNullOrEmpty(ApiKeyHelper.GetEmailDomain(newApiKeyInput.OwnerEmail)))
return BadRequest("Owner Email must be a valid email address.");
var apiKey = new ApiKey
{
Key = ApiKeyHelper.GenerateKey(),
OwnerEmail = newApiKeyInput.OwnerEmail,
IsAdmin = newApiKeyInput.IsAdmin,
Members = newApiKeyInput.Members.Select(m => new Member { Email = m.Email, IsExternal = m.IsExternal })
.ToArray()
};
context.ApiKeys.Add(apiKey);
await context.SaveChangesAsync();
return CreatedAtAction(nameof(GetApiKey), new { key = apiKey.Key }, apiKey);
}
[HttpPost]
public async Task<ActionResult> CreateApiKeyMember([FromBody] AddApiKeyMemberInput input)
{
var member = new Member
{
ApiKeyId = input.ApiKey,
Email = input.Member.Email,
IsExternal = input.Member.IsExternal
};
context.Members.Add(member);
await context.SaveChangesAsync();
return CreatedAtAction(nameof(GetApiKey), new { key = input.ApiKey });
}
[HttpDelete]
public async Task<ActionResult> DeleteApiKey([FromQuery] string key)
{
var rows = await context.ApiKeys.Where(apiKey => apiKey.Key == key)
.ExecuteDeleteAsync();
if (rows == 0) return NotFound();
return Ok();
}
[HttpDelete]
public async Task<ActionResult> DeleteApiKeyMemberEmail([FromQuery] string key, [FromQuery] string email)
{
var rows = await context.Members.Where(member => member.ApiKeyId == key)
.Where(member => member.Email == email)
.ExecuteDeleteAsync();
if (rows == 0) return NotFound();
return Ok();
}
}

View file

@ -0,0 +1,56 @@
using AdOrbitSDK;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StalwartSimpleLoginMiddleware.Models;
using StalwartSimpleLoginMiddleware.Services;
namespace StalwartSimpleLoginMiddleware.Controllers;
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class AliasController(StalwartClient stalwartClient) : ControllerBase
{
[Route("random/new")]
[HttpPost]
public async Task<IActionResult> NewRandomAlias([FromQuery] string? hostname,
[FromQuery] string? mode,
[FromBody] NewRandomAliasInput body)
{
var apiKeyAccessor = HttpContext.RequestServices.GetRequiredService<IApiKeyAccessor>();
var randomAlias = Get8CharacterRandomString();
var client = await stalwartClient.GetClient();
var requestBody = new Body2
{
Type = "list",
Name = randomAlias,
Description = body.Note,
Emails = new List<object> { $"{randomAlias}@{apiKeyAccessor.Metadata.Domain}" },
Members = apiKeyAccessor.Metadata.Members as ICollection<object>,
ExternalMembers = apiKeyAccessor.Metadata.ExternalMembers as ICollection<object>
};
await client.PrincipalPOSTAsync(requestBody);
return Created(null as string, new NewRandomAliasOutput
{
CreationDate = DateTime.Today.Date,
CreationTimestamp = DateTime.Now.Ticks,
Email = requestBody.Emails?.OfType<string>().FirstOrDefault() ?? string.Empty,
Alias = requestBody.Emails?.OfType<string>().FirstOrDefault() ?? string.Empty,
Name = requestBody.Name ?? string.Empty,
Enabled = true,
Id = new Random().Next(),
Mailbox = new Mailbox { Id = new Random().Next(), Email = apiKeyAccessor.Metadata.Members.First() },
Mailboxes = apiKeyAccessor.Metadata.Members.Concat(apiKeyAccessor.Metadata.ExternalMembers)
.Select(email => new Mailbox { Id = new Random().Next(), Email = email.ToString() }),
Note = body.Note ?? string.Empty,
});
}
private static string Get8CharacterRandomString()
{
var path = Path.GetRandomFileName();
path = path.Replace(".", ""); // Remove period
return path.Substring(0, 8); // Return 8 character string
}
}

View file

@ -0,0 +1,29 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extra AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build
ARG BUILD_CONFIGURATION=Release
ENV DOTNET_TOOLS_PATH=/build/.dotnet-tools
ENV PATH="$DOTNET_TOOLS_PATH:$PATH"
RUN dotnet tool install NSwag.ConsoleCore --tool-path $DOTNET_TOOLS_PATH --version 14.2.0.0
WORKDIR /src
COPY StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj StalwartSimpleLoginMiddleware/
COPY StalwartSDK/StalwartSDK.csproj StalwartSDK/
RUN dotnet restore "StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj"
COPY . .
# Run NSwag for StalwartSDK
RUN cd /src/StalwartSDK && \
nswag run nswag.json
RUN dotnet publish StalwartSimpleLoginMiddleware/StalwartSimpleLoginMiddleware.csproj -c $BUILD_CONFIGURATION --no-restore -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "StalwartSimpleLoginMiddleware.dll"]

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace StalwartSimpleLoginMiddleware.Entities;
public class ApiKey
{
[Key]
[StringLength(88)]
[Unicode(false)]
public string Key { get; set; }
[Required] [StringLength(254)] public string OwnerEmail { get; set; }
public bool IsAdmin { get; set; }
public virtual ICollection<Member> Members { get; set; }
}

View file

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace StalwartSimpleLoginMiddleware.Entities;
public class Member
{
[Required] [StringLength(88)] public string ApiKeyId { get; set; }
[Required] [StringLength(254)] public string Email { get; set; }
public bool IsExternal { get; set; }
public virtual ApiKey ApiKey { get; set; }
}

View file

@ -0,0 +1,83 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StalwartSimpleLoginMiddleware.Contexts;
#nullable disable
namespace StalwartSimpleLoginMiddleware.Migrations
{
[DbContext(typeof(ApiKeyContext))]
[Migration("20250510085312_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b =>
{
b.Property<string>("Key")
.HasMaxLength(88)
.IsUnicode(false)
.HasColumnType("character varying(88)");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<string>("OwnerEmail")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.HasKey("Key");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b =>
{
b.Property<string>("ApiKeyId")
.HasMaxLength(88)
.HasColumnType("character varying(88)");
b.Property<string>("Email")
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.HasKey("ApiKeyId", "Email");
b.ToTable("Members");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b =>
{
b.HasOne("StalwartSimpleLoginMiddleware.Entities.ApiKey", "ApiKey")
.WithMany("Members")
.HasForeignKey("ApiKeyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApiKey");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b =>
{
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace StalwartSimpleLoginMiddleware.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApiKeys",
columns: table => new
{
Key = table.Column<string>(type: "character varying(88)", unicode: false, maxLength: 88, nullable: false),
OwnerEmail = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: false),
IsAdmin = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Key);
});
migrationBuilder.CreateTable(
name: "Members",
columns: table => new
{
ApiKeyId = table.Column<string>(type: "character varying(88)", maxLength: 88, nullable: false),
Email = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: false),
IsExternal = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Members", x => new { x.ApiKeyId, x.Email });
table.ForeignKey(
name: "FK_Members_ApiKeys_ApiKeyId",
column: x => x.ApiKeyId,
principalTable: "ApiKeys",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Members");
migrationBuilder.DropTable(
name: "ApiKeys");
}
}
}

View file

@ -0,0 +1,80 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StalwartSimpleLoginMiddleware.Contexts;
#nullable disable
namespace StalwartSimpleLoginMiddleware.Migrations
{
[DbContext(typeof(ApiKeyContext))]
partial class ApiKeyContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b =>
{
b.Property<string>("Key")
.HasMaxLength(88)
.IsUnicode(false)
.HasColumnType("character varying(88)");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<string>("OwnerEmail")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.HasKey("Key");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b =>
{
b.Property<string>("ApiKeyId")
.HasMaxLength(88)
.HasColumnType("character varying(88)");
b.Property<string>("Email")
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.HasKey("ApiKeyId", "Email");
b.ToTable("Members");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.Member", b =>
{
b.HasOne("StalwartSimpleLoginMiddleware.Entities.ApiKey", "ApiKey")
.WithMany("Members")
.HasForeignKey("ApiKeyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApiKey");
});
modelBuilder.Entity("StalwartSimpleLoginMiddleware.Entities.ApiKey", b =>
{
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,7 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class AddApiKeyMemberInput
{
public string ApiKey { get; set; }
public MemberInput Member { get; set; }
}

View file

@ -0,0 +1,9 @@
using AspNetCore.Authentication.ApiKey;
namespace StalwartSimpleLoginMiddleware.Models;
public class ApiKeyAccessor : IApiKeyAccessor
{
public IApiKey ApiKey { get; set; }
public KeyMetadata Metadata { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class ApiKeyInput
{
public string OwnerEmail { get; set; }
public bool IsAdmin { get; set; }
public ICollection<MemberInput> Members { get; set; }
}

View file

@ -0,0 +1,11 @@
using System.Security.Claims;
using AspNetCore.Authentication.ApiKey;
namespace StalwartSimpleLoginMiddleware.Models;
public class DbApiKey : IApiKey
{
public string Key { get; set; }
public string OwnerName { get; set; }
public IReadOnlyCollection<Claim> Claims { get; set; }
}

View file

@ -0,0 +1,9 @@
using AspNetCore.Authentication.ApiKey;
namespace StalwartSimpleLoginMiddleware.Models;
public interface IApiKeyAccessor
{
IApiKey ApiKey { get; set; }
KeyMetadata Metadata { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class KeyMetadata
{
public string Domain { get; set; }
public ICollection<string> Members { get; set; }
public ICollection<string> ExternalMembers { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class MemberInput
{
public string Email { get; set; }
public bool IsExternal { get; set; }
}

View file

@ -0,0 +1,6 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class NewRandomAliasInput
{
public string? Note { get; set; }
}

View file

@ -0,0 +1,21 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class NewRandomAliasOutput
{
public DateTime CreationDate { get; set; }
public long CreationTimestamp { get; set; }
public string Email { get; set; }
public string Alias { get; set; }
public string Name { get; set; }
public bool Enabled { get; set; }
public int Id { get; set; }
public Mailbox Mailbox { get; set; }
public IEnumerable<Mailbox> Mailboxes { get; set; }
public string Note { get; set; }
}
public class Mailbox
{
public string Email { get; set; }
public int Id { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace StalwartSimpleLoginMiddleware.Models;
public class UpdateOwnerEmailInput
{
public string ApiKey { get; set; }
public string OwnerEmail { get; set; }
}

View file

@ -0,0 +1,116 @@
using System.Text.Json.Serialization;
using AspNetCore.Authentication.ApiKey;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.OpenApi.Models;
using Npgsql;
using StalwartSimpleLoginMiddleware.Contexts;
using StalwartSimpleLoginMiddleware.Models;
using StalwartSimpleLoginMiddleware.Repositories;
using StalwartSimpleLoginMiddleware.Services;
var logger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("Startup");
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddDbContextPool<ApiKeyContext>((_, options) => ApiKeyContext.ConfigureOptions(options));
builder.Services.AddHealthChecks()
.AddCheck("WebService", () => HealthCheckResult.Healthy("The web service is running."))
.AddDbContextCheck<ApiKeyContext>();
builder.Services.AddScoped<IApiKeyAccessor, ApiKeyAccessor>()
.AddScoped<IApiKeyRepository, ApiKeyContextRepository>();
builder.Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme)
.AddApiKeyInHeader(options =>
{
options.Realm = "StalwartSimpleLoginMiddleware";
options.KeyName = "Authentication";
options.Events = new ApiKeyProvider();
});
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
});
builder.Services.AddControllers()
.AddJsonOptions(options => { options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; });
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
// Add the API Key Security Definition
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
{
Description =
"API Key needed to access endpoints. Add it to the request headers as 'Authentication: <API_KEY>'",
Type = SecuritySchemeType.ApiKey,
Name = "Authentication", // Header name for the API Key
In = ParameterLocation.Header
});
// Add security requirements to ensure the header is required
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "ApiKey"
}
},
new string[] { } // No specific scopes
}
});
});
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>()
.AddSingleton<StalwartClient>();
var app = builder.Build();
// Ensure the database is migrated at startup.
using (var scope = app.Services.CreateScope())
{
try
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<ApiKeyContext>();
await context.EnsureDatabaseMigrated(logger);
}
catch (NpgsqlException ex)
{
if (ex.SqlState is PostgresErrorCodes.ConnectionFailure or PostgresErrorCodes.ConnectionException ||
ex.Message.StartsWith("Failed to connect"))
logger.LogCritical($"Database connection failed: {ex.Message}");
else if (ex.SqlState is PostgresErrorCodes.InvalidPassword
or PostgresErrorCodes.InvalidAuthorizationSpecification)
logger.LogCritical("Failed to connect. Invalid password.");
else
logger.LogCritical($"An unknown error occurred:{Environment.NewLine}{ex.StackTrace}");
}
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();

View file

@ -0,0 +1,28 @@
using AspNetCore.Authentication.ApiKey;
using Microsoft.EntityFrameworkCore;
using StalwartSimpleLoginMiddleware.Contexts;
using StalwartSimpleLoginMiddleware.Models;
using StalwartSimpleLoginMiddleware.Utilities;
namespace StalwartSimpleLoginMiddleware.Repositories;
public class ApiKeyContextRepository(ApiKeyContext context) : IApiKeyRepository
{
public async Task<IApiKey?> GetApiKeyAsync(string key)
{
var dbKey = await context.ApiKeys.AsNoTracking()
.FirstOrDefaultAsync(api => api.Key == key);
if (dbKey == null) return null;
return ApiKeyHelper.CreateDbApiKey(dbKey);
}
public async Task<KeyMetadata> GetMetadataAsync(string key)
{
var dbKey = await context.ApiKeys.AsNoTracking()
.Include(api => api.Members)
.FirstAsync(api => api.Key == key);
return ApiKeyHelper.CreateKeyMetadata(dbKey);
}
}

View file

@ -0,0 +1,10 @@
using AspNetCore.Authentication.ApiKey;
using StalwartSimpleLoginMiddleware.Models;
namespace StalwartSimpleLoginMiddleware.Repositories;
public interface IApiKeyRepository
{
Task<IApiKey?> GetApiKeyAsync(string key);
Task<KeyMetadata> GetMetadataAsync(string key);
}

View file

@ -0,0 +1,40 @@
using AspNetCore.Authentication.ApiKey;
using StalwartSimpleLoginMiddleware.Models;
using StalwartSimpleLoginMiddleware.Repositories;
namespace StalwartSimpleLoginMiddleware.Services;
public class ApiKeyProvider : ApiKeyEvents
{
public ApiKeyProvider()
{
OnValidateKey = OnValidateKeyAsync;
}
private static async Task OnValidateKeyAsync(ApiKeyValidateKeyContext context)
{
var apiKeyRepository = context.HttpContext.RequestServices.GetRequiredService<IApiKeyRepository>();
var apiKey = await apiKeyRepository.GetApiKeyAsync(context.ApiKey);
if (apiKey == null || !apiKey.Key.Equals(context.ApiKey, StringComparison.OrdinalIgnoreCase))
{
context.ValidationFailed();
return;
}
context.ValidationSucceeded(apiKey.OwnerName, apiKey.Claims);
var apiKeyAccessor = context.HttpContext.RequestServices.GetRequiredService<IApiKeyAccessor>();
apiKeyAccessor.ApiKey = apiKey;
apiKeyAccessor.Metadata = await apiKeyRepository.GetMetadataAsync(context.ApiKey);
}
public override async Task HandleChallengeAsync(ApiKeyHandleChallengeContext context)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("{\"Unauthorized\": 401}");
context.Handled();
}
}

View file

@ -0,0 +1,41 @@
using AdOrbitSDK;
using StalwartSimpleLoginMiddleware.Constants;
namespace StalwartSimpleLoginMiddleware.Services;
public class StalwartClient() : IDisposable
{
private Client _userClient;
private static HttpClient HttpClient => new()
{
BaseAddress = new Uri(Environment.GetEnvironmentVariable(EnvironmentVariable.MailServerUri) + "/api/")
};
public async Task<Client> GetClient()
{
if (_userClient != null) return _userClient;
return GetClient(Environment.GetEnvironmentVariable(EnvironmentVariable.MailServerApiKey) ?? string.Empty);
}
private Client GetClient(string apiKey)
{
if (_userClient != null) return _userClient;
if (string.IsNullOrEmpty(HttpClient.BaseAddress?.AbsoluteUri))
throw new NullReferenceException($"{EnvironmentVariable.MailServerUri} is missing in environment.");
if (string.IsNullOrEmpty(apiKey))
throw new NullReferenceException($"{EnvironmentVariable.MailServerApiKey} is missing in environment.");
_userClient = new Client(HttpClient, apiKey);
return _userClient;
}
public void Dispose()
{
HttpClient.Dispose();
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="8.0.11"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.11"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StalwartSDK\StalwartSDK.csproj"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,66 @@
using System.Net.Mail;
using System.Security.Cryptography;
using StalwartSimpleLoginMiddleware.Entities;
using StalwartSimpleLoginMiddleware.Models;
namespace StalwartSimpleLoginMiddleware.Utilities;
public class ApiKeyHelper
{
public static string GenerateKey()
{
var key = new byte[64];
using (var generator = RandomNumberGenerator.Create())
generator.GetBytes(key);
return Convert.ToBase64String(key);
}
public static DbApiKey CreateDbApiKey(ApiKey dbKey)
{
return new DbApiKey
{
Key = dbKey.Key,
OwnerName = dbKey.OwnerEmail,
Claims = ClaimsHelper.BuildClaims(dbKey)
};
}
public static KeyMetadata CreateKeyMetadata(ApiKey dbKey)
{
return new KeyMetadata
{
Domain = GetEmailDomain(dbKey.OwnerEmail),
Members = GetMembers(dbKey, false),
ExternalMembers = GetMembers(dbKey, true)
};
}
public static ICollection<string> GetMembers(ApiKey dbKey, bool isExternal)
{
var members = dbKey.Members.Where(m => m.IsExternal == isExternal)
.Select(m => m.Email);
if (!isExternal)
{
return members.Append(dbKey.OwnerEmail)
.ToArray();
}
return members.ToArray();
}
public static string GetEmailDomain(string email)
{
if (string.IsNullOrEmpty(email))
{
throw new ArgumentException("Email cannot be null or empty.", nameof(email));
}
if (MailAddress.TryCreate(email, out var address))
{
return address.Host;
}
throw new ArgumentException($"Invalid email format: {email}", nameof(email));
}
}

View file

@ -0,0 +1,19 @@
using System.Security.Claims;
using StalwartSimpleLoginMiddleware.Entities;
namespace StalwartSimpleLoginMiddleware.Utilities;
public static class ClaimsHelper
{
public static List<Claim> BuildClaims(ApiKey apiKey)
{
var claims = new List<Claim>();
if (apiKey.IsAdmin)
{
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
}
return claims;
}
}

View file

@ -0,0 +1,22 @@
using Npgsql;
namespace StalwartSimpleLoginMiddleware.Utilities;
public static class ConnectionHelper
{
public static string GetPostgresConnectionString(string url)
{
var uri = new Uri(url);
var userInfo = uri.UserInfo.Split(':');
var builder = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port,
Database = uri.AbsolutePath.Trim('/'),
Username = userInfo[0],
Password = userInfo[1],
SslMode = SslMode.Prefer
};
return builder.ToString();
}
}

View file

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

View file

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