Initial commit

This commit is contained in:
Mike 2024-12-11 20:36:30 +00:00
commit 858c1bebd2
95 changed files with 174562 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea
*.DotSettings.user

248
Alveus.ruleset Normal file
View file

@ -0,0 +1,248 @@
<?xml version="1.0"?>
<RuleSet Name="Alveus StyleCop Rules" Description="Rules with IsEnabledByDefault = false are disabled."
ToolsVersion="14.0">
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.SpecialRules">
<Rule Id="SA0001" Action="Warning"/> <!-- XML comment analysis disabled -->
<Rule Id="SA0002" Action="Warning"/> <!-- Invalid settings file -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.SpacingRules">
<Rule Id="SA1000" Action="Warning"/> <!-- Keywords should be spaced correctly -->
<Rule Id="SA1001" Action="Warning"/> <!-- Commas should be spaced correctly -->
<Rule Id="SA1002" Action="Warning"/> <!-- Semicolons should be spaced correctly -->
<Rule Id="SA1003" Action="Warning"/> <!-- Symbols should be spaced correctly -->
<Rule Id="SA1004" Action="Warning"/> <!-- Documentation lines should begin with single space -->
<Rule Id="SA1005" Action="Warning"/> <!-- Single line comments should begin with single space -->
<Rule Id="SA1006" Action="Warning"/> <!-- Preprocessor keywords should not be preceded by space -->
<Rule Id="SA1007" Action="Warning"/> <!-- Operator keyword should be followed by space -->
<Rule Id="SA1008" Action="Warning"/> <!-- Opening parenthesis should be spaced correctly -->
<Rule Id="SA1009" Action="Warning"/> <!-- Closing parenthesis should be spaced correctly -->
<Rule Id="SA1010" Action="Warning"/> <!-- Opening square brackets should be spaced correctly -->
<Rule Id="SA1011" Action="Warning"/> <!-- Closing square brackets should be spaced correctly -->
<Rule Id="SA1012" Action="Warning"/> <!-- Opening braces should be spaced correctly -->
<Rule Id="SA1013" Action="Warning"/> <!-- Closing braces should be spaced correctly -->
<Rule Id="SA1014" Action="Warning"/> <!-- Opening generic brackets should be spaced correctly -->
<Rule Id="SA1015" Action="Warning"/> <!-- Closing generic brackets should be spaced correctly -->
<Rule Id="SA1016" Action="Warning"/> <!-- Opening attribute brackets should be spaced correctly -->
<Rule Id="SA1017" Action="Warning"/> <!-- Closing attribute brackets should be spaced correctly -->
<Rule Id="SA1018" Action="Warning"/> <!-- Nullable type symbols should be spaced correctly -->
<Rule Id="SA1019" Action="Warning"/> <!-- Member access symbols should be spaced correctly -->
<Rule Id="SA1020" Action="Warning"/> <!-- Increment decrement symbols should be spaced correctly -->
<Rule Id="SA1021" Action="Warning"/> <!-- Negative signs should be spaced correctly -->
<Rule Id="SA1022" Action="Warning"/> <!-- Positive signs should be spaced correctly -->
<Rule Id="SA1023"
Action="Warning"/> <!-- Dereference and access of symbols should be spaced correctly -->
<Rule Id="SA1024" Action="Warning"/> <!-- Colons should be spaced correctly -->
<Rule Id="SA1025" Action="Warning"/> <!-- Code should not contain multiple whitespace in a row -->
<Rule Id="SA1026"
Action="Warning"/> <!-- Code should not contain space after new or stackalloc keyword in implicitly typed array allocation -->
<Rule Id="SA1027" Action="Warning"/> <!-- Use tabs correctly -->
<Rule Id="SA1028" Action="Warning"/> <!-- Code should not contain trailing whitespace -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.ReadabilityRules">
<Rule Id="SA1100"
Action="Warning"/> <!-- Do not prefix calls with base unless local implementation exists -->
<Rule Id="SA1101" Action="None"/> <!-- Prefix local calls with this -->
<Rule Id="SA1102" Action="Warning"/> <!-- Query clause should follow previous clause -->
<Rule Id="SA1103"
Action="Warning"/> <!-- Query clauses should be on separate lines or all on one line -->
<Rule Id="SA1104"
Action="Warning"/> <!-- Query clause should begin on new line when previous clause spans multiple lines -->
<Rule Id="SA1105"
Action="Warning"/> <!-- Query clauses spanning multiple lines should begin on own line -->
<Rule Id="SA1106" Action="Warning"/> <!-- Code should not contain empty statements -->
<Rule Id="SA1107" Action="Warning"/> <!-- Code should not contain multiple statements on one line -->
<Rule Id="SA1108" Action="Warning"/> <!-- Block statements should not contain embedded comments -->
<Rule Id="SA1109" Action="None"/> <!-- Block statements should not contain embedded regions -->
<Rule Id="SA1110"
Action="Warning"/> <!-- Opening parenthesis or bracket should be on declaration line -->
<Rule Id="SA1111" Action="Warning"/> <!-- Closing parenthesis should be on line of last parameter -->
<Rule Id="SA1112"
Action="Warning"/> <!-- Closing parenthesis should be on line of opening parenthesis -->
<Rule Id="SA1113" Action="Warning"/> <!-- Comma should be on the same line as previous parameter -->
<Rule Id="SA1114" Action="Warning"/> <!-- Parameter list should follow declaration -->
<Rule Id="SA1115" Action="Warning"/> <!-- Parameter should follow comma -->
<Rule Id="SA1116" Action="Warning"/> <!-- Split parameters should start on line after declaration -->
<Rule Id="SA1117" Action="Warning"/> <!-- Parameters should be on same line or separate lines -->
<Rule Id="SA1118" Action="Warning"/> <!-- Parameter should not span multiple lines -->
<Rule Id="SA1120" Action="Warning"/> <!-- Comments should contain text -->
<Rule Id="SA1121" Action="Warning"/> <!-- Use built-in type alias -->
<Rule Id="SA1122" Action="Warning"/> <!-- Use string.Empty for empty strings -->
<Rule Id="SA1123" Action="Warning"/> <!-- Do not place regions within elements -->
<Rule Id="SA1124" Action="Warning"/> <!-- Do not use regions -->
<Rule Id="SA1125" Action="Warning"/> <!-- Use shorthand for nullable types -->
<Rule Id="SA1126" Action="None"/> <!-- Prefix calls correctly -->
<Rule Id="SA1127" Action="Warning"/> <!-- Generic type constraints should be on their own line -->
<Rule Id="SA1128" Action="Warning"/> <!-- Put constructor initializers on their own line -->
<Rule Id="SA1129" Action="Warning"/> <!-- Do not use default value type constructor -->
<Rule Id="SA1130" Action="Warning"/> <!-- Use lambda syntax -->
<Rule Id="SA1131" Action="Warning"/> <!-- Use readable conditions -->
<Rule Id="SA1132" Action="Warning"/> <!-- Do not combine fields -->
<Rule Id="SA1133" Action="Warning"/> <!-- Do not combine attributes -->
<Rule Id="SA1134" Action="Warning"/> <!-- Attributes should not share line -->
<Rule Id="SA1135" Action="Warning"/> <!-- Using directives should be qualified -->
<Rule Id="SA1136" Action="Warning"/> <!-- Enum values should be on separate lines -->
<Rule Id="SA1137" Action="Warning"/> <!-- Elements should have the same indentation -->
<Rule Id="SA1139" Action="Warning"/> <!-- Use literal suffix notation instead of casting -->
<Rule Id="SX1101" Action="Warning"/> <!-- Do not prefix local calls with 'this.' -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.OrderingRules">
<Rule Id="SA1200" Action="None"/> <!-- Using directives should be placed correctly -->
<Rule Id="SA1201" Action="Warning"/> <!-- Elements should appear in the correct order -->
<Rule Id="SA1202" Action="Warning"/> <!-- Elements should be ordered by access -->
<Rule Id="SA1203" Action="Warning"/> <!-- Constants should appear before fields -->
<Rule Id="SA1204" Action="Warning"/> <!-- Static elements should appear before instance elements -->
<Rule Id="SA1205" Action="Warning"/> <!-- Partial elements should declare access -->
<Rule Id="SA1206" Action="Warning"/> <!-- Declaration keywords should follow order -->
<Rule Id="SA1207" Action="Warning"/> <!-- Protected should come before internal -->
<Rule Id="SA1208"
Action="Warning"/> <!-- System using directives should be placed before other using directives -->
<Rule Id="SA1209"
Action="Warning"/> <!-- Using alias directives should be placed after other using directives -->
<Rule Id="SA1210"
Action="Warning"/> <!-- Using directives should be ordered alphabetically by namespace -->
<Rule Id="SA1211"
Action="Warning"/> <!-- Using alias directives should be ordered alphabetically by alias name -->
<Rule Id="SA1212" Action="Warning"/> <!-- Property accessors should follow order -->
<Rule Id="SA1213" Action="Warning"/> <!-- Event accessors should follow order -->
<Rule Id="SA1214" Action="Warning"/> <!-- Readonly fields should appear before non-readonly fields -->
<Rule Id="SA1216"
Action="Warning"/> <!-- Using static directives should be placed at the correct location -->
<Rule Id="SA1217" Action="Warning"/> <!-- Using static directives should be ordered alphabetically -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.NamingRules">
<Rule Id="SA1300" Action="Warning"/> <!-- Element should begin with upper-case letter -->
<Rule Id="SA1301" Action="None"/> <!-- Element should begin with lower-case letter -->
<Rule Id="SA1302" Action="Warning"/> <!-- Interface names should begin with I -->
<Rule Id="SA1303" Action="Warning"/> <!-- Const field names should begin with upper-case letter -->
<Rule Id="SA1304"
Action="Warning"/> <!-- Non-private readonly fields should begin with upper-case letter -->
<Rule Id="SA1305" Action="None"/> <!-- Field names should not use Hungarian notation -->
<Rule Id="SA1306" Action="Warning"/> <!-- Field names should begin with lower-case letter -->
<Rule Id="SA1307" Action="Warning"/> <!-- Accessible fields should begin with upper-case letter -->
<Rule Id="SA1308" Action="Warning"/> <!-- Variable names should not be prefixed -->
<Rule Id="SA1309" Action="None"/> <!-- Field names should not begin with underscore -->
<Rule Id="SA1310" Action="Warning"/> <!-- Field names should not contain underscore -->
<Rule Id="SA1311"
Action="Warning"/> <!-- Static readonly fields should begin with upper-case letter -->
<Rule Id="SA1312" Action="Warning"/> <!-- Variable names should begin with lower-case letter -->
<Rule Id="SA1313" Action="Warning"/> <!-- Parameter names should begin with lower-case letter -->
<Rule Id="SA1314" Action="Warning"/> <!-- Type parameter names should begin with T -->
<Rule Id="SX1309" Action="None"/> <!-- Field names should begin with underscore -->
<Rule Id="SX1309S" Action="None"/> <!-- Static field names should begin with underscore -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.MaintainabilityRules">
<Rule Id="SA1119" Action="Warning"/> <!-- Statement should not use unnecessary parenthesis -->
<Rule Id="SA1400" Action="Warning"/> <!-- Access modifier should be declared -->
<Rule Id="SA1401" Action="Warning"/> <!-- Fields should be private -->
<Rule Id="SA1402" Action="Warning"/> <!-- File may only contain a single type -->
<Rule Id="SA1403" Action="Warning"/> <!-- File may only contain a single namespace -->
<Rule Id="SA1404" Action="Warning"/> <!-- Code analysis suppression should have justification -->
<Rule Id="SA1405" Action="Warning"/> <!-- Debug.Assert should provide message text -->
<Rule Id="SA1406" Action="Warning"/> <!-- Debug.Fail should provide message text -->
<Rule Id="SA1407" Action="Warning"/> <!-- Arithmetic expressions should declare precedence -->
<Rule Id="SA1408" Action="Warning"/> <!-- Conditional expressions should declare precedence -->
<Rule Id="SA1409" Action="None"/> <!-- Remove unnecessary code -->
<Rule Id="SA1410" Action="Warning"/> <!-- Remove delegate parenthesis when possible -->
<Rule Id="SA1411"
Action="Warning"/> <!-- Attribute constructor should not use unnecessary parenthesis -->
<Rule Id="SA1412" Action="None"/> <!-- Store files as UTF-8 with byte order mark -->
<Rule Id="SA1413" Action="None"/> <!-- Use trailing comma in multi-line initializers -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.LayoutRules">
<Rule Id="SA1500" Action="Warning"/> <!-- Braces for multi-line statements should not share line -->
<Rule Id="SA1501" Action="Warning"/> <!-- Statement should not be on a single line -->
<Rule Id="SA1502" Action="Warning"/> <!-- Element should not be on a single line -->
<Rule Id="SA1503" Action="Warning"/> <!-- Braces should not be omitted -->
<Rule Id="SA1504" Action="Warning"/> <!-- All accessors should be single-line or multi-line -->
<Rule Id="SA1505" Action="Warning"/> <!-- Opening braces should not be followed by blank line -->
<Rule Id="SA1506"
Action="Warning"/> <!-- Element documentation headers should not be followed by blank line -->
<Rule Id="SA1507" Action="Warning"/> <!-- Code should not contain multiple blank lines in a row -->
<Rule Id="SA1508" Action="Warning"/> <!-- Closing braces should not be preceded by blank line -->
<Rule Id="SA1509" Action="Warning"/> <!-- Opening braces should not be preceded by blank line -->
<Rule Id="SA1510"
Action="Warning"/> <!-- Chained statement blocks should not be preceded by blank line -->
<Rule Id="SA1511" Action="Warning"/> <!-- While-do footer should not be preceded by blank line -->
<Rule Id="SA1512" Action="Warning"/> <!-- Single-line comments should not be followed by blank line -->
<Rule Id="SA1513" Action="Warning"/> <!-- Closing brace should be followed by blank line -->
<Rule Id="SA1514"
Action="Warning"/> <!-- Element documentation header should be preceded by blank line -->
<Rule Id="SA1515" Action="Warning"/> <!-- Single-line comment should be preceded by blank line -->
<Rule Id="SA1516" Action="None"/> <!-- Elements should be separated by blank line -->
<Rule Id="SA1517" Action="Warning"/> <!-- Code should not contain blank lines at start of file -->
<Rule Id="SA1518" Action="Warning"/> <!-- Use line endings correctly at end of file -->
<Rule Id="SA1519"
Action="Warning"/> <!-- Braces should not be omitted from multi-line child statement -->
<Rule Id="SA1520" Action="Warning"/> <!-- Use braces consistently -->
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers.DocumentationRules">
<Rule Id="SA1600" Action="Warning"/> <!-- Elements should be documented -->
<Rule Id="SA1601" Action="Warning"/> <!-- Partial elements should be documented -->
<Rule Id="SA1602" Action="Warning"/> <!-- Enumeration items should be documented -->
<Rule Id="SA1603" Action="None"/> <!-- Documentation should contain valid XML -->
<Rule Id="SA1604" Action="Warning"/> <!-- Element documentation should have summary -->
<Rule Id="SA1605" Action="Warning"/> <!-- Partial element documentation should have summary -->
<Rule Id="SA1606" Action="Warning"/> <!-- Element documentation should have summary text -->
<Rule Id="SA1607" Action="Warning"/> <!-- Partial element documentation should have summary text -->
<Rule Id="SA1608" Action="Warning"/> <!-- Element documentation should not have default summary -->
<Rule Id="SA1609" Action="None"/> <!-- Property documentation should have value -->
<Rule Id="SA1610" Action="Warning"/> <!-- Property documentation should have value text -->
<Rule Id="SA1611" Action="Warning"/> <!-- Element parameters should be documented -->
<Rule Id="SA1612"
Action="Warning"/> <!-- Element parameter documentation should match element parameters -->
<Rule Id="SA1613"
Action="Warning"/> <!-- Element parameter documentation should declare parameter name -->
<Rule Id="SA1614" Action="Warning"/> <!-- Element parameter documentation should have text -->
<Rule Id="SA1615" Action="None"/> <!-- Element return value should be documented -->
<Rule Id="SA1616" Action="Warning"/> <!-- Element return value documentation should have text -->
<Rule Id="SA1617" Action="Warning"/> <!-- Void return value should not be documented -->
<Rule Id="SA1618" Action="Warning"/> <!-- Generic type parameters should be documented -->
<Rule Id="SA1619"
Action="Warning"/> <!-- Generic type parameters should be documented partial class -->
<Rule Id="SA1620"
Action="Warning"/> <!-- Generic type parameter documentation should match type parameters -->
<Rule Id="SA1621"
Action="Warning"/> <!-- Generic type parameter documentation should declare parameter name -->
<Rule Id="SA1622" Action="Warning"/> <!-- Generic type parameter documentation should have text -->
<Rule Id="SA1623" Action="None"/> <!-- Property summary documentation should match accessors -->
<Rule Id="SA1624"
Action="Warning"/> <!-- Property summary documentation should omit accessor with restricted access -->
<Rule Id="SA1625" Action="Warning"/> <!-- Element documentation should not be copied and pasted -->
<Rule Id="SA1626"
Action="Warning"/> <!-- Single-line comments should not use documentation style slashes -->
<Rule Id="SA1627" Action="Warning"/> <!-- Documentation text should not be empty -->
<Rule Id="SA1628" Action="None"/> <!-- Documentation text should begin with a capital letter -->
<Rule Id="SA1629" Action="Warning"/> <!-- Documentation text should end with a period -->
<Rule Id="SA1630" Action="None"/> <!-- Documentation text should contain whitespace -->
<Rule Id="SA1631" Action="None"/> <!-- Documentation should meet character percentage -->
<Rule Id="SA1632" Action="None"/> <!-- Documentation text should meet minimum character length -->
<Rule Id="SA1633" Action="Warning"/> <!-- File should have header -->
<Rule Id="SA1634" Action="Warning"/> <!-- File header should show copyright -->
<Rule Id="SA1635" Action="Warning"/> <!-- File header should have copyright text -->
<Rule Id="SA1636" Action="None"/> <!-- File header copyright text should match -->
<Rule Id="SA1637" Action="Warning"/> <!-- File header should contain file name -->
<Rule Id="SA1638"
Action="Warning"/> <!-- File header file name documentation should match file name -->
<Rule Id="SA1639" Action="None"/> <!-- File header should have summary -->
<Rule Id="SA1640" Action="Warning"/> <!-- File header should have valid company text -->
<Rule Id="SA1641" Action="Warning"/> <!-- File header company name text should match -->
<Rule Id="SA1642"
Action="Warning"/> <!-- Constructor summary documentation should begin with standard text -->
<Rule Id="SA1643"
Action="Warning"/> <!-- Destructor summary documentation should begin with standard text -->
<Rule Id="SA1644" Action="None"/> <!-- Documentation headers should not contain blank lines -->
<Rule Id="SA1645" Action="None"/> <!-- Included documentation file does not exist -->
<Rule Id="SA1646" Action="None"/> <!-- Included documentation XPath does not exist -->
<Rule Id="SA1647" Action="None"/> <!-- Include node does not contain valid file and path -->
<Rule Id="SA1648" Action="Warning"/> <!-- Inheritdoc should be used with inheriting class -->
<Rule Id="SA1649" Action="Warning"/> <!-- File name should match first type name -->
<Rule Id="SA1650" Action="None"/> <!-- Element documentation should be spelled correctly -->
<Rule Id="SA1651" Action="Warning"/> <!-- Do not use placeholder elements -->
</Rules>
</RuleSet>

View file

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CodeAnalysisRuleSet>../Alveus.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0"/>
<PackageReference Include="MyCSharp.HttpUserAgentParser.AspNetCore" Version="3.0.9"/>
<PackageReference Include="MyCSharp.HttpUserAgentParser.MemoryCache" Version="3.0.9"/>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0-dev-02301"/>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="7.1.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="7.1.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Astral.Services\Astral.Services.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,31 @@
// <copyright file="ApiErrorCodes.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.ApiServer.Core;
/// <summary>
/// Api specific error codes.
/// </summary>
public static class ApiErrorCodes
{
/// <summary>
/// Validation failed.
/// </summary>
public const string UnknownError = "unknown-error";
/// <summary>
/// Illegal method call.
/// </summary>
public const string UnknownMethod = "unknown-method";
/// <summary>
/// Illegal method call.
/// </summary>
public const string IllegalMethod = "illegal-method";
/// <summary>
/// Validation failed.
/// </summary>
public const string UnsupportedBody = "unsupported-body";
}

View file

@ -0,0 +1,36 @@
// <copyright file="JwtOptionsExtensions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Text;
using Astral.Core.Options;
using Microsoft.IdentityModel.Tokens;
namespace Astral.ApiServer.Extensions;
/// <summary>
/// Extensions for <see cref="JwtOptions" />.
/// </summary>
public static class JwtOptionsExtensions
{
/// <summary>
/// Generate an instance of <see cref="TokenValidationParameters" /> from <see cref="JwtOptions" />.
/// </summary>
/// <param name="config">Instance of <see cref="JwtOptions" />.</param>
/// <returns>Instance of <see cref="TokenValidationParameters" />.</returns>
public static TokenValidationParameters ToTokenValidationParameters(this JwtOptions config)
{
return new TokenValidationParameters
{
ValidateIssuer = config.ValidateIssuer,
ValidateAudience = config.ValidateAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = config.Audience,
ValidAudiences =
[config.Audience],
ValidIssuer = config.Issuer,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SecretKey))
};
}
}

View file

@ -0,0 +1,62 @@
// <copyright file="StartupService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Infrastructure;
using Astral.Services.Interfaces;
namespace Astral.ApiServer.HostedService;
/// <summary>
/// Processes to be executed at startup.
/// </summary>
public class StartupService : IHostedService
{
private readonly ILogger<StartupService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StartupService" /> class.
/// </summary>
/// <param name="serviceScopeFactory">Instance of <see cref="IServiceScopeFactory" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public StartupService(
IServiceScopeFactory serviceScopeFactory,
ILogger<StartupService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Running startup processes");
using var scope = _serviceScopeFactory.CreateScope();
// Database migrations.
var dbMigrator = scope.ServiceProvider.GetRequiredService<IDatabaseMigrator>();
await dbMigrator.MigrateDatabaseAsync(AppDomain.CurrentDomain.BaseDirectory + "Migrations");
// DAL setup.
var dalSetup = scope.ServiceProvider.GetService<IDataAccessLayerSetup>();
if (dalSetup is not null)
{
await dalSetup.Setup();
}
// Email address domain blacklist update.
var emDomainBlacklist = scope.ServiceProvider.GetRequiredService<IEmailDomainBlacklistService>();
await emDomainBlacklist.UpdateBlacklist();
// Initial user setup.
var initialUserSetup = scope.ServiceProvider.GetRequiredService<IInitialUserService>();
await initialUserSetup.CreateFirstUserIfRequiredAsync();
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,110 @@
// <copyright file="ExceptionMiddleware.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.ApiServer.Core;
using Astral.ApiServer.Models.Common;
using Astral.Core.Constants;
using Astral.Core.Exceptions;
using FluentValidation;
namespace Astral.ApiServer.Middleware;
/// <summary>
/// Handle exceptions caught in a request.
/// </summary>
public class ExceptionMiddleware
{
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionMiddleware" /> class.
/// </summary>
/// <param name="next">Instance of <see cref="RequestDelegate" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Invoke middleware.
/// </summary>
/// <param name="httpContext">Instance of <see cref="HttpContext" />.</param>
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (ServiceException exception)
{
await HandleServiceExceptionAsync(httpContext, exception);
}
catch (ValidationException exception)
{
await HandleValidationExceptionAsync(httpContext, exception);
}
catch (Exception exception)
{
_logger.LogError(
"Unhandled Exception: {path} using {method}: {exception}",
httpContext.Request.Path,
httpContext.Request.Method,
exception);
await HandleOtherExceptionAsync(httpContext);
}
}
/// <summary>
/// Api exception.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext" />.</param>
/// <param name="exception">Instance of <see cref="ValidationException" />.</param>
private static async Task HandleServiceExceptionAsync(HttpContext context, ServiceException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)exception.HttpStatusCode;
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = exception.ErrorCode,
Message = exception.ErrorMessage
});
}
/// <summary>
/// Validation failed.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext" />.</param>
/// <param name="exception">Instance of <see cref="ValidationException" />.</param>
private static async Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = CoreErrorCodes.ValidationError,
Message = exception.Message
});
}
/// <summary>
/// Unexpected exception.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext" />.</param>
private static async Task HandleOtherExceptionAsync(HttpContext context)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = ApiErrorCodes.UnknownError,
Message = "Something went wrong. Try again later"
});
}
}

View file

@ -0,0 +1,93 @@
// <copyright file="StatusCodeMiddleware.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.ApiServer.Core;
using Astral.ApiServer.Models.Common;
using Astral.Core.Constants;
namespace Astral.ApiServer.Middleware;
/// <summary>
/// Handle status code responses.
/// </summary>
public class StatusCodeMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="StatusCodeMiddleware" /> class.
/// </summary>
/// <param name="next">Instance of <see cref="RequestDelegate" />.</param>
public StatusCodeMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Invoke middleware.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext" />.</param>
public Task Invoke(HttpContext context)
{
return InvokeAsync(context);
}
private async Task InvokeAsync(HttpContext context)
{
await _next(context);
switch (context.Response.StatusCode)
{
case 401:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = CoreErrorCodes.Unauthorized,
Message = "You're not authorized to do that"
});
break;
case 404:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = ApiErrorCodes.UnknownMethod,
Message = "Unknown method"
});
break;
case 405:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = ApiErrorCodes.IllegalMethod,
Message = "Illegal method"
});
break;
case 415:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = ApiErrorCodes.UnsupportedBody,
Message = "Unsupported body/media type"
});
break;
case 500:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = ApiErrorCodes.UnknownError,
Message = "Unknown error"
});
break;
}
}
}

View file

@ -0,0 +1,31 @@
// <copyright file="ErrorResultModel.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Text.Json.Serialization;
namespace Astral.ApiServer.Models.Common;
/// <summary>
/// Error result model.
/// </summary>
public class ErrorResultModel
{
/// <summary>
/// Status: failure.
/// </summary>
[JsonPropertyName("status")]
public const string Status = "failure";
/// <summary>
/// Error code.
/// </summary>
[JsonPropertyName("error")]
public string Error { get; set; }
/// <summary>
/// Error message.
/// </summary>
[JsonPropertyName("message")]
public string Message { get; set; }
}

View file

@ -0,0 +1,19 @@
// <copyright file="ResultModel.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Text.Json.Serialization;
namespace Astral.ApiServer.Models.Common;
/// <summary>
/// Generic result model.
/// </summary>
public class ResultModel
{
/// <summary>
/// Either "success" or "failure".
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; }
}

108
Astral.ApiServer/Program.cs Normal file
View file

@ -0,0 +1,108 @@
// <copyright file="Program.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.ApiServer.Extensions;
using Astral.ApiServer.HostedService;
using Astral.ApiServer.Middleware;
using Astral.Core.Options;
using Astral.Services.Options;
using Astral.Services.Profiles;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.OpenApi.Models;
using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection;
using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", true, true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true)
.AddEnvironmentVariables();
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
builder.Services.AddSerilog();
builder.Services.AddHttpUserAgentMemoryCachedParser(opt =>
{
opt.CacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(30);
opt.CacheOptions.SizeLimit = 1024;
}).AddHttpUserAgentParserAccessor();
builder.Services.AddHostedService<StartupService>();
builder.Services.AddAstralCore();
builder.Services.AddAstralServices();
builder.Services.AddAstralDAL();
builder.Services.AddHttpContextAccessor();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(setup =>
{
var jwtSecurityScheme = new OpenApiSecurityScheme
{
BearerFormat = "JWT",
Name = "JWT Authentication",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = JwtBearerDefaults.AuthenticationScheme,
Description = "Paste your access token below",
Reference = new OpenApiReference
{
Id = JwtBearerDefaults.AuthenticationScheme,
Type = ReferenceType.SecurityScheme
}
};
setup.AddSecurityDefinition(jwtSecurityScheme.Reference.Id, jwtSecurityScheme);
setup.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{ jwtSecurityScheme, Array.Empty<string>() }
});
});
// Setup authentication.
var jwtConfig = new JwtOptions();
builder.Configuration.Bind("JWT", jwtConfig);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => options.TokenValidationParameters = jwtConfig.ToTokenValidationParameters());
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
// Setup automapping.
builder.Services.AddAutoMapper(typeof(AutomapperProfile).Assembly);
// Setup configuration.
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection("Database"));
builder.Services.Configure<PwdHashOptions>(builder.Configuration.GetSection("PwdHash"));
builder.Services.Configure<InitialUserOptions>(builder.Configuration.GetSection("InitialUser"));
builder.Services.Configure<RegistrationOptions>(builder.Configuration.GetSection("Registration"));
builder.Services.Configure<EmailDomainBlacklistOptions>(
builder.Configuration.GetSection("EmailDomainBlacklist"));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("JWT"));
var app = builder.Build();
// Error handling middleware.
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<StatusCodeMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthorization();
app.MapControllers();
await app.RunAsync();

View file

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

View file

@ -0,0 +1,43 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Warning"
}
},
"Database": {
"ConnectionString": ""
},
"PwdHash": {
"DegreeOfParallelism": 4,
"NumberOfIterations": 3,
"MemoryToUseKb": 16,
"SaltSize": 64,
"HashSize": 128
},
"InitialUser": {
"Username": "admin",
"Email": "admin@changeme.com",
"Password": "Change!Me1234"
},
"EmailDomainBlacklist": {
"Enabled": true,
"MasterList": "https://raw.githubusercontent.com/disposable/disposable-email-domains/refs/heads/master/domains.txt"
},
"Registration": {
"RequireEmailValidation": false,
"DefaultHeroImageUrl": "heroimage",
"DefaultThumbnailImageUrl": "thumbnail"
},
"JWT": {
"SecretKey": "your-secret-key",
"Issuer": "your-issuer",
"Audience": "your-audience",
"AccessTokenExpiration": "7 days",
"RefreshTokenExpiration": "14 days",
"ValidateIssuer": true,
"ValidateAudience": true
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<CodeAnalysisRuleSet>../Alveus.ruleset</CodeAnalysisRuleSet>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="13.0.1"/>
<PackageReference Include="FluentValidation" Version="11.11.0"/>
<PackageReference Include="Injectio" Version="4.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0"/>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,26 @@
// <copyright file="ColumnMappingAttribute.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Attributes.EntityAnnotation;
/// <summary>
/// Define the column name this property relates to.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class ColumnMappingAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ColumnMappingAttribute" /> class.
/// </summary>
/// <param name="name">Database column name.</param>
public ColumnMappingAttribute(string name)
{
Name = name;
}
/// <summary>
/// Database table column name.
/// </summary>
public string Name { get; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="PrimaryKeyAttribute.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Attributes.EntityAnnotation;
/// <summary>
/// Treat this property as the primary key in the database.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class PrimaryKeyAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryKeyAttribute" /> class.
/// </summary>
/// <param name="autoGenerated">If the primary key auto-generated by the database.</param>
public PrimaryKeyAttribute(bool autoGenerated = false)
{
AutoGenerated = autoGenerated;
}
/// <summary>
/// Should this primary key be auto-generated by the database? (i.e. Auto incrementing).
/// </summary>
public bool AutoGenerated { get; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="TableMappingAttribute.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Attributes.EntityAnnotation;
/// <summary>
/// Define the database table name for this entity.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class TableMappingAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="TableMappingAttribute" /> class.
/// </summary>
/// <param name="name">The database table name.</param>
public TableMappingAttribute(string name)
{
Name = name;
}
/// <summary>
/// Database table name.
/// </summary>
public string Name { get; }
}

View file

@ -0,0 +1,36 @@
// <copyright file="CoreErrorCodes.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Constants;
/// <summary>
/// Error codes to be provided in a response.
/// </summary>
public static class CoreErrorCodes
{
/// <summary>
/// Unauthorised access.
/// </summary>
public const string Unauthorized = "unauthorized";
/// <summary>
/// The request body is missing data.
/// </summary>
public const string NoDataProvided = "missing-data";
/// <summary>
/// Validation failed.
/// </summary>
public const string ValidationError = "validation-fail";
/// <summary>
/// Entity not found.
/// </summary>
public const string NotFoundError = "not-found";
/// <summary>
/// An unexpected error.
/// </summary>
public const string UnexpectedError = "unexpected-error";
}

View file

@ -0,0 +1,30 @@
// <copyright file="UserRole.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Constants;
/// <summary>
/// Available user roles.
/// </summary>
public static class UserRole
{
/// <summary>
/// User role.
/// </summary>
public const string User = "user";
/// <summary>
/// Administrator role.
/// </summary>
public const string Admin = "admin";
/// <summary>
/// Available values.
/// </summary>
public static readonly string[] AvailableRoles =
[
User,
Admin
];
}

View file

@ -0,0 +1,27 @@
// <copyright file="EmailDomainBlacklist.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
namespace Astral.Core.Entities;
/// <summary>
/// Entity representing a blacklisted email address domain.
/// </summary>
[TableMapping("emailDomainBlacklist")]
public class EmailDomainBlacklist
{
/// <summary>
/// The blacklisted domain.
/// </summary>
[PrimaryKey]
[ColumnMapping("domain")]
public string Domain { get; set; }
/// <summary>
/// When the record was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
}

View file

@ -0,0 +1,87 @@
// <copyright file="User.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
namespace Astral.Core.Entities;
/// <summary>
/// User entity.
/// </summary>
[TableMapping("users")]
public class User
{
/// <summary>
/// User id.
/// </summary>
[ColumnMapping("id")]
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>
/// When the user was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the user's entity was last updated.
/// </summary>
[ColumnMapping("updatedAt")]
public DateTime UpdatedAt { get; set; }
/// <summary>
/// The user's name.
/// </summary>
[ColumnMapping("username")]
public string Username { get; set; }
/// <summary>
/// The user's email address.
/// </summary>
[ColumnMapping("email")]
public string EmailAddress { get; set; }
/// <summary>
/// Authentication hash.
/// </summary>
[ColumnMapping("authHash")]
public string PasswordHash { get; set; }
/// <summary>
/// Authentication salt.
/// </summary>
[ColumnMapping("authSalt")]
public string PasswordSalt { get; set; }
/// <summary>
/// The ip address of the creator.
/// </summary>
[ColumnMapping("creatorIp")]
public string CreatorIp { get; set; }
/// <summary>
/// When the user last logged in.
/// </summary>
[ColumnMapping("lastLoggedIn")]
public DateTime LastLoggedIn { get; set; }
/// <summary>
/// The user's role.
/// </summary>
[ColumnMapping("role")]
public string UserRole { get; set; }
/// <summary>
/// Group for this user's connections.
/// </summary>
[ColumnMapping("connectionGroup")]
public Guid ConnectionGroup { get; set; }
/// <summary>
/// Group for this user's friends.
/// </summary>
[ColumnMapping("friendGroup")]
public Guid FriendsGroup { get; set; }
}

View file

@ -0,0 +1,57 @@
// <copyright file="UserGroup.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
namespace Astral.Core.Entities;
/// <summary>
/// User group entity.
/// </summary>
[TableMapping("userGroups")]
public class UserGroup
{
/// <summary>
/// User id.
/// </summary>
[ColumnMapping("id")]
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>
/// When the user was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the user's entity was last updated.
/// </summary>
[ColumnMapping("updatedAt")]
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Whether this group is an internal group.
/// </summary>
[ColumnMapping("internal")]
public bool Internal { get; set; }
/// <summary>
/// Owner user id.
/// </summary>
[ColumnMapping("ownerUserId")]
public Guid OwnerUserId { get; set; }
/// <summary>
/// Group title.
/// </summary>
[ColumnMapping("title")]
public string Title { get; set; }
/// <summary>
/// Group description.
/// </summary>
[ColumnMapping("description")]
public string Description { get; set; }
}

View file

@ -0,0 +1,45 @@
// <copyright file="UserProfile.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
namespace Astral.Core.Entities;
/// <summary>
/// User profile entity.
/// </summary>
[TableMapping("userProfile")]
public class UserProfile
{
/// <summary>
/// User id.
/// </summary>
[ColumnMapping("id")]
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>
/// When the user was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the user's entity was last updated.
/// </summary>
[ColumnMapping("updatedAt")]
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Hero image url.
/// </summary>
[ColumnMapping("heroImageUrl")]
public string HeroImageUrl { get; set; }
/// <summary>
/// Thumbnail image url.
/// </summary>
[ColumnMapping("thumbnailImageUrl")]
public string ThumbnailImageUrl { get; set; }
}

View file

@ -0,0 +1,21 @@
// <copyright file="MigrationException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Exceptions;
/// <summary>
/// Exception to be thrown if errors occur during migrations.
/// </summary>
public class MigrationException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MigrationException" /> class.
/// </summary>
/// <param name="file">The migration file causing the error.</param>
/// <param name="error">The error message.</param>
public MigrationException(string file, string error)
: base($"An exception occured whilst attempting to migrate the database with file {file}: {error}")
{
}
}

View file

@ -0,0 +1,26 @@
// <copyright file="MissingColumnAttribute.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Exceptions;
/// <summary>
/// Thrown if an entity has a property that has a primary key attribute, but no column attribute.
/// </summary>
public class MissingColumnAttributeException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MissingColumnAttributeException" /> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public MissingColumnAttributeException(Type entityType)
: base($"Entity '{entityType}' primary key requires a ColumnMapping attribute")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="MultiplePrimaryKeysException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Exceptions;
/// <summary>
/// Thrown if an entity has multiple primary keys defined.
/// </summary>
public class MultiplePrimaryKeysException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MultiplePrimaryKeysException" /> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public MultiplePrimaryKeysException(Type entityType)
: base($"Entity '{entityType}' contains multiple primary keys")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,28 @@
// <copyright file="PrimaryKeyMissingException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
namespace Astral.Core.Exceptions;
/// <summary>
/// Thrown if an entity is missing a property with <see cref="PrimaryKeyAttribute" />.
/// </summary>
public class PrimaryKeyMissingException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryKeyMissingException" /> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public PrimaryKeyMissingException(Type entityType)
: base($"Entity '{entityType}' is missing a property with a PrimaryKey.")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,42 @@
// <copyright file="ServiceExceptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
namespace Astral.Core.Exceptions;
/// <summary>
/// Service exception to be fed back into the response.
/// </summary>
public class ServiceException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ServiceException" /> class.
/// </summary>
/// <param name="errorCode">Error code to provide.</param>
/// <param name="errorMessage">Error message to provide.</param>
/// <param name="httpStatusCode">The http status code to provide.</param>
protected ServiceException(string errorCode, string errorMessage, HttpStatusCode httpStatusCode)
: base(errorMessage)
{
ErrorCode = errorCode;
ErrorMessage = errorMessage;
HttpStatusCode = httpStatusCode;
}
/// <summary>
/// Standard error code.
/// </summary>
public string ErrorCode { get; }
/// <summary>
/// Error message.
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Http Status Code.
/// </summary>
public HttpStatusCode HttpStatusCode { get; }
}

View file

@ -0,0 +1,25 @@
// <copyright file="UnexpectedErrorException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Constants;
namespace Astral.Core.Exceptions;
/// <summary>
/// Thrown when something bad has happened.
/// </summary>
public class UnexpectedErrorException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UnexpectedErrorException" /> class.
/// </summary>
public UnexpectedErrorException()
: base(
CoreErrorCodes.UnexpectedError,
"Something unexpected occured, please try later",
HttpStatusCode.InternalServerError)
{
}
}

View file

@ -0,0 +1,34 @@
// <copyright file="JwtConfigurationExtensions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Options;
using TimeSpanParserUtil;
namespace Astral.Core.Extensions;
/// <summary>
/// Extensions for <see cref="JwtOptions" />.
/// </summary>
public static class JwtConfigurationExtensions
{
/// <summary>
/// Return when (UTC) the access token expires.
/// </summary>
/// <param name="configuration">Instance of <see cref="JwtOptions" />.</param>
/// <returns>A UTC DateTime of when the expiration occurs.</returns>
public static DateTime AccessTokenExpirationDateTime(this JwtOptions configuration)
{
return DateTime.UtcNow.Add(TimeSpanParser.Parse(configuration.AccessTokenExpiration));
}
/// <summary>
/// Return when (UTC) the refresh token expires.
/// </summary>
/// <param name="configuration">Instance of <see cref="JwtOptions" />.</param>
/// <returns>A UTC DateTime of when the expiration occurs.</returns>
public static DateTime RefreshTokenExpirationDateTime(this JwtOptions configuration)
{
return DateTime.UtcNow.Add(TimeSpanParser.Parse(configuration.RefreshTokenExpiration));
}
}

View file

@ -0,0 +1,27 @@
// <copyright file="ListExtensions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Extensions;
/// <summary>
/// Extensions for lists.
/// </summary>
public static class ListExtensions
{
/// <summary>
/// Chunk a list into smaller chunks.
/// </summary>
/// <param name="source">Source list.</param>
/// <param name="chunkSize">Chunk size.</param>
/// <typeparam name="T">List type.</typeparam>
/// <returns>Collection of chunked lists.</returns>
public static List<List<T>> ChunkBy<T>(this List<T> source, int chunkSize)
{
return source
.Select((x, i) => new { Index = i, Value = x })
.GroupBy(x => x.Index / chunkSize)
.Select(x => x.Select(v => v.Value).ToList())
.ToList();
}
}

View file

@ -0,0 +1,36 @@
// <copyright file="StringExtensions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Extensions;
/// <summary>
/// Extension methods for strings.
/// </summary>
public static class StringExtensions
{
/// <summary>
/// Mask an email address for logging purposes.
/// </summary>
/// <param name="emailAddress">The email address to obfuscate.</param>
/// <returns>The obfuscated email address, or the original if there's an issue with the email address.</returns>
public static string MaskEmailAddress(this string emailAddress)
{
if (string.IsNullOrWhiteSpace(emailAddress))
{
return string.Empty;
}
if (!emailAddress.Contains('@'))
{
return emailAddress;
}
if (emailAddress.StartsWith('@'))
{
return emailAddress;
}
return $"{emailAddress[0]}****{emailAddress[(emailAddress.IndexOf('@') - 1) ..]}";
}
}

View file

@ -0,0 +1,16 @@
// <copyright file="IDataAccessLayerSetup.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Infrastructure;
/// <summary>
/// Perform any setup steps required for the data access layer.
/// </summary>
public interface IDataAccessLayerSetup
{
/// <summary>
/// Setup routine.
/// </summary>
Task Setup();
}

View file

@ -0,0 +1,17 @@
// <copyright file="IDatabaseMigrator.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Infrastructure;
/// <summary>
/// Database migration process.
/// </summary>
public interface IDatabaseMigrator
{
/// <summary>
/// Run database migrations.
/// </summary>
/// <param name="migrationsPath">The directory where migrations are found.</param>
Task MigrateDatabaseAsync(string migrationsPath = "Migrations");
}

View file

@ -0,0 +1,25 @@
// <copyright file="IDbConnectionProvider.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Data;
namespace Astral.Core.Infrastructure;
/// <summary>
/// Scoped database connection provider.
/// </summary>
public interface IDbConnectionProvider : IDisposable
{
/// <summary>
/// Open new database connection for this scope.
/// </summary>
/// <returns>Instance of <see cref="IDbConnection" />.</returns>
IDbConnection OpenConnection();
/// <summary>
/// Open new database connection for this scope.
/// </summary>
/// <returns>Instance of <see cref="IDbConnection" />.</returns>
Task<IDbConnection> OpenConnectionAsync();
}

View file

@ -0,0 +1,27 @@
// <copyright file="ITransactionProvider.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Data;
namespace Astral.Core.Infrastructure;
/// <summary>
/// Database transaction provider.
/// </summary>
public interface ITransactionProvider
{
/// <summary>
/// Begin a new transaction.
/// </summary>
/// <param name="isolationLevel">One of <see cref="IsolationLevel" />.</param>
/// <returns>Instance of <see cref="IDbTransaction" />.</returns>
IDbTransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Unspecified);
/// <summary>
/// Begin a new transaction.
/// </summary>
/// <param name="isolationLevel">One of <see cref="IsolationLevel" />.</param>
/// <returns>Instance of <see cref="IDbTransaction" />.</returns>
Task<IDbTransaction> BeginTransactionAsync(IsolationLevel isolationLevel = IsolationLevel.Unspecified);
}

View file

@ -0,0 +1,16 @@
// <copyright file="DatabaseOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Options;
/// <summary>
/// Database Options.
/// </summary>
public class DatabaseOptions
{
/// <summary>
/// Connection string.
/// </summary>
public string ConnectionString { get; init; }
}

View file

@ -0,0 +1,46 @@
// <copyright file="JwtOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.Options;
/// <summary>
/// Jwt Authentication Configuration.
/// </summary>
public class JwtOptions
{
/// <summary>
/// Secret key.
/// </summary>
public string SecretKey { get; set; }
/// <summary>
/// Issuer.
/// </summary>
public string Issuer { get; set; }
/// <summary>
/// Audience.
/// </summary>
public string Audience { get; set; }
/// <summary>
/// Access token expiration.
/// </summary>
public string AccessTokenExpiration { get; set; }
/// <summary>
/// Refresh token expiration.
/// </summary>
public string RefreshTokenExpiration { get; set; }
/// <summary>
/// Validate issuer.
/// </summary>
public bool ValidateIssuer { get; set; }
/// <summary>
/// Validate audience.
/// </summary>
public bool ValidateAudience { get; set; }
}

View file

@ -0,0 +1,19 @@
// <copyright file="IEmailDomainBlacklistRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
namespace Astral.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="EmailDomainBlacklist" /> Repository.
/// </summary>
public interface IEmailDomainBlacklistRepository : IGenericRepository<EmailDomainBlacklist, string>
{
/// <summary>
/// Count how many domains are in the blacklist.
/// </summary>
/// <returns>A count of entries.</returns>
Task<long> CountEntries();
}

View file

@ -0,0 +1,54 @@
// <copyright file="IGenericRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Core.RepositoryInterfaces;
/// <summary>
/// Generic repository with auto-generated CRUD operations.
/// </summary>
/// <typeparam name="TEntity">The entity this repository handles.</typeparam>
/// <typeparam name="TKeyType">The data type of the primary key.</typeparam>
public interface IGenericRepository<TEntity, TKeyType>
where TEntity : class
{
/// <summary>
/// Find an entity by its id.
/// </summary>
/// <param name="id">The entity's id.</param>
/// <returns>The entity, or null if not found.</returns>
Task<TEntity> FindByIdAsync(TKeyType id);
/// <summary>
/// Retrieve all entities.
/// </summary>
/// <returns>Collection of entities.</returns>
Task<IEnumerable<TEntity>> GetAllAsync();
/// <summary>
/// Add an entity to the table and return it's new id.
/// </summary>
/// <param name="entity">The entity to add.</param>
/// <returns>The id of the new entity.</returns>
Task<TKeyType> AddAsync(TEntity entity);
/// <summary>
/// Add multiple entities to the table.
/// </summary>
/// <param name="entities">The entities to add.</param>
/// <returns>The id of the new entity.</returns>
Task AddAsync(IEnumerable<TEntity> entities);
/// <summary>
/// Update an entity.
/// </summary>
/// <param name="entity">The entity (needs an id) to update.</param>
/// <returns>True if entity updated.</returns>
Task<bool> UpdateAsync(TEntity entity);
/// <summary>
/// Delete an entity by its id.
/// </summary>
/// <param name="id">The entity id.</param>
Task DeleteAsync(TKeyType id);
}

View file

@ -0,0 +1,14 @@
// <copyright file="IUserGroupRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
namespace Astral.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="UserGroup" /> repository.
/// </summary>
public interface IUserGroupRepository : IGenericRepository<UserGroup, Guid>
{
}

View file

@ -0,0 +1,14 @@
// <copyright file="IUserProfileRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
namespace Astral.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="UserProfile" /> repository.
/// </summary>
public interface IUserProfileRepository : IGenericRepository<UserProfile, Guid>
{
}

View file

@ -0,0 +1,33 @@
// <copyright file="IUserRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
namespace Astral.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="User" /> repository.
/// </summary>
public interface IUserRepository : IGenericRepository<User, Guid>
{
/// <summary>
/// Return a count of all users.
/// </summary>
/// <returns>A count of users in the table.</returns>
Task<long> CountUsersAsync();
/// <summary>
/// Find a user by their username.
/// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>The user entity if found, or null.</returns>
Task<User> FindByUsername(string username);
/// <summary>
/// Find a user by their email address.
/// </summary>
/// <param name="emailAddress">The email address to search for.</param>
/// <returns>The user entity if found, or null.</returns>
Task<User> FindByEmailAddress(string emailAddress);
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<CodeAnalysisRuleSet>../Alveus.ruleset</CodeAnalysisRuleSet>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Astral.Core\Astral.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35"/>
<PackageReference Include="Npgsql" Version="9.0.2"/>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="Migrations\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,54 @@
// <copyright file="DataAccessLayerSetup.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Attributes.EntityAnnotation;
using Astral.Core.Infrastructure;
using Dapper;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
namespace Astral.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class DataAccessLayerSetup : IDataAccessLayerSetup
{
private readonly ILogger<DataAccessLayerSetup> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataAccessLayerSetup" /> class.
/// </summary>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public DataAccessLayerSetup(ILogger<DataAccessLayerSetup> logger)
{
_logger = logger;
}
/// <inheritdoc />
public Task Setup()
{
_logger.LogInformation("Setting up data access layer");
DefaultTypeMap.MatchNamesWithUnderscores = true;
SetupMappings();
return Task.CompletedTask;
}
/// <summary>
/// Setup custom mapping on all entities decorated with <see cref="TableMappingAttribute" />.
/// </summary>
private static void SetupMappings()
{
// Find all entities annotated with Table attribute.
var entityTypes = typeof(IDataAccessLayerSetup).Assembly.GetTypes()
.Where(type => type.GetCustomAttributes(typeof(TableMappingAttribute), true).Any());
foreach (var entity in entityTypes)
{
SqlMapper.SetTypeMap(entity, new EntityAttributeTypeMapper(entity));
}
}
}

View file

@ -0,0 +1,95 @@
// <copyright file="DatabaseMigrator.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Exceptions;
using Astral.Core.Infrastructure;
using Dapper;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
namespace Astral.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class DatabaseMigrator : IDatabaseMigrator
{
private readonly IDbConnectionProvider _dbConnectionProvider;
private readonly ILogger<DatabaseMigrator> _logger;
private readonly ITransactionProvider _transactionProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseMigrator" /> class.
/// </summary>
/// <param name="dbConnectionProvider">Instance of <see cref="IDbConnectionProvider" />.</param>
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public DatabaseMigrator(
IDbConnectionProvider dbConnectionProvider,
ITransactionProvider transactionProvider,
ILogger<DatabaseMigrator> logger)
{
_dbConnectionProvider = dbConnectionProvider;
_transactionProvider = transactionProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task MigrateDatabaseAsync(string migrationsPath)
{
var connection = await _dbConnectionProvider.OpenConnectionAsync();
_logger.LogInformation("Beginning database migrations");
using var transaction = await _transactionProvider.BeginTransactionAsync();
await connection.ExecuteAsync("""
CREATE TABLE IF NOT EXISTS migrations (
date_added TIMESTAMP NOT NULL,
filename VARCHAR(128) NOT NULL,
CONSTRAINT migrations_filename_pk PRIMARY KEY (filename)
);
""");
var appliedMigrationsEnumerable = await
connection.QueryAsync<string>("SELECT filename FROM migrations ORDER BY date_added;");
// Prevent multiple enumeration.
var appliedMigrations = appliedMigrationsEnumerable.ToList();
var files = Directory.GetFiles(migrationsPath, "*.sql").ToList();
files.Sort();
foreach (var filename in files)
{
var file = filename.Replace(migrationsPath, string.Empty)
.Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (appliedMigrations.Contains(file))
{
_logger.LogInformation("Already applied: {file}", file);
continue;
}
_logger.LogInformation("Applying {file}...", file);
try
{
var sql = await File.ReadAllTextAsync(filename);
await connection.ExecuteAsync(sql);
await connection.ExecuteAsync(
"INSERT INTO migrations (date_added, filename) SELECT CURRENT_TIMESTAMP, @pFilename ;",
new
{
pFilename = file
});
}
catch (Exception e)
{
throw new MigrationException(file, e.Message);
}
}
transaction.Commit();
_logger.LogInformation("Database migrations complete");
}
}

View file

@ -0,0 +1,99 @@
// <copyright file="DbConnectionProvider.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Data;
using Astral.Core.Infrastructure;
using Astral.Core.Options;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Astral.DAL.Infrastructure;
/// <inheritdoc cref="Astral.Core.Infrastructure.IDbConnectionProvider" />
[RegisterScoped]
public class DbConnectionProvider : IDbConnectionProvider, IAsyncDisposable
{
private readonly IOptions<DatabaseOptions> _databaseConfiguration;
private readonly ILogger<DbConnectionProvider> _logger;
private NpgsqlConnection _dbConnection;
/// <summary>
/// Initializes a new instance of the <see cref="DbConnectionProvider" /> class.
/// </summary>
/// <param name="databaseConfiguration">Instance of <see cref="IOptions{DatabaseOptions}" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public DbConnectionProvider(
IOptions<DatabaseOptions> databaseConfiguration,
ILogger<DbConnectionProvider> logger)
{
_databaseConfiguration = databaseConfiguration;
_logger = logger;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_dbConnection != null)
{
await _dbConnection.DisposeAsync();
}
GC.SuppressFinalize(this);
}
/// <inheritdoc cref="IDbConnectionProvider" />
public IDbConnection OpenConnection()
{
if (_dbConnection is not null)
{
if (_dbConnection.State == ConnectionState.Closed)
{
_dbConnection.Open();
}
return _dbConnection;
}
_logger.LogDebug("Opening database connection");
_dbConnection = new NpgsqlConnection(_databaseConfiguration.Value.ConnectionString);
_dbConnection.Open();
return _dbConnection;
}
/// <inheritdoc />
public async Task<IDbConnection> OpenConnectionAsync()
{
if (_dbConnection is not null)
{
if (_dbConnection.State == ConnectionState.Closed)
{
await _dbConnection.OpenAsync();
}
return _dbConnection;
}
_logger.LogDebug("Opening database connection");
_dbConnection = new NpgsqlConnection(_databaseConfiguration.Value.ConnectionString);
await _dbConnection.OpenAsync();
return _dbConnection;
}
/// <inheritdoc />
public void Dispose()
{
if (_dbConnection is null)
{
return;
}
_logger.LogDebug("Disposing database connection");
_dbConnection.Close();
_dbConnection.Dispose();
_dbConnection = null;
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,35 @@
// <copyright file="EntityAttributeTypeMapper.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Reflection;
using Astral.Core.Attributes.EntityAnnotation;
using Dapper;
namespace Astral.DAL.Infrastructure;
/// <summary>
/// Entity attribute type mapper.
/// </summary>
public class EntityAttributeTypeMapper : FallbackTypeMapper
{
/// <summary>
/// Initializes a new instance of the <see cref="EntityAttributeTypeMapper" /> class.
/// </summary>
/// <param name="entityType">The entity type.</param>
public EntityAttributeTypeMapper(Type entityType)
: base([
new CustomPropertyTypeMap(
entityType,
(type, columnName) =>
type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(
prop =>
prop.GetCustomAttributes(false)
.OfType<ColumnMappingAttribute>()
.Any(attr => attr.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)))),
new DefaultTypeMap(entityType)
])
{
}
}

View file

@ -0,0 +1,114 @@
// <copyright file="FallbackTypeMapper.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Reflection;
using Dapper;
namespace Astral.DAL.Infrastructure;
/// <summary>
/// Fallback type mapper.
/// </summary>
public class FallbackTypeMapper : SqlMapper.ITypeMap
{
private readonly IEnumerable<SqlMapper.ITypeMap> _mappers;
/// <summary>
/// Initializes a new instance of the <see cref="FallbackTypeMapper" /> class.
/// </summary>
/// <param name="mappers">Collection of <see cref="SqlMapper.ITypeMap" />.</param>
protected FallbackTypeMapper(IEnumerable<SqlMapper.ITypeMap> mappers)
{
_mappers = mappers;
}
/// <summary>
/// Find constructor.
/// </summary>
/// <param name="names">Collection of names.</param>
/// <param name="types">Collection of types.</param>
/// <returns>Instance of <see cref="ConstructorInfo" />.</returns>
public ConstructorInfo FindConstructor(string[] names, Type[] types)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.FindConstructor(names, types);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing
}
}
return null;
}
/// <summary>
/// Find explicit constructor.
/// </summary>
/// <returns>Instance of <see cref="ConstructorInfo" />.</returns>
public ConstructorInfo FindExplicitConstructor()
{
return null;
}
/// <summary>
/// Get constructor parameter map.
/// </summary>
/// <param name="constructor">Instance of <see cref="ConstructorInfo" />.</param>
/// <param name="columnName">Column name.</param>
/// <returns>Instance of <see cref="SqlMapper.IMemberMap" />.</returns>
public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.GetConstructorParameter(constructor, columnName);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing.
}
}
return null;
}
/// <summary>
/// Get member.
/// </summary>
/// <param name="columnName">Column name.</param>
/// <returns>Instance of <see cref="SqlMapper.IMemberMap" />.</returns>
public SqlMapper.IMemberMap GetMember(string columnName)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.GetMember(columnName);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing.
}
}
return null;
}
}

View file

@ -0,0 +1,39 @@
// <copyright file="TransactionProvider.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Data;
using Astral.Core.Infrastructure;
using Injectio.Attributes;
namespace Astral.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class TransactionProvider : ITransactionProvider
{
private readonly IDbConnectionProvider _dbConnectionProvider;
/// <summary>
/// Initializes a new instance of the <see cref="TransactionProvider" /> class.
/// </summary>
/// <param name="dbConnectionProvider">Instance of <see cref="IDbConnectionProvider" />.</param>
public TransactionProvider(IDbConnectionProvider dbConnectionProvider)
{
_dbConnectionProvider = dbConnectionProvider;
}
/// <inheritdoc cref="ITransactionProvider" />
public IDbTransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Unspecified)
{
var connection = _dbConnectionProvider.OpenConnection();
return connection.BeginTransaction(isolationLevel);
}
/// <inheritdoc cref="ITransactionProvider" />
public async Task<IDbTransaction> BeginTransactionAsync(IsolationLevel isolationLevel = IsolationLevel.Unspecified)
{
var connection = await _dbConnectionProvider.OpenConnectionAsync();
return connection.BeginTransaction(isolationLevel);
}
}

View file

@ -0,0 +1,9 @@
CREATE OR REPLACE FUNCTION updated_at_timestamp() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.updatedAt = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;

View file

@ -0,0 +1,5 @@
CREATE TABLE emailDomainBlacklist
(
domain TEXT UNIQUE PRIMARY KEY NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
CREATE TABLE users
(
id UUID UNIQUE PRIMARY KEY NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
authHash TEXT,
authSalt TEXT,
lastLoggedIn TIMESTAMP DEFAULT NULL,
creatorIp TEXT DEFAULT '0.0.0.0',
role TEXT DEFAULT 'user' NOT NULL,
state SMALLINT DEFAULT 0 NOT NULL,
connectionGroup UUID DEFAULT NULL,
friendGroup UUID DEFAULT NULL,
language TEXT DEFAULT 'en'
);
CREATE TRIGGER users_updated_at
BEFORE UPDATE
ON users
FOR EACH ROW
EXECUTE PROCEDURE updated_at_timestamp();

View file

@ -0,0 +1,14 @@
CREATE TABLE userProfile
(
id UUID UNIQUE PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
heroImageUrl TEXT DEFAULT '',
thumbnailImageUrl TEXT DEFAULT ''
);
CREATE TRIGGER userProfile_updated_at
BEFORE UPDATE
ON userProfile
FOR EACH ROW
EXECUTE PROCEDURE updated_at_timestamp();

View file

@ -0,0 +1,16 @@
CREATE TABLE userGroups
(
id UUID UNIQUE PRIMARY KEY NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
internal BOOLEAN DEFAULT FALSE NOT NULL,
ownerUserId UUID REFERENCES users (id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '' NOT NULL
);
CREATE TRIGGER userGroups_updated_at
BEFORE UPDATE
ON userGroups
FOR EACH ROW
EXECUTE PROCEDURE updated_at_timestamp();

View file

@ -0,0 +1,252 @@
// <copyright file="BaseRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Data;
using System.Reflection;
using System.Text;
using Astral.Core.Attributes.EntityAnnotation;
using Astral.Core.Exceptions;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Dapper;
namespace Astral.DAL.Repositories;
/// <inheritdoc />
public class BaseRepository<TEntity, TKeyType> : IGenericRepository<TEntity, TKeyType>
where TEntity : class
{
/// <summary>
/// Instance of <see cref="IDbConnectionProvider" />.
/// </summary>
private readonly IDbConnectionProvider _db;
/// <summary>
/// Initializes a new instance of the <see cref="BaseRepository{TEntity, TKeyType}" /> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider" />.</param>
protected BaseRepository(IDbConnectionProvider db)
{
_db = db;
SetupRepository();
}
/// <summary>
/// Table name.
/// </summary>
protected string Table { get; set; }
/// <summary>
/// Primary key column name.
/// </summary>
private string PrimaryKeyColumn { get; set; }
/// <summary>
/// Whether the primary key should be treated as auto-generated by the database.
/// </summary>
private bool AutoPrimaryKey { get; set; }
/// <summary>
/// Generated select all query string.
/// </summary>
private string SelectAllQuery { get; set; }
/// <summary>
/// Generated find by id query string.
/// </summary>
private string FindByIdQuery { get; set; }
/// <summary>
/// Generated insert query string.
/// </summary>
private string InsertQuery { get; set; }
/// <summary>
/// Generated update query string.
/// </summary>
private string UpdateQuery { get; set; }
/// <summary>
/// Generated delete query string.
/// </summary>
private string DeleteQuery { get; set; }
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task<TEntity> FindByIdAsync(TKeyType id)
{
return await WithDatabaseAsync(async connection =>
{
var result = await connection.QueryFirstOrDefaultAsync<TEntity>(FindByIdQuery, new
{
pId = id
});
return result;
});
}
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task<IEnumerable<TEntity>> GetAllAsync()
{
return await WithDatabaseAsync(async connection =>
{
var result = await connection.QueryAsync<TEntity>(SelectAllQuery);
return result;
});
}
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task<TKeyType> AddAsync(TEntity entity)
{
return await WithDatabaseAsync(async connection => await connection.QuerySingleAsync<TKeyType>(
InsertQuery, entity));
}
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task AddAsync(IEnumerable<TEntity> entities)
{
await WithDatabaseAsync(async connection =>
{
await connection.ExecuteAsync(
InsertQuery, entities);
});
}
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task<bool> UpdateAsync(TEntity entity)
{
return await WithDatabaseAsync(async connection => await connection.ExecuteAsync(UpdateQuery, entity) > 0);
}
/// <inheritdoc cref="IGenericRepository{TEntity,TKeyType}" />
public async Task DeleteAsync(TKeyType id)
{
await WithDatabaseAsync(async connection => await connection.ExecuteAsync(DeleteQuery, new
{
pId = id
}));
}
/// <summary>
/// Establish a connection and perform the query function.
/// </summary>
/// <param name="query">Query method to execute.</param>
/// <typeparam name="T">The return type.</typeparam>
/// <returns>Result of query.</returns>
/// <exception cref="Exception">Thrown if an exception occurs.</exception>
protected async Task<T> WithDatabaseAsync<T>(Func<IDbConnection, Task<T>> query)
{
var connection = await _db.OpenConnectionAsync();
return await query(connection);
}
/// <summary>
/// Establish a connection and perform the query function.
/// </summary>
/// <param name="query">Query method to execute.</param>
protected async Task WithDatabaseAsync(Func<IDbConnection, Task> query)
{
var connection = await _db.OpenConnectionAsync();
await query(connection);
}
/// <summary>
/// Fetch a comma separated list of property names.
/// </summary>
/// <param name="excludeKey">True to exclude key property.</param>
/// <returns>Comma separated list of column names.</returns>
private static string GetPropertyNames(bool excludeKey = false)
{
var properties = typeof(TEntity).GetProperties()
.Where(p => !excludeKey || p.GetCustomAttribute<PrimaryKeyAttribute>() == null)
.Where(p => p.IsDefined(typeof(PrimaryKeyAttribute), true) ||
p.IsDefined(typeof(ColumnMappingAttribute), true));
var values = string.Join(", ", properties.Select(p => $"@{p.Name}"));
return values;
}
/// <summary>
/// Setup repository.
/// </summary>
private void SetupRepository()
{
var entityType = typeof(TEntity);
var properties = entityType.GetProperties();
var propertiesExcludingKey = properties
.Where(p => p.GetCustomAttribute<PrimaryKeyAttribute>() == null);
// Set up table name.
var tableAttribute = entityType.GetCustomAttribute<TableMappingAttribute>();
if (tableAttribute is null)
{
Table = typeof(TEntity).Name + "s";
}
else
{
Table = tableAttribute.Name;
}
// Set up primary key.
var primaryKeyProperties = properties.Where(p => p.IsDefined(typeof(PrimaryKeyAttribute), true)).ToList();
if (primaryKeyProperties is null || primaryKeyProperties.Count == 0)
{
throw new PrimaryKeyMissingException(typeof(TEntity));
}
if (primaryKeyProperties.Count > 1)
{
throw new MultiplePrimaryKeysException(typeof(TEntity));
}
var primaryKeyProperty = primaryKeyProperties.First();
var primaryKey = primaryKeyProperty.GetCustomAttribute<PrimaryKeyAttribute>();
var primaryKeyCol = primaryKeyProperty.GetCustomAttribute<ColumnMappingAttribute>();
if (primaryKeyCol is null)
{
throw new MissingColumnAttributeException(typeof(TEntity));
}
PrimaryKeyColumn = primaryKeyCol!.Name;
AutoPrimaryKey = primaryKey!.AutoGenerated;
SelectAllQuery = $"SELECT * FROM {Table};";
FindByIdQuery = $"SELECT * FROM {Table} WHERE {PrimaryKeyColumn} = @pId;";
// Add query.
var columns = string.Join(", ", properties
.Where(p => !AutoPrimaryKey || !p.IsDefined(typeof(PrimaryKeyAttribute)))
.Where(p => p.IsDefined(typeof(ColumnMappingAttribute)))
.Select(p => p.GetCustomAttribute<ColumnMappingAttribute>() !.Name));
var propertyNames = GetPropertyNames(AutoPrimaryKey);
var insertQuery = new StringBuilder();
insertQuery.Append(
$"INSERT INTO {Table} ({columns}) VALUES ({propertyNames}) RETURNING {PrimaryKeyColumn};");
InsertQuery = insertQuery.ToString();
// Update query.
var updateQuery = new StringBuilder();
updateQuery.Append($"UPDATE {Table} SET");
foreach (var property in propertiesExcludingKey.Where(p => p.IsDefined(typeof(ColumnMappingAttribute))))
{
var columnAttr = property.GetCustomAttribute<ColumnMappingAttribute>();
var propertyName = property.Name;
updateQuery.Append($" {columnAttr!.Name} = @{propertyName},");
}
updateQuery.Remove(updateQuery.Length - 1, 1);
updateQuery.Append($" WHERE {PrimaryKeyColumn} = @{primaryKeyProperty.Name};");
UpdateQuery = updateQuery.ToString();
// Delete query.
DeleteQuery = $"DELETE FROM {Table} WHERE {PrimaryKeyColumn} = @pId;";
}
}

View file

@ -0,0 +1,33 @@
// <copyright file="EmailDomainBlacklistRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Dapper;
using Injectio.Attributes;
namespace Astral.DAL.Repositories;
/// <inheritdoc cref="IEmailDomainBlacklistRepository" />
[RegisterScoped]
public class EmailDomainBlacklistRepository : BaseRepository<EmailDomainBlacklist, string>,
IEmailDomainBlacklistRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="EmailDomainBlacklistRepository" /> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider" />.</param>
public EmailDomainBlacklistRepository(IDbConnectionProvider db)
: base(db)
{
}
/// <inheritdoc cref="IEmailDomainBlacklistRepository" />
public async Task<long> CountEntries()
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstAsync<long>($"SELECT COUNT(*) FROM {Table};"));
}
}

View file

@ -0,0 +1,24 @@
// <copyright file="UserGroupRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Injectio.Attributes;
namespace Astral.DAL.Repositories;
/// <inheritdoc cref="IUserGroupRepository" />
[RegisterScoped]
public class UserGroupRepository : BaseRepository<UserGroup, Guid>, IUserGroupRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupRepository" /> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider" />.</param>
public UserGroupRepository(IDbConnectionProvider db)
: base(db)
{
}
}

View file

@ -0,0 +1,24 @@
// <copyright file="UserProfileRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Injectio.Attributes;
namespace Astral.DAL.Repositories;
/// <inheritdoc cref="IUserProfileRepository" />
[RegisterScoped]
public class UserProfileRepository : BaseRepository<UserProfile, Guid>, IUserProfileRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="UserProfileRepository" /> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider" />.</param>
public UserProfileRepository(IDbConnectionProvider db)
: base(db)
{
}
}

View file

@ -0,0 +1,52 @@
// <copyright file="UserRepository.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Dapper;
using Injectio.Attributes;
namespace Astral.DAL.Repositories;
/// <inheritdoc cref="IUserRepository" />
[RegisterScoped]
public class UserRepository : BaseRepository<User, Guid>, IUserRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="UserRepository" /> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider" />.</param>
public UserRepository(IDbConnectionProvider db)
: base(db)
{
}
/// <inheritdoc cref="IUserRepository" />
public async Task<long> CountUsersAsync()
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstAsync<long>($"SELECT COUNT(*) FROM {Table};"));
}
/// <inheritdoc cref="IUserRepository" />
public async Task<User> FindByUsername(string username)
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstOrDefaultAsync<User>($"SELECT * FROM {Table} WHERE username = @pUsername", new
{
pUsername = username
}));
}
/// <inheritdoc cref="IUserRepository" />
public async Task<User> FindByEmailAddress(string emailAddress)
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstOrDefaultAsync<User>($"SELECT * FROM {Table} WHERE email = @pEmailAddress", new
{
pEmailAddress = emailAddress
}));
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<CodeAnalysisRuleSet>../Alveus.ruleset</CodeAnalysisRuleSet>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Astral.Core\Astral.Core.csproj"/>
<ProjectReference Include="..\Astral.DAL\Astral.DAL.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11"/>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,41 @@
// <copyright file="ServiceErrorCodes.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Constants;
/// <summary>
/// Collection of standard error codes.
/// </summary>
public static class ServiceErrorCodes
{
/// <summary>
/// Username is already taken.
/// </summary>
public const string UsernameTaken = "username-taken";
/// <summary>
/// Email address already registered to a user.
/// </summary>
public const string EmailAlreadyRegistered = "email-already-registered";
/// <summary>
/// Invalid login credentials were provided.
/// </summary>
public const string InvalidCredentials = "invalid-credentials";
/// <summary>
/// This user is suspended from logging in.
/// </summary>
public const string SuspendedUser = "user-suspended";
/// <summary>
/// This user is banned from logging in.
/// </summary>
public const string BannedUser = "user-banned";
/// <summary>
/// This user's account is not yet activated.
/// </summary>
public const string PendingActivation = "pending-activation";
}

View file

@ -0,0 +1,21 @@
// <copyright file="AuthenticateUserDto.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Dtos;
/// <summary>
/// Authentication (login) request dto.
/// </summary>
public class AuthenticateUserDto
{
/// <summary>
/// Agent's username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Agent's password.
/// </summary>
public string Password { get; set; }
}

View file

@ -0,0 +1,38 @@
// <copyright file="CreateUserDto.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
namespace Astral.Services.Dtos;
/// <summary>
/// Perform the creation of a new user.
/// </summary>
public class CreateUserDto
{
/// <summary>
/// New user's username. Must be unique.
/// </summary>
public string Username { get; set; }
/// <summary>
/// New user's email address. Must be unique.
/// </summary>
public string Email { get; set; }
/// <summary>
/// New user's password. Must comply with password policy.
/// </summary>
public string Password { get; set; }
/// <summary>
/// New user's IP address upon registration.
/// </summary>
public IPAddress IpAddress { get; set; }
/// <summary>
/// If activation should be performed immediately.
/// </summary>
public bool ActivateImmediately { get; set; }
}

View file

@ -0,0 +1,61 @@
// <copyright file="UserDto.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Dtos;
/// <summary>
/// Data transfer object for user.
/// </summary>
public class UserDto
{
/// <summary>
/// User id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// When the user was registered.
/// </summary>
public DateTime RegistrationDate { get; set; }
/// <summary>
/// When the user was registered in ticks.
/// </summary>
public long RegistrationDateTicks { get; set; }
/// <summary>
/// The user's name.
/// </summary>
public string Username { get; set; }
/// <summary>
/// The user's email address.
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// The ip address of the creator.
/// </summary>
public string CreatorIp { get; set; }
/// <summary>
/// When the user last logged in.
/// </summary>
public DateTime LastLoggedIn { get; set; }
/// <summary>
/// The user's role.
/// </summary>
public string UserRole { get; set; }
/// <summary>
/// Group for this user's connections.
/// </summary>
public Guid ConnectionGroup { get; set; }
/// <summary>
/// Group for this user's friends.
/// </summary>
public Guid FriendsGroup { get; set; }
}

View file

@ -0,0 +1,41 @@
// <copyright file="UserGroupDto.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Dtos;
/// <summary>
/// Data transfer object for user group.
/// </summary>
public class UserGroupDto
{
/// <summary>
/// User group id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// When the user group was created.
/// </summary>
public DateTime CreationDate { get; set; }
/// <summary>
/// When the user group was created in ticks.
/// </summary>
public long CreationDateTicks { get; set; }
/// <summary>
/// The title of the user group.
/// </summary>
public string Title { get; set; }
/// <summary>
/// The description of the user group.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Whether this is an internal-use user group.
/// </summary>
public bool Internal { get; set; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="EmailAlreadyRegisteredException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Exceptions;
using Astral.Services.Constants;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when an attempt to create a user with an email address already registered to an account.
/// </summary>
public class EmailAlreadyRegisteredException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="EmailAlreadyRegisteredException" /> class.
/// </summary>
public EmailAlreadyRegisteredException()
: base(
ServiceErrorCodes.EmailAlreadyRegistered,
"This email address is already registered to an account",
HttpStatusCode.Conflict)
{
}
}

View file

@ -0,0 +1,26 @@
// <copyright file="InvalidCredentialsException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Exceptions;
using Astral.Services.Constants;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown if username/password is incorrect.
/// </summary>
public class InvalidCredentialsException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="InvalidCredentialsException" /> class.
/// </summary>
public InvalidCredentialsException()
: base(
ServiceErrorCodes.InvalidCredentials,
"The credentials provided were incorrect",
HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,23 @@
// <copyright file="UnauthorizedException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Constants;
using Astral.Core.Exceptions;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when an attempt to perform an unauthorised action occurs.
/// </summary>
public class UnauthorizedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UnauthorizedException" /> class.
/// </summary>
public UnauthorizedException()
: base(CoreErrorCodes.Unauthorized, "You are not authorized to do that", HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,23 @@
// <copyright file="UserBannedException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Exceptions;
using Astral.Services.Constants;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserBannedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserBannedException" /> class.
/// </summary>
public UserBannedException()
: base(ServiceErrorCodes.BannedUser, "You are banned from logging in", HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,26 @@
// <copyright file="UserNotActivatedException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Exceptions;
using Astral.Services.Constants;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserNotActivatedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserNotActivatedException" /> class.
/// </summary>
public UserNotActivatedException()
: base(
ServiceErrorCodes.PendingActivation,
"Your account is pending activation. Please check your emails",
HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,23 @@
// <copyright file="UserNotFoundException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Constants;
using Astral.Core.Exceptions;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserNotFoundException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserNotFoundException" /> class.
/// </summary>
public UserNotFoundException()
: base(CoreErrorCodes.NotFoundError, "The provided user was not found", HttpStatusCode.NotFound)
{
}
}

View file

@ -0,0 +1,24 @@
// <copyright file="UsernameTakenException.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Exceptions;
using Astral.Services.Constants;
namespace Astral.Services.Exceptions;
/// <summary>
/// Thrown when there is an attempt to create a user with an already taken username.
/// </summary>
public class UsernameTakenException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UsernameTakenException" /> class.
/// </summary>
/// <param name="username">The username attempted.</param>
public UsernameTakenException(string username)
: base(ServiceErrorCodes.UsernameTaken, $"Username {username} has already been taken", HttpStatusCode.Conflict)
{
}
}

View file

@ -0,0 +1,51 @@
// <copyright file="ICryptographyService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Interfaces;
/// <summary>
/// Cryptographic operations.
/// </summary>
public interface ICryptographyService
{
/// <summary>
/// Generate a random salt.
/// </summary>
/// <param name="size">Size of the salt to generate - if omitted, will use PwdHashSettings:HashSize config.</param>
/// <returns>Byte array containing the generated salt.</returns>
byte[] GenerateSalt(int? size = null);
/// <summary>
/// Hash a password string with the accompanying salt.
/// </summary>
/// <param name="password">The password to hash.</param>
/// <param name="salt">The salt to use.</param>
/// <returns>A byte array of PwdHashSettings:HashSize config size of the password.</returns>
byte[] HashPassword(string password, byte[] salt);
/// <summary>
/// Verify that the given password matches the password hash and salt.
/// </summary>
/// <param name="password">The password to test.</param>
/// <param name="salt">The password's accompanying salt.</param>
/// <param name="passwordHash">The password hash to test again.</param>
/// <returns>True if verified, false otherwise.</returns>
bool VerifyPassword(string password, byte[] salt, byte[] passwordHash);
/// <summary>
/// Verify that the given password matches the password hash and salt.
/// </summary>
/// <param name="password">The password to test.</param>
/// <param name="salt">The password's base64 encoded accompanying salt.</param>
/// <param name="passwordHash">The password base64 hash to test again.</param>
/// <returns>True if verified, false otherwise.</returns>
bool VerifyPassword(string password, string salt, string passwordHash);
/// <summary>
/// Generate a cryptographically random string.
/// </summary>
/// <param name="length">Length of string to create.</param>
/// <returns>A random string.</returns>
string GenerateRandomString(int length);
}

View file

@ -0,0 +1,23 @@
// <copyright file="IEmailDomainBlacklistService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Interfaces;
/// <summary>
/// A service providing a blacklist of email domains to prevent abuse.
/// </summary>
public interface IEmailDomainBlacklistService
{
/// <summary>
/// Update the blacklist.
/// </summary>
Task UpdateBlacklist();
/// <summary>
/// Check the email against the blacklist.
/// </summary>
/// <param name="emailAddress">The email address to test.</param>
/// <returns>True if the email address' domain is in the blacklist.</returns>
Task<bool> CheckBlacklist(string emailAddress);
}

View file

@ -0,0 +1,17 @@
// <copyright file="IIdentityProvider.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Interfaces;
/// <summary>
/// Identify who the requesting user is.
/// </summary>
public interface IIdentityProvider
{
/// <summary>
/// Get the user id of the requester.
/// </summary>
/// <returns>String representing the user id, or null if none.</returns>
string GetRequestingUserId();
}

View file

@ -0,0 +1,16 @@
// <copyright file="IInitialUserService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Interfaces;
/// <summary>
/// Service responsible for creating the initial user if no other users exist.
/// </summary>
public interface IInitialUserService
{
/// <summary>
/// If an initial user is required, then create it.
/// </summary>
Task CreateFirstUserIfRequiredAsync();
}

View file

@ -0,0 +1,32 @@
// <copyright file="IUserGroupService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Services.Dtos;
namespace Astral.Services.Interfaces;
/// <summary>
/// <see cref="UserGroup" /> service.
/// </summary>
public interface IUserGroupService
{
/// <summary>
/// Create a new user group.
/// </summary>
/// <param name="ownerUserId">The id of the owner <see cref="User" />.</param>
/// <param name="title">The title of the new group.</param>
/// <param name="description">The description of the new group.</param>
/// <returns>The new user group dto.</returns>
Task<UserGroupDto> CreateUserGroup(Guid ownerUserId, string title, string description);
/// <summary>
/// Create a new internal user group.
/// </summary>
/// <param name="ownerUserId">The id of the owner <see cref="User" />.</param>
/// <param name="title">The title of the new group.</param>
/// <param name="description">The description of the new group.</param>
/// <returns>The new user group dto.</returns>
Task<UserGroupDto> CreateInternalGroup(Guid ownerUserId, string title, string description);
}

View file

@ -0,0 +1,27 @@
// <copyright file="IUserService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Services.Dtos;
namespace Astral.Services.Interfaces;
/// <summary>
/// User Service.
/// </summary>
public interface IUserService
{
/// <summary>
/// Create a new user and return its user dto.
/// </summary>
/// <param name="createUser">Instance of <see cref="CreateUserDto" />.</param>
/// <returns>Instance of <see cref="UserDto" />.</returns>
Task<UserDto> CreateNewUser(CreateUserDto createUser);
/// <summary>
/// Fetch a user based on their user id.
/// </summary>
/// <param name="userId">User id.</param>
/// <returns>Instance of <see cref="UserDto" />, or null if not found.</returns>
Task<UserDto> FindById(string userId);
}

View file

@ -0,0 +1,21 @@
// <copyright file="EmailDomainBlacklistOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Options;
/// <summary>
/// Configuration for the email domain blacklist service.
/// </summary>
public class EmailDomainBlacklistOptions
{
/// <summary>
/// Whether the email domain blacklist should be used.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Url of a master list for email address domains to blacklist.
/// </summary>
public string MasterList { get; set; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="InitialUserOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Options;
/// <summary>
/// Configuration for the first user created if none other exist.
/// </summary>
public class InitialUserOptions
{
/// <summary>
/// Username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Password.
/// </summary>
public string Password { get; set; }
}

View file

@ -0,0 +1,37 @@
// <copyright file="PwdHashOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Options;
/// <summary>
/// How the password should be hashed.
/// Refer to https://argon2-cffi.readthedocs.io/en/stable/argon2.html.
/// </summary>
public class PwdHashOptions
{
/// <summary>
/// Number of threads to use. (Higher the better).
/// </summary>
public int DegreeOfParallelism { get; set; }
/// <summary>
/// Number of iterations over the memory.
/// </summary>
public int NumberOfIterations { get; set; }
/// <summary>
/// Memory used by the algorithm.
/// </summary>
public int MemoryToUseKb { get; set; }
/// <summary>
/// Salt size in bytes.
/// </summary>
public int SaltSize { get; set; }
/// <summary>
/// Hash size in bytes.
/// </summary>
public int HashSize { get; set; }
}

View file

@ -0,0 +1,26 @@
// <copyright file="RegistrationOptions.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
namespace Astral.Services.Options;
/// <summary>
/// User registration configuration.
/// </summary>
public class RegistrationOptions
{
/// <summary>
/// Whether email verification should be required.
/// </summary>
public bool RequireEmailActivation { get; set; }
/// <summary>
/// Default profile hero image url.
/// </summary>
public string DefaultHeroImageUrl { get; set; }
/// <summary>
/// Default thumbnail image url.
/// </summary>
public string DefaultThumbnailImageUrl { get; set; }
}

View file

@ -0,0 +1,35 @@
// <copyright file="AutomapperProfile.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Services.Dtos;
using AutoMapper;
namespace Astral.Services.Profiles;
/// <summary>
/// Profile for AutoMapper.
/// </summary>
public class AutomapperProfile : Profile
{
/// <summary>
/// Initializes a new instance of the <see cref="AutomapperProfile" /> class.
/// </summary>
public AutomapperProfile()
{
CreateMap<User, UserDto>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.RegistrationDate, opt => opt.MapFrom(src => src.CreatedAt))
.ForMember(dest => dest.RegistrationDateTicks, opt => opt.MapFrom(src => src.CreatedAt.Ticks))
.ForMember(dest => dest.UserRole, opt => opt.MapFrom(src => src.UserRole));
CreateMap<UserGroup, UserGroupDto>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.CreationDate, opt => opt.MapFrom(src => src.CreatedAt))
.ForMember(dest => dest.CreationDateTicks, opt => opt.MapFrom(src => src.CreatedAt.Ticks))
.ForMember(dest => dest.Title, opt => opt.MapFrom(src => src.Title))
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description))
.ForMember(dest => dest.Internal, opt => opt.MapFrom(src => src.Internal));
}
}

View file

@ -0,0 +1,70 @@
// <copyright file="CryptographyService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using Astral.Services.Interfaces;
using Astral.Services.Options;
using Injectio.Attributes;
using Konscious.Security.Cryptography;
using Microsoft.Extensions.Options;
namespace Astral.Services.Services;
/// <inheritdoc />
[RegisterSingleton]
public class CryptographyService : ICryptographyService
{
private readonly PwdHashOptions _configuration;
/// <summary>
/// Initializes a new instance of the <see cref="CryptographyService" /> class.
/// </summary>
/// <param name="pwdHashSettings">Instance of <see cref="IOptions{PwdHashOptions}" />.</param>
public CryptographyService(IOptions<PwdHashOptions> pwdHashSettings)
{
_configuration = pwdHashSettings.Value;
}
/// <inheritdoc />
public byte[] GenerateSalt(int? size = null)
{
var result = RandomNumberGenerator.GetBytes(size ?? _configuration.SaltSize);
return result;
}
/// <inheritdoc />
public byte[] HashPassword(string password, byte[] salt)
{
var argon2Id = new Argon2id(Encoding.UTF8.GetBytes(password));
argon2Id.Salt = salt;
argon2Id.DegreeOfParallelism = _configuration.DegreeOfParallelism;
argon2Id.Iterations = _configuration.NumberOfIterations;
argon2Id.MemorySize = _configuration.MemoryToUseKb;
var bytes = argon2Id.GetBytes(_configuration.HashSize);
GC.Collect();
return bytes;
}
/// <inheritdoc />
public bool VerifyPassword(string password, byte[] salt, byte[] passwordHash)
{
var checkHash = HashPassword(password, salt);
return passwordHash.SequenceEqual(checkHash);
}
/// <inheritdoc />
public bool VerifyPassword(string password, string salt, string passwordHash)
{
var checkHash = HashPassword(password, Convert.FromBase64String(salt));
return Convert.FromBase64String(passwordHash).SequenceEqual(checkHash);
}
/// <inheritdoc />
public string GenerateRandomString(int length)
{
const string availableChars = "ABCDEFGHIJKLMONOPQRSTUVWXYZabcdefghijklmonopqrstuvwxyz0123456789";
return RandomNumberGenerator.GetString(availableChars, length);
}
}

View file

@ -0,0 +1,128 @@
// <copyright file="EmailDomainBlacklistService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net.Mail;
using Astral.Core.Entities;
using Astral.Core.Extensions;
using Astral.Core.RepositoryInterfaces;
using Astral.Services.Interfaces;
using Astral.Services.Options;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Astral.Services.Services;
/// <inheritdoc />
[RegisterScoped]
public class EmailDomainBlacklistService : IEmailDomainBlacklistService
{
private readonly EmailDomainBlacklistOptions _configuration;
private readonly ILogger<EmailDomainBlacklistService> _logger;
private readonly IEmailDomainBlacklistRepository _repository;
/// <summary>
/// Initializes a new instance of the <see cref="EmailDomainBlacklistService" /> class.
/// </summary>
/// <param name="configuration">Instance of <see cref="IOptions{TOptions}" />.</param>
/// <param name="repository">Instance of <see cref="EmailDomainBlacklistOptions" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public EmailDomainBlacklistService(
IOptions<EmailDomainBlacklistOptions> configuration,
IEmailDomainBlacklistRepository repository,
ILogger<EmailDomainBlacklistService> logger)
{
_configuration = configuration.Value;
_repository = repository;
_logger = logger;
}
/// <inheritdoc />
public async Task UpdateBlacklist()
{
if (!_configuration.Enabled)
{
_logger.LogInformation("Email address domain blacklist disabled");
return;
}
_logger.LogInformation("Updating email address domain blacklist");
try
{
var client = new HttpClient();
// Attempt to fetch master list.
var result = await client.GetAsync(_configuration.MasterList);
if (!result.IsSuccessStatusCode)
{
_logger.LogError(
"Failed to retrieve up-to-date blacklisted domains. Http status code: {code}",
result.StatusCode);
return;
}
var content = await result.Content.ReadAsStringAsync();
var entries = content.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.None)
.Where(s => !string.IsNullOrWhiteSpace(s) && s.Trim()[..1] != "#" && s.Trim()[..1] != "[" &&
s.Trim()[..1] != "!");
var existing = await _repository.GetAllAsync();
var newDomains = entries.Except(existing.Select(e => e.Domain)).ToList();
_logger.LogInformation("Adding {count} new domains to the blacklist", newDomains.Count);
// Add new domains to blacklist
var listChunks = newDomains.ChunkBy(1000);
newDomains.Clear();
foreach (var chunk in listChunks)
{
await _repository.AddAsync(chunk.Select(d => new EmailDomainBlacklist
{
Domain = d,
CreatedAt = DateTime.UtcNow
}));
}
GC.Collect(2, GCCollectionMode.Aggressive);
GC.WaitForFullGCComplete();
}
catch (Exception exception)
{
_logger.LogError("Failed to retrieve up-to-date blacklisted domains: {exception}", exception);
}
var count = await _repository.CountEntries();
_logger.LogInformation("Total email address domains in blacklist: {count}", count);
}
/// <inheritdoc />
public async Task<bool> CheckBlacklist(string emailAddress)
{
if (!_configuration.Enabled)
{
return false;
}
try
{
var mailAddress = new MailAddress(emailAddress.ToLowerInvariant());
var domain = mailAddress.Host;
var blacklisted = await _repository.FindByIdAsync(domain);
return blacklisted is not null;
}
catch (Exception exception)
{
_logger.LogWarning(
"Failed to determine if following email address is in the blacklist: {email}. {exception}",
emailAddress.MaskEmailAddress(),
exception);
return false;
}
}
}

View file

@ -0,0 +1,78 @@
// <copyright file="InitialUserService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using System.Net;
using Astral.Core.Constants;
using Astral.Core.RepositoryInterfaces;
using Astral.Services.Dtos;
using Astral.Services.Interfaces;
using Astral.Services.Options;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Astral.Services.Services;
/// <inheritdoc />
[RegisterScoped]
public class InitialUserService : IInitialUserService
{
private readonly InitialUserOptions _configuration;
private readonly ILogger<InitialUserService> _logger;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="InitialUserService" /> class.
/// </summary>
/// <param name="initialUserConfiguration">Instance of <see cref="IOptions{InitialUserOptions}" />.</param>
/// <param name="userService">Instance of <see cref="IUserService" />.</param>
/// <param name="userRepository">Instance of <see cref="IUserRepository" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public InitialUserService(
IOptions<InitialUserOptions> initialUserConfiguration,
IUserService userService,
IUserRepository userRepository,
ILogger<InitialUserService> logger)
{
_configuration = initialUserConfiguration.Value;
_userService = userService;
_userRepository = userRepository;
_logger = logger;
}
/// <inheritdoc />
public async Task CreateFirstUserIfRequiredAsync()
{
// Check if any users exist.
var userCount = await _userRepository.CountUsersAsync();
if (userCount > 0)
{
return;
}
_logger.LogInformation("Creating initial user. Username: {username}", _configuration.Username);
_logger.LogWarning("*************************************************************");
_logger.LogWarning("* CHANGE INITIAL USER PASSWORD IMMEDIATELY AFTER LOGGING IN *");
_logger.LogWarning("*************************************************************");
var newUser = await _userService.CreateNewUser(new CreateUserDto
{
Username = _configuration.Username,
Email = _configuration.Email,
Password = _configuration.Password,
ActivateImmediately = true,
IpAddress = IPAddress.Any
});
// Promote user to admin.
var userEntity = await _userRepository.FindByIdAsync(newUser.Id);
userEntity.UserRole = UserRole.Admin;
await _userRepository.UpdateAsync(userEntity);
_logger.LogInformation("Initial user {username} created", newUser.Id);
}
}

View file

@ -0,0 +1,137 @@
// <copyright file="UserGroupService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Entities;
using Astral.Core.Exceptions;
using Astral.Core.RepositoryInterfaces;
using Astral.Services.Dtos;
using Astral.Services.Exceptions;
using Astral.Services.Interfaces;
using AutoMapper;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
namespace Astral.Services.Services;
/// <inheritdoc />
[RegisterScoped]
public class UserGroupService : IUserGroupService
{
private readonly ILogger<UserGroupService> _logger;
private readonly IMapper _mapper;
private readonly IUserGroupRepository _userGroupRepository;
private readonly IUserRepository _userRepository;
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupService" /> class.
/// </summary>
/// <param name="userGroupRepository">Instance of <see cref="IUserGroupRepository" />.</param>
/// <param name="userRepository">Instance of <see cref="IUserRepository" />.</param>
/// <param name="mapper">Instance of <see cref="IMapper" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public UserGroupService(
IUserGroupRepository userGroupRepository,
IUserRepository userRepository,
IMapper mapper,
ILogger<UserGroupService> logger)
{
_userGroupRepository = userGroupRepository;
_userRepository = userRepository;
_mapper = mapper;
_logger = logger;
}
/// <inheritdoc />
public async Task<UserGroupDto> CreateUserGroup(Guid ownerUserId, string title, string description)
{
var user = await _userRepository.FindByIdAsync(ownerUserId);
if (user is null)
{
_logger.LogError("Attempted to create a user group for a user that does not exist: {userId}", ownerUserId);
throw new UserNotFoundException();
}
var uniqueId = await GetUniqueId();
var userGroup = new UserGroup
{
Id = uniqueId,
CreatedAt = DateTime.UtcNow,
OwnerUserId = ownerUserId,
Title = title,
Description = description ?? string.Empty,
Internal = false
};
_logger.LogInformation(
"Creating new user group {groupTitle} [{groupId}] for user {user} [{userId}]",
userGroup.Title,
userGroup.Id,
user.Username,
user.Id);
await _userGroupRepository.AddAsync(userGroup);
return _mapper.Map<UserGroupDto>(userGroup);
}
/// <inheritdoc />
public async Task<UserGroupDto> CreateInternalGroup(Guid ownerUserId, string title, string description)
{
var user = await _userRepository.FindByIdAsync(ownerUserId);
if (user is null)
{
_logger.LogError("Attempted to create a user group for a user that does not exist: {userId}", ownerUserId);
throw new UserNotFoundException();
}
var uniqueId = await GetUniqueId();
var userGroup = new UserGroup
{
Id = uniqueId,
CreatedAt = DateTime.UtcNow,
OwnerUserId = ownerUserId,
Title = title,
Description = description ?? string.Empty,
Internal = true
};
_logger.LogInformation(
"Creating new internal user group {groupTitle} [{groupId}] for user {user} [{userId}]",
userGroup.Title,
userGroup.Id,
user.Username,
user.Id);
await _userGroupRepository.AddAsync(userGroup);
return _mapper.Map<UserGroupDto>(userGroup);
}
/// <summary>
/// Create a unique guid.
/// </summary>
/// <returns>The new guid.</returns>
private async Task<Guid> GetUniqueId()
{
var attempt = 0;
const int maxAttempts = 10;
var newId = Guid.NewGuid();
while (await _userGroupRepository.FindByIdAsync(newId) is not null)
{
attempt++;
if (attempt >= maxAttempts)
{
_logger.LogCritical("Unable to generate a unique guid for user group");
throw new UnexpectedErrorException();
}
newId = Guid.NewGuid();
}
return newId;
}
}

View file

@ -0,0 +1,193 @@
// <copyright file="UserService.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Core.Constants;
using Astral.Core.Entities;
using Astral.Core.Exceptions;
using Astral.Core.Extensions;
using Astral.Core.Infrastructure;
using Astral.Core.RepositoryInterfaces;
using Astral.Services.Dtos;
using Astral.Services.Exceptions;
using Astral.Services.Interfaces;
using Astral.Services.Options;
using Astral.Services.Validators;
using AutoMapper;
using FluentValidation;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Astral.Services.Services;
/// <inheritdoc />
[RegisterScoped]
public class UserService : IUserService
{
private readonly IValidator<CreateUserDto> _createUserValidator;
private readonly ICryptographyService _cryptographyService;
private readonly IMapper _mapper;
private readonly RegistrationOptions _registrationConfiguration;
private readonly ITransactionProvider _transactionProvider;
private readonly IUserGroupService _userGroupService;
private readonly IUserRepository _userRepository;
private readonly IUserProfileRepository _userProfileRepository;
private readonly ILogger<UserService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="UserService" /> class.
/// </summary>
/// <param name="userRepository">Instance of <see cref="IUserRepository" />.</param>
/// <param name="cryptographyService">Instance of <see cref="ICryptographyService" />.</param>
/// <param name="userGroupService">Instance of <see cref="IUserGroupService" />.</param>
/// <param name="mapper">Instance of <see cref="IMapper" />.</param>
/// <param name="createUserValidator">Instance of <see cref="CreateUserValidator" />.</param>
/// <param name="registrationConfig">Instance of <see cref="IOptions{RegistrationOptions}" />.</param>
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider" />.</param>
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider" />.</param>
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
public UserService(
IUserRepository userRepository,
ICryptographyService cryptographyService,
IUserGroupService userGroupService,
IMapper mapper,
IValidator<CreateUserDto> createUserValidator,
IOptions<RegistrationOptions> registrationConfig,
ITransactionProvider transactionProvider,
IUserProfileRepository userProfileRepository,
ILogger<UserService> logger)
{
_userRepository = userRepository;
_cryptographyService = cryptographyService;
_userGroupService = userGroupService;
_mapper = mapper;
_createUserValidator = createUserValidator;
_registrationConfiguration = registrationConfig.Value;
_transactionProvider = transactionProvider;
_userProfileRepository = userProfileRepository;
_logger = logger;
}
/// <inheritdoc />
public async Task<UserDto> CreateNewUser(CreateUserDto createUser)
{
createUser.Username = createUser.Username.ToLower();
createUser.Email = createUser.Email.ToLowerInvariant();
await _createUserValidator.ValidateAndThrowAsync(createUser);
// Check if username is already taken.
var existingUser = await _userRepository.FindByUsername(createUser.Username);
if (existingUser is not null)
{
throw new UsernameTakenException(createUser.Username);
}
// Check if email already registered.
existingUser = await _userRepository.FindByEmailAddress(createUser.Email);
if (existingUser is not null)
{
throw new EmailAlreadyRegisteredException();
}
var user = new User
{
Id = await GetUniqueId(),
Username = createUser.Username,
EmailAddress = createUser.Email,
CreatedAt = DateTime.UtcNow,
CreatorIp = createUser.IpAddress.ToString(),
UserRole = UserRole.User
};
var salt = _cryptographyService.GenerateSalt();
var hash = _cryptographyService.HashPassword(createUser.Password, salt);
user.PasswordHash = Convert.ToBase64String(hash);
user.PasswordSalt = Convert.ToBase64String(salt);
_logger.LogInformation(
"Creating new user {username} ({email}) [{id}]",
user.Username,
user.EmailAddress.MaskEmailAddress(),
user.Id);
if (!_registrationConfiguration.RequireEmailActivation || createUser.ActivateImmediately)
{
}
using var transaction = await _transactionProvider.BeginTransactionAsync();
await _userRepository.AddAsync(user);
// Setup internal user groups.
var connectionGroup = await _userGroupService.CreateInternalGroup(
user.Id,
$"User {user.Id} connections",
$"Connections for {user.Username}");
var friendsGroup = await _userGroupService.CreateInternalGroup(
user.Id,
$"User {user.Id} friends",
$"Friends for {user.Username}");
user.ConnectionGroup = connectionGroup.Id;
user.FriendsGroup = friendsGroup.Id;
// Setup user profile.
var profile = new UserProfile()
{
Id = user.Id,
CreatedAt = DateTime.UtcNow,
HeroImageUrl = _registrationConfiguration.DefaultHeroImageUrl,
ThumbnailImageUrl = _registrationConfiguration.DefaultThumbnailImageUrl
};
await _userProfileRepository.AddAsync(profile);
await _userRepository.UpdateAsync(user);
transaction.Commit();
return _mapper.Map<UserDto>(user);
}
/// <inheritdoc />
public async Task<UserDto> FindById(string userId)
{
if (!Guid.TryParse(userId, out var userGuid))
{
return null;
}
var user = await _userRepository.FindByIdAsync(userGuid);
return _mapper.Map<UserDto>(user);
}
/// <summary>
/// Create a unique guid.
/// </summary>
/// <returns>The new guid.</returns>
private async Task<Guid> GetUniqueId()
{
var attempt = 0;
const int maxAttempts = 10;
var newId = Guid.NewGuid();
while (await _userRepository.FindByIdAsync(newId) is not null)
{
attempt++;
if (attempt >= maxAttempts)
{
_logger.LogCritical("Unable to generate a unique guid for user group");
throw new UnexpectedErrorException();
}
newId = Guid.NewGuid();
}
return newId;
}
}

View file

@ -0,0 +1,32 @@
// <copyright file="AuthenticateUserValidator.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Services.Dtos;
using FluentValidation;
using Injectio.Attributes;
namespace Astral.Services.Validators;
/// <summary>
/// Validation for <see cref="AuthenticateUserDto" />.
/// </summary>
[RegisterScoped]
public class AuthenticateUserValidator : AbstractValidator<AuthenticateUserDto>
{
private const int MinimumUsernameLength = 4;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticateUserValidator" /> class.
/// </summary>
public AuthenticateUserValidator()
{
RuleFor(dto => dto.Username).NotEmpty().WithMessage("Username must be provided")
.MinimumLength(MinimumUsernameLength)
.WithMessage("Username must be a minimum of " + MinimumUsernameLength + " characters")
.Matches(@"^[A-Za-z0-9._-]+$")
.WithMessage("Username can only contain letters, numbers, hyphens, dashes and periods.");
RuleFor(dto => dto.Password).NotEmpty().WithMessage("Password must be provided");
}
}

View file

@ -0,0 +1,44 @@
// <copyright file="CreateUserValidator.cs" company="alveus.dev">
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
// </copyright>
using Astral.Services.Dtos;
using Astral.Services.Interfaces;
using FluentValidation;
using Injectio.Attributes;
namespace Astral.Services.Validators;
/// <summary>
/// Validation for <see cref="CreateUserDto" />.
/// </summary>
[RegisterScoped]
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
private const int MinimumUsernameLength = 4;
private const int MaximumUsernameLength = 16;
/// <summary>
/// Initializes a new instance of the <see cref="CreateUserValidator" /> class.
/// </summary>
/// <param name="emailDomainBlacklistService">Instance of <see cref="IEmailDomainBlacklistService" />.</param>
public CreateUserValidator(IEmailDomainBlacklistService emailDomainBlacklistService)
{
RuleFor(dto => dto.Username).NotEmpty().WithMessage("Username must be provided")
.MinimumLength(MinimumUsernameLength)
.WithMessage("Username must be a minimum of " + MinimumUsernameLength + " characters")
.MaximumLength(MaximumUsernameLength)
.WithMessage("Username must be within " + MaximumUsernameLength + " characters")
.Matches(@"^[A-Za-z0-9._-]+$")
.WithMessage("Username can only contain letters, numbers, hyphens, dashes and periods.");
RuleFor(dto => dto.Email).NotEmpty().WithMessage("Email address must be provided")
.EmailAddress().WithMessage("Email address is not a valid email address");
RuleFor(dto => dto.Email).MustAsync(async (email, _) =>
await emailDomainBlacklistService.CheckBlacklist(email) == false)
.WithMessage("Email addresses from this domain cannot be used");
RuleFor(dto => dto.Password).NotEmpty().WithMessage("Password must be provided");
}
}

41
AstralApi.sln Normal file
View file

@ -0,0 +1,41 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Astral.ApiServer", "Astral.ApiServer\Astral.ApiServer.csproj", "{9A2C68B3-F38D-424E-AAB9-86BB5F4467CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Astral.Core", "Astral.Core\Astral.Core.csproj", "{31AF880C-0699-42B2-A5D0-896D1A9EA8DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Astral.DAL", "Astral.DAL\Astral.DAL.csproj", "{AD0427F5-4B88-4FE5-9F1C-28184482B551}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Astral.Services", "Astral.Services\Astral.Services.csproj", "{7594740E-7061-46C9-A870-7E3687D3BC36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{EB52D62B-1EB7-45C0-AB8F-71C3685F9240}"
ProjectSection(SolutionItems) = preProject
Alveus.ruleset = Alveus.ruleset
LICENSE = LICENSE
README.md = README.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9A2C68B3-F38D-424E-AAB9-86BB5F4467CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A2C68B3-F38D-424E-AAB9-86BB5F4467CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A2C68B3-F38D-424E-AAB9-86BB5F4467CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A2C68B3-F38D-424E-AAB9-86BB5F4467CA}.Release|Any CPU.Build.0 = Release|Any CPU
{31AF880C-0699-42B2-A5D0-896D1A9EA8DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{31AF880C-0699-42B2-A5D0-896D1A9EA8DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{31AF880C-0699-42B2-A5D0-896D1A9EA8DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{31AF880C-0699-42B2-A5D0-896D1A9EA8DF}.Release|Any CPU.Build.0 = Release|Any CPU
{AD0427F5-4B88-4FE5-9F1C-28184482B551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD0427F5-4B88-4FE5-9F1C-28184482B551}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD0427F5-4B88-4FE5-9F1C-28184482B551}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD0427F5-4B88-4FE5-9F1C-28184482B551}.Release|Any CPU.Build.0 = Release|Any CPU
{7594740E-7061-46C9-A870-7E3687D3BC36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7594740E-7061-46C9-A870-7E3687D3BC36}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7594740E-7061-46C9-A870-7E3687D3BC36}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7594740E-7061-46C9-A870-7E3687D3BC36}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 alveus.dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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 OR COPYRIGHT HOLDERS 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.

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# Astral API Service
Astral api directory service providing directory services.
Designed for Overte interface and domain servers.
## Note
There will be no compatibility offered for the Overte Metaverse Dashboard.
More information to come.