update infra docs

This commit is contained in:
Aaron Po
2026-08-04 06:18:54 -04:00
parent 88abb8e995
commit 64302286fb
11 changed files with 21 additions and 151 deletions

View File

@@ -9,20 +9,9 @@ namespace Infrastructure.Email.Templates.Rendering;
/// <summary>
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
/// </summary>
/// <param name="serviceProvider">
/// The service provider used to resolve dependencies for the Razor component rendering
/// pipeline.
/// </param>
/// <param name="loggerFactory">The logger factory passed to the <see cref="HtmlRenderer" /> used to render components.</param>
public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
: IEmailTemplateProvider
{
/// <summary>
/// Renders the UserRegisteredEmail template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The email confirmation link</param>
/// <returns>The rendered HTML string</returns>
public async Task<string> RenderUserRegisteredEmailAsync(
string username,
string confirmationLink
@@ -37,12 +26,6 @@ public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFact
return await RenderComponentAsync<UserRegistration>(parameters);
}
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
public async Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
@@ -58,13 +41,9 @@ public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFact
}
/// <summary>
/// Generic method to render any Razor component to HTML.
/// Creates a scoped <see cref="HtmlRenderer" />, dispatches the render onto its renderer thread,
/// and returns the resulting HTML string.
/// </summary>
/// <typeparam name="TComponent">The type of the Razor component to render.</typeparam>
/// <param name="parameters">A dictionary of parameter names and values to pass to the component.</param>
/// <returns>The rendered HTML string for the component.</returns>
private async Task<string> RenderComponentAsync<TComponent>(
Dictionary<string, object?> parameters
)

View File

@@ -5,21 +5,7 @@ namespace Infrastructure.Email;
/// </summary>
public interface IEmailProvider
{
/// <summary>
/// Sends an email to a single recipient.
/// </summary>
/// <param name="to">Recipient email address</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
Task SendAsync(string to, string subject, string body, bool isHtml = true);
/// <summary>
/// Sends an email to multiple recipients.
/// </summary>
/// <param name="to">List of recipient email addresses</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true);
}

View File

@@ -19,38 +19,10 @@ public class SmtpEmailProvider : IEmailProvider
private readonly bool _useSsl;
/// <summary>
/// Initializes a new instance of <see cref="SmtpEmailProvider" />, reading SMTP configuration
/// from environment variables.
/// Initializes a new instance of the <see cref="SmtpEmailProvider" /> class, reading SMTP configuration
/// from environment variables (<c>SMTP_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>,
/// <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>, <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>).
/// </summary>
/// <remarks>
/// Reads the following environment variables:
/// <list type="bullet">
/// <item>
/// <description><c>SMTP_HOST</c> (required) - the SMTP server hostname.</description>
/// </item>
/// <item>
/// <description><c>SMTP_PORT</c> (optional, default "587") - the SMTP server port, must be a valid integer.</description>
/// </item>
/// <item>
/// <description><c>SMTP_USERNAME</c> (optional) - the username used for authentication.</description>
/// </item>
/// <item>
/// <description><c>SMTP_PASSWORD</c> (optional) - the password used for authentication.</description>
/// </item>
/// <item>
/// <description><c>SMTP_USE_SSL</c> (optional, default "true") - whether to use StartTls when connecting.</description>
/// </item>
/// <item>
/// <description><c>SMTP_FROM_EMAIL</c> (required) - the email address used as the sender.</description>
/// </item>
/// <item>
/// <description>
/// <c>SMTP_FROM_NAME</c> (optional, default "The Biergarten") - the display name used as the
/// sender.
/// </description>
/// </item>
/// </list>
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>SMTP_HOST</c> or <c>SMTP_FROM_EMAIL</c> is not set, or when <c>SMTP_PORT</c> is not a valid integer.
/// </exception>
@@ -79,31 +51,17 @@ public class SmtpEmailProvider : IEmailProvider
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
}
/// <summary>
/// Sends an email to a single recipient by delegating to the multi-recipient overload.
/// </summary>
/// <param name="to">Recipient email address</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
/// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails.</exception>
public async Task SendAsync(string to, string subject, string body, bool isHtml = true)
{
await SendAsync([to], subject, body, isHtml);
}
/// <summary>
/// Sends an email to multiple recipients using MailKit's <see cref="SmtpClient" />.
/// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates
/// if credentials were configured, sends the message, and disconnects.
/// </summary>
/// <param name="to">List of recipient email addresses</param>
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
/// <exception cref="InvalidOperationException">
/// Thrown when connecting, authenticating, or sending via SMTP fails. The
/// original exception is included as the inner exception.
/// Thrown when connecting, authenticating, or sending via SMTP fails, wrapping the original exception.
/// </exception>
public async Task SendAsync(
IEnumerable<string> to,

View File

@@ -10,22 +10,11 @@ public interface ITokenInfrastructure
/// <summary>
/// Generates a signed JWT for the given user.
/// </summary>
/// <param name="userId">The unique identifier of the user the token is issued for.</param>
/// <param name="username">The username of the user, included as a claim.</param>
/// <param name="expiry">The date and time at which the token expires.</param>
/// <param name="secret">The symmetric secret used to sign the token.</param>
/// <returns>The serialized, signed JWT string.</returns>
string GenerateJwt(Guid userId, string username, DateTime expiry, string secret);
/// <summary>
/// Validates a JWT and returns the resulting claims principal.
/// </summary>
/// <param name="token">The JWT string to validate.</param>
/// <param name="secret">The symmetric secret used to verify the token's signature.</param>
/// <returns>A <see cref="ClaimsPrincipal" /> representing the validated token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is invalid, expired, or fails
/// validation.
/// </exception>
/// <exception cref="Domain.Exceptions.UnauthorizedException">Thrown when the token is invalid, expired, or fails validation.</exception>
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
}

View File

@@ -13,18 +13,13 @@ namespace Infrastructure.Jwt;
public class JwtInfrastructure : ITokenInfrastructure
{
/// <summary>
/// Generates a signed JWT containing the user's ID, username, issued-at time, expiry time,
/// and a unique token identifier (JTI), signed using HMAC-SHA256 with the provided secret.
/// Generates a signed JWT, signed using HMAC-SHA256 with the provided secret.
/// </summary>
/// <remarks>
/// Sets the following registered claims: <c>sub</c> (userId), <c>unique_name</c> (username),
/// <c>iat</c> (current UTC time), <c>exp</c> (expiry), and <c>jti</c> (a newly generated GUID).
/// Sets registered claims <c>sub</c> (userId), <c>unique_name</c> (username), <c>iat</c> (current UTC time),
/// <c>exp</c> (expiry), and <c>jti</c> (a newly generated GUID).
/// </remarks>
/// <param name="userId">The unique identifier of the user the token is issued for.</param>
/// <param name="username">The username of the user, included as a claim.</param>
/// <param name="expiry">The date and time at which the token expires.</param>
/// <param name="secret">The symmetric secret used to sign the token (encoded as UTF-8 bytes).</param>
/// <returns>The serialized, signed JWT string.</returns>
public string GenerateJwt(Guid userId, string username, DateTime expiry, string secret)
{
JsonWebTokenHandler handler = new();
@@ -61,12 +56,9 @@ public class JwtInfrastructure : ITokenInfrastructure
/// Validates a JWT's signature and lifetime (issuer and audience validation are disabled),
/// using the provided secret as the HMAC-SHA256 symmetric signing key.
/// </summary>
/// <param name="token">The JWT string to validate.</param>
/// <param name="secret">The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes).</param>
/// <returns>A <see cref="ClaimsPrincipal" /> wrapping the validated token's claims identity.</returns>
/// <exception cref="UnauthorizedException">
/// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation
/// (including when validation itself throws, e.g. due to a malformed token or signature mismatch).
/// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation.
/// </exception>
public async Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret)
{

View File

@@ -15,15 +15,9 @@ public class Argon2Infrastructure : IPasswordInfrastructure
private const int ArgonMemoryKb = 65536; // 64MB
/// <summary>
/// Hashes a plaintext password using Argon2id with a newly generated 128-bit cryptographically
/// random salt, 4 iterations, 64MB of memory, and a degree of parallelism equal to the number of
/// available processors (minimum 1).
/// Hashes a plaintext password using Argon2id with a fresh random salt.
/// </summary>
/// <param name="password">The plaintext password to hash.</param>
/// <returns>
/// A string of the form <c>"{base64Salt}:{base64Hash}"</c> containing the salt and the resulting
/// 256-bit hash, suitable for storage and later verification.
/// </returns>
/// <returns>A string of the form <c>"{base64Salt}:{base64Hash}"</c>, suitable for storage.</returns>
public string Hash(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
@@ -40,18 +34,12 @@ public class Argon2Infrastructure : IPasswordInfrastructure
}
/// <summary>
/// Verifies a plaintext password against a stored salt/hash string by recomputing the Argon2id
/// hash with the extracted salt and comparing it to the stored hash using a fixed-time comparison
/// Verifies a plaintext password against a stored salt/hash string, using a fixed-time comparison
/// to mitigate timing attacks.
/// </summary>
/// <param name="password">The plaintext password to verify.</param>
/// <param name="stored">
/// The stored string of the form <c>"{base64Salt}:{base64Hash}"</c> previously produced by <see cref="Hash" />.
/// </param>
/// <returns>
/// <c>true</c> if the password matches the stored hash; <c>false</c> if it does not match, the stored
/// string is malformed (e.g. not in the expected two-part format, or not valid base64), or any other
/// error occurs while verifying.
/// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any
/// other error occurs during verification.
/// </returns>
public bool Verify(string password, string stored)
{

View File

@@ -8,15 +8,10 @@ public interface IPasswordInfrastructure
/// <summary>
/// Hashes a plaintext password, generating a new random salt.
/// </summary>
/// <param name="password">The plaintext password to hash.</param>
/// <returns>A string encoding both the salt and the resulting hash, suitable for storage.</returns>
public string Hash(string password);
/// <summary>
/// Verifies a plaintext password against a previously stored hash.
/// Verifies a plaintext password against a previously stored hash produced by <see cref="Hash" />.
/// </summary>
/// <param name="password">The plaintext password to verify.</param>
/// <param name="stored">The stored salt/hash string previously produced by <see cref="Hash" />.</param>
/// <returns><c>true</c> if the password matches the stored hash; otherwise <c>false</c>.</returns>
public bool Verify(string password, string stored);
}

View File

@@ -8,15 +8,11 @@ namespace Infrastructure.Sql;
/// Default <see cref="ISqlConnectionFactory" /> implementation that creates SQL Server connections,
/// resolving the connection string from environment variables or application configuration.
/// </summary>
/// <param name="configuration">The application configuration, used as a fallback source for the connection string.</param>
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(configuration);
/// <summary>
/// Creates a new, unopened <see cref="SqlConnection" /> using the resolved connection string.
/// </summary>
/// <returns>A new <see cref="SqlConnection" /> instance.</returns>
/// <inheritdoc />
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
@@ -24,15 +20,10 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon
/// <summary>
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables
/// via <see cref="SqlConnectionStringHelper.BuildConnectionString" />, and finally the <c>"Default"</c>
/// connection string from <paramref name="configuration" />.
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables,
/// and finally the <c>"Default"</c> connection string from configuration.
/// </summary>
/// <param name="configuration">The application configuration to fall back to.</param>
/// <returns>The resolved SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no connection string can be resolved from any of the supported sources.
/// </exception>
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first

View File

@@ -10,6 +10,5 @@ public interface ISqlConnectionFactory
/// <summary>
/// Creates a new, unopened database connection.
/// </summary>
/// <returns>A new <see cref="DbConnection" /> instance.</returns>
DbConnection CreateConnection();
}

View File

@@ -6,15 +6,12 @@ namespace Infrastructure.Sql;
/// Base class for ADO.NET-based repositories, providing shared connection creation and
/// entity-mapping infrastructure for derived repository implementations.
/// </summary>
/// <typeparam name="T">The entity type managed by the repository.</typeparam>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
/// <summary>
/// Creates and opens a new database connection using the configured <see cref="ISqlConnectionFactory" />.
/// </summary>
/// <returns>An open <see cref="DbConnection" /> ready for use.</returns>
/// <exception cref="DbException">Thrown when the connection cannot be opened.</exception>
protected async Task<DbConnection> CreateConnection()
{
@@ -26,7 +23,5 @@ public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
/// <summary>
/// Maps the current row of a data reader to an instance of <typeparamref name="T" />.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped entity instance.</returns>
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -9,10 +9,9 @@ public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
/// <remarks>Throws <see cref="InvalidOperationException" /> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.</remarks>
/// <param name="databaseName">If null, falls back to the DB_NAME environment variable.</param>
public static string BuildConnectionString(string? databaseName = null)
{
string server =
@@ -48,7 +47,6 @@ public static class SqlConnectionStringHelper
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");