Move projects to src; add Neutrino secret

This commit is contained in:
Holden
2026-06-19 22:52:38 -05:00
parent 2cfc5e7369
commit 95751904fa
90 changed files with 32 additions and 10 deletions

21
src/API/API.csproj Normal file
View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>cfcf35c1-8c26-473a-a8e9-44ba009adea8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RecNet.Application\RecNet.Application.csproj" />
<ProjectReference Include="..\RecNet.Infrastructure\RecNet.Infrastructure.csproj" />
<ProjectReference Include="..\RecNet.ServiceDefaults\RecNet.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace API.Configuration;
public class NeutrinoOptions
{
public string Secret { get; set; } = string.Empty;
public bool ValidateSecret { get; set; } = false;
}

View File

@@ -0,0 +1,6 @@
namespace API.Configuration;
public class RecNetOptions
{
public bool UseForwardedHeaders { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace API.Contracts.Neutrino.Enums;
public enum AuthenticationResultCode
{
/// <summary>
/// Indicates that authentication is incomplete and only the associated data is returned.
/// </summary>
/// <remarks>This value is typically used when launching the game with no account, in which data to authenticate is not provided.</remarks>
AuthenticationIncomplete,
/// <summary>
/// Indicates that authentication was successful.
/// </summary>
AuthenticationSuccessful,
/// <summary>
/// Indicates that authentication failed due to incorrect credentials.
/// </summary>
AuthenticationFailedWrongCredentials,
/// <summary>
/// Indicates that the parameters provided to the operation are invalid.
/// </summary>
InvalidParameters
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace API.Contracts.Neutrino.Requests;
public class NeutrinoAuthenticateRequest
{
[JsonPropertyName("profileId")]
public Guid ProfileId;
[JsonPropertyName("accessToken")]
public required string AccessToken;
[JsonPropertyName("appVersion")]
public required string AppVersion;
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using API.Contracts.Neutrino.Enums;
namespace API.Contracts.Neutrino.Responses;
// {"ResultCode":1,"UserId":"their user id","Nickname":"splotybean"}
public class NeutrinoAuthenticateResponse : NeutrinoResultCodeResponse
{
public static NeutrinoAuthenticateResponse Success(string userId, string nickname) => new()
{
ResultCode = (byte)AuthenticationResultCode.AuthenticationSuccessful,
UserId = userId,
Nickname = nickname
};
public static NeutrinoAuthenticateResponse Failure(AuthenticationResultCode error) => new()
{
ResultCode = (byte)error
};
[JsonPropertyName(name: "UserId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? UserId { get; set; }
[JsonPropertyName(name: "Nickname")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nickname { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace API.Contracts.Neutrino.Responses;
public class NeutrinoResultCodeResponse
{
public static NeutrinoResultCodeResponse Success() => new NeutrinoResultCodeResponse
{
ResultCode = 0
};
public static NeutrinoResultCodeResponse Failure(byte resultCode, string? message = null)
{
return new NeutrinoResultCodeResponse
{
Message = message,
ResultCode = resultCode
};
}
[JsonPropertyName(name: "Data")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Data { get; set; }
[JsonPropertyName(name: "Message")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; set; }
[JsonPropertyName(name: "ResultCode")]
public byte ResultCode { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using RecNet.Domain.Common;
namespace API.Contracts.Profiles.Requests;
public class LoginRequest
{
[Required]
public required string AppVersion { get; set; }
[Required, StringLength(128, MinimumLength = 1)]
public required string DeviceId { get; set; }
[Required]
public required string PlatformAuthentication { get; set; }
[Required, StringLength(50, MinimumLength = 1)]
public required string PlatformId { get; set; }
[EnumDataType(typeof(PlatformType))]
public PlatformType PlatformType { get; set; }
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using RecNet.Application.Profiles;
namespace API.Contracts.Profiles.Responses;
public class LoginResponse
{
[JsonPropertyName("Profile")]
public required ProfileDTO Profile { get; set; }
[JsonPropertyName("AccessToken")]
public required string AccessToken { get; set; }
[JsonPropertyName("ExpiresIn")]
public required int ExpiresIn { get; set; }
}

View File

@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using RecNet.Application.Common.Interfaces;
namespace API.Controllers.Config.V1;
[Route("api/[controller]/v1")]
[ApiController]
public class ConfigController(IConfigService configService) : ControllerBase
{
[HttpGet("motd")]
public async Task<ActionResult<string>> GetMotd(CancellationToken ct = default)
{
var motd = await configService.GetAsync("Config:MOTD", "Ten Whole Years!", ct);
return Ok(motd);
}
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json;
using API.Configuration;
using API.Contracts.Neutrino.Enums;
using API.Contracts.Neutrino.Requests;
using API.Contracts.Neutrino.Responses;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using RecNet.Application.Neutrino;
namespace API.Controllers.Neutrino;
[Route("[controller]")]
[ApiController]
public class NeutrinoController(
INeutrinoAuthorizationService authorizationService,
IOptions<NeutrinoOptions> options) : ControllerBase
{
private readonly NeutrinoOptions _options = options.Value;
// Photon sends the request with Content-Type: text/plain instead of application/json.
[Route("authorize")]
public async Task<ActionResult<NeutrinoAuthenticateResponse>> AuthorizeNeutrinoAsync(
[FromQuery(Name = "secret")] string? secret = null,
CancellationToken ct = default)
{
if (_options.ValidateSecret && secret != _options.Secret)
return Forbid();
using var reader = new StreamReader(Request.Body);
string rawText = await reader.ReadToEndAsync(ct);
NeutrinoAuthenticateRequest? request;
try
{
request = JsonSerializer.Deserialize<NeutrinoAuthenticateRequest>(rawText, new JsonSerializerOptions
{
IncludeFields = true,
PropertyNameCaseInsensitive = true
});
}
catch
{
return Ok(
NeutrinoAuthenticateResponse.Failure(
AuthenticationResultCode.InvalidParameters));
}
if (request is null)
return Ok(
NeutrinoAuthenticateResponse.Failure(
AuthenticationResultCode.InvalidParameters));
var result = await authorizationService.AuthorizeAsync(
new AuthorizeNeutrinoCommand(
request.ProfileId,
request.AccessToken),
ct);
if (!result.Succeeded)
return Ok(
NeutrinoAuthenticateResponse.Failure(
MapFailure(result.Failure)));
return Ok(
NeutrinoAuthenticateResponse.Success(
result.UserId!,
result.Nickname!));
}
private static AuthenticationResultCode MapFailure(NeutrinoAuthorizationFailure? failure)
=> failure == NeutrinoAuthorizationFailure.InvalidParameters
? AuthenticationResultCode.InvalidParameters
: AuthenticationResultCode.AuthenticationFailedWrongCredentials;
}

View File

@@ -0,0 +1,53 @@
using API.Contracts.Profiles.Responses;
using Microsoft.AspNetCore.Mvc;
using RecNet.Application.Profiles;
using LoginRequest = API.Contracts.Profiles.Requests.LoginRequest;
namespace API.Controllers.Profiles.V1;
[Route("api/[controller]/v1")]
[ApiController]
public class ProfilesController(IProfileService profileService) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProfileDTO>> GetProfile(
Guid id,
CancellationToken ct)
{
var profile = await profileService.GetProfileAsync(id, ct);
if (profile == null)
return NotFound();
return profile;
}
[HttpGet("bulk")]
public async Task<IReadOnlyList<ProfileDTO>> GetBulkProfiles(
[FromQuery(Name = "id")] List<Guid> ids,
CancellationToken ct)
=> await profileService.GetProfilesAsync(ids, ct);
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
var result = await profileService.LoginAsync(
new LoginProfileCommand(
request.AppVersion,
request.DeviceId,
request.PlatformType,
request.PlatformId,
request.PlatformAuthentication
), ct);
if (!result.Succeeded)
return BadRequest(result.Message);
return new LoginResponse
{
Profile = result.Profile!,
AccessToken = result.AccessToken!,
ExpiresIn = result.ExpiresIn
};
}
}

79
src/API/Program.cs Normal file
View File

@@ -0,0 +1,79 @@
using System.Text;
using API.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;
using RecNet.Application;
using RecNet.Infrastructure;
using RecNet.Infrastructure.Services.Tokens;
using RecNet.ServiceDefaults;
namespace API;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<NeutrinoOptions>()
.Bind(builder.Configuration.GetSection("Neutrino"))
.ValidateOnStart();
var recNetOptions = builder.Configuration.GetSection("RecNet").Get<RecNetOptions>()
?? new RecNetOptions();
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>()
?? new JwtOptions();
builder.AddServiceDefaults();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddApplication();
builder.AddInfrastructure();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
if (recNetOptions.UseForwardedHeaders)
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapDefaultEndpoints();
app.Run();
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5155",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7218;http://localhost:5155",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

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

9
src/API/appsettings.json Normal file
View File

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

25
src/AppHost/AppHost.cs Normal file
View File

@@ -0,0 +1,25 @@
var builder = DistributedApplication.CreateBuilder(args);
var postgresPassword = builder.AddParameter("postgres-password", secret: true);
// Infra
var postgres = builder.AddPostgres("postgres")
.WithDataVolume("recnet-postgres-data")
.WithLifetime(ContainerLifetime.Persistent)
.WithPassword(postgresPassword)
.WithHostPort(5432)
.WithPgAdmin();
var db = postgres.AddDatabase("recnet");
// Services
var migrationService = builder.AddProject<Projects.RecNet_MigrationService>("migrationservice")
.WithReference(db)
.WaitFor(db);
builder.AddProject<Projects.API>("api")
.WithReference(db)
.WaitFor(db)
.WaitForCompletion(migrationService);
builder.Build().Run();

View File

@@ -0,0 +1,20 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.5">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>f7385039-092b-4272-a61a-69c6a3d0d618</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\API\API.csproj" />
<ProjectReference Include="..\RecNet.MigrationService\RecNet.MigrationService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17063;http://localhost:15151",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21097",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22143"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15151",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19205",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20044"
}
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"appHost": {
"path": "AppHost.csproj"
}
}

View File

@@ -0,0 +1,8 @@
namespace RecNet.Application.Common.Interfaces;
public interface IConfigService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task<T> GetAsync<T>(string key, T defaultValue, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, CancellationToken ct = default);
}

View File

@@ -0,0 +1,14 @@
using RecNet.Application.Common.PlatformAuth;
using RecNet.Domain.Common;
namespace RecNet.Application.Common.Interfaces;
public interface IPlatformAuthValidator
{
PlatformType PlatformType { get; }
Task<PlatformAuthResult> ValidateAsync(
string platformAuthentication,
string platformId,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
using RecNet.Application.Common.Steam;
namespace RecNet.Application.Common.Interfaces;
public interface ISteamAuthService
{
Task<SteamAuthResult> AuthorizeAsync(
string ticket,
uint appId,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
using RecNet.Application.Common.Tokens;
using RecNet.Domain.Profiles;
namespace RecNet.Application.Common.Interfaces;
public interface ITokenService
{
TokenResult GenerateProfileToken(Profile profile);
TokenVerifyResult VerifyToken(string token);
}

View File

@@ -0,0 +1,12 @@
using AutoMapper;
using RecNet.Application.Profiles;
namespace RecNet.Application.Common.Mapping;
public class ApplicationMappingProfile : Profile
{
public ApplicationMappingProfile()
{
CreateMap<Domain.Profiles.Profile, ProfileDTO>();
}
}

View File

@@ -0,0 +1,21 @@
namespace RecNet.Application.Common.PlatformAuth;
public class PlatformAuthResult
{
public bool Succeeded { get; private set; }
public string? Message { get; private set; }
public string? Name { get; private set; }
private PlatformAuthResult(bool succeeded, string? message, string? name)
{
Succeeded = succeeded;
Message = message;
Name = name;
}
public static PlatformAuthResult Success(string? name)
=> new(true, null, name);
public static PlatformAuthResult Failure(string? message = null)
=> new(false, message, null);
}

View File

@@ -0,0 +1,19 @@
using System.Security.Claims;
namespace RecNet.Application.Common.Security;
public static class ClaimsPrincipalExtensions
{
public static Guid GetProfileId(this ClaimsPrincipal user)
{
var value =
user.FindFirst("sub")?.Value ??
user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(value, out var profileId))
throw new UnauthorizedAccessException(
"Account id claim is missing or invalid.");
return profileId;
}
}

View File

@@ -0,0 +1,21 @@
namespace RecNet.Application.Common.Steam;
public class SteamAuthResult
{
public bool Succeeded { get; private set; }
public string? SteamId { get; private set; }
public string? DisplayName { get; private set; }
private SteamAuthResult(bool succeeded, string? steamId, string? displayName)
{
Succeeded = succeeded;
SteamId = steamId;
DisplayName = displayName;
}
public static SteamAuthResult Success(string steamId, string? displayName)
=> new(true, steamId, displayName);
public static SteamAuthResult Failure()
=> new (false, null, null);
}

View File

@@ -0,0 +1,5 @@
namespace RecNet.Application.Common.Tokens;
public sealed record TokenResult(
string AccessToken,
int ExpiresIn);

View File

@@ -0,0 +1,12 @@
namespace RecNet.Application.Common.Tokens;
public sealed record TokenVerifyResult(
bool Succeeded,
Guid? ProfileId)
{
public static TokenVerifyResult Success(Guid profileId)
=> new(true, profileId);
public static TokenVerifyResult Failure()
=> new(false, null);
}

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
using RecNet.Application.Neutrino;
using RecNet.Application.Profiles;
namespace RecNet.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly);
services.AddScoped<NameGenerator>();
services.AddScoped<IProfileService, ProfileService>();
services.AddScoped<INeutrinoAuthorizationService, NeutrinoAuthorizationService>();
return services;
}
}

View File

@@ -0,0 +1,5 @@
namespace RecNet.Application.Neutrino;
public sealed record AuthorizeNeutrinoCommand(
Guid ProfileId,
string AccessToken);

View File

@@ -0,0 +1,8 @@
namespace RecNet.Application.Neutrino;
public interface INeutrinoAuthorizationService
{
Task<NeutrinoAuthorizationResult> AuthorizeAsync(
AuthorizeNeutrinoCommand command,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,7 @@
namespace RecNet.Application.Neutrino;
public enum NeutrinoAuthorizationFailure
{
InvalidParameters,
AuthenticationFailed
}

View File

@@ -0,0 +1,17 @@
namespace RecNet.Application.Neutrino;
public sealed record NeutrinoAuthorizationResult(
bool Succeeded,
NeutrinoAuthorizationFailure? Failure,
string? UserId,
string? Nickname)
{
public static NeutrinoAuthorizationResult Success(Guid userId, string nickname)
=> new(true, null, userId.ToString(), nickname);
public static NeutrinoAuthorizationResult InvalidParameters()
=> new(false, NeutrinoAuthorizationFailure.InvalidParameters, null, null);
public static NeutrinoAuthorizationResult AuthenticationFailed()
=> new(false, NeutrinoAuthorizationFailure.AuthenticationFailed, null, null);
}

View File

@@ -0,0 +1,30 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Profiles;
namespace RecNet.Application.Neutrino;
public class NeutrinoAuthorizationService(
IProfileRepository profileRepository,
ITokenService tokenService) : INeutrinoAuthorizationService
{
public async Task<NeutrinoAuthorizationResult> AuthorizeAsync(
AuthorizeNeutrinoCommand command,
CancellationToken ct = default)
{
if (command.ProfileId == Guid.Empty || string.IsNullOrWhiteSpace(command.AccessToken))
return NeutrinoAuthorizationResult.InvalidParameters();
var tokenVerifyResult = tokenService.VerifyToken(command.AccessToken);
if (!tokenVerifyResult.Succeeded || tokenVerifyResult.ProfileId is null)
return NeutrinoAuthorizationResult.AuthenticationFailed();
if (tokenVerifyResult.ProfileId.Value != command.ProfileId)
return NeutrinoAuthorizationResult.AuthenticationFailed();
var profile = await profileRepository.GetByIdAsync(command.ProfileId, ct);
if (profile is null || profile.IsBanned)
return NeutrinoAuthorizationResult.AuthenticationFailed();
return NeutrinoAuthorizationResult.Success(profile.ProfileId, profile.Name);
}
}

View File

@@ -0,0 +1,8 @@
namespace RecNet.Application.Profiles;
public interface IProfileService
{
Task<ProfileDTO?> GetProfileAsync(Guid profileId, CancellationToken ct = default);
Task<IReadOnlyList<ProfileDTO>> GetProfilesAsync(List<Guid> profileIds, CancellationToken ct = default);
Task<LoginProfileResult> LoginAsync(LoginProfileCommand command, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
using RecNet.Domain.Common;
namespace RecNet.Application.Profiles;
public sealed record LoginProfileCommand(
string AppVersion,
string DeviceId,
PlatformType PlatformType,
string PlatformId,
string PlatformAuthentication);

View File

@@ -0,0 +1,33 @@
namespace RecNet.Application.Profiles;
public class LoginProfileResult
{
public bool Succeeded { get; set; }
public string? Message { get; set; }
public ProfileDTO? Profile { get; set; }
public string? AccessToken { get; set; }
public int ExpiresIn { get; set; }
public static LoginProfileResult Success(ProfileDTO profile, string accessToken, int expiresIn)
{
return new LoginProfileResult
{
Succeeded = true,
Profile = profile,
AccessToken = accessToken,
ExpiresIn = expiresIn
};
}
public static LoginProfileResult Fail(string? message = null)
{
return new LoginProfileResult
{
Succeeded = false,
Message = message
};
}
}

View File

@@ -0,0 +1,17 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Profiles.Names;
namespace RecNet.Application.Profiles;
public class NameGenerator(IConfigService configService)
{
public async Task<string> GenerateAsync(CancellationToken ct = default)
{
var config = await configService.GetAsync("Profiles:NameGen", DefaultNameGenConfig.Value, ct);
var adjective = config.Adjectives[Random.Shared.Next(config.Adjectives.Count)];
var noun = config.Nouns[Random.Shared.Next(config.Nouns.Count)];
return $"{adjective}{noun}";
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace RecNet.Application.Profiles;
public class ProfileDTO
{
[JsonPropertyName("ProfileId")]
public Guid ProfileId { get; init; }
[JsonPropertyName("Name")]
public required string Name { get; init; }
}

View File

@@ -0,0 +1,95 @@
using AutoMapper;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
using ProfileEntity = RecNet.Domain.Profiles.Profile;
namespace RecNet.Application.Profiles;
public class ProfileService(
IEnumerable<IPlatformAuthValidator> validators,
IGameVersionRepository gameVersionRepository,
IProfileRepository profileRepository,
IConfigService configService,
NameGenerator nameGenerator,
ITokenService tokenService,
IMapper mapper) : IProfileService
{
public async Task<ProfileDTO?> GetProfileAsync(Guid profileId, CancellationToken ct = default)
{
var profile = await profileRepository.GetByIdAsync(profileId, ct);
return profile is null
? null
: mapper.Map<ProfileDTO>(profile);
}
public async Task<IReadOnlyList<ProfileDTO>> GetProfilesAsync(
List<Guid> profileIds,
CancellationToken ct = default)
{
var profiles = await profileRepository.GetByIdsAsync(profileIds, ct);
return mapper.Map<List<ProfileDTO>>(profiles);
}
public async Task<LoginProfileResult> LoginAsync(
LoginProfileCommand command,
CancellationToken ct = default)
{
// 1. Validate Game Version
if (string.IsNullOrWhiteSpace(command.AppVersion))
return LoginProfileResult.Fail("App version is required.");
var isValidGameVersion = await gameVersionRepository.IsValidAsync(command.AppVersion, ct);
if (!isValidGameVersion)
return LoginProfileResult.Fail("Invalid app version.");
// 2. Validate Platform Authentication
var validator = validators.FirstOrDefault(x => x.PlatformType == command.PlatformType);
if (validator is null)
return LoginProfileResult.Fail("Invalid platform type");
var ignoreAuthValidation = await configService.GetAsync("Profiles:IgnoreAuthValidation", true, ct);
var auth = await validator.ValidateAsync(
command.PlatformAuthentication,
command.PlatformId,
ct);
if (!auth.Succeeded && !ignoreAuthValidation)
return LoginProfileResult.Fail($"Platform Auth Failed: {auth.Message}");
// 3. Get or Create Profile
var profile = await profileRepository.GetByPlatform(
command.PlatformType,
command.PlatformId,
ct);
if (profile is null)
{
var name = await nameGenerator.GenerateAsync(ct);
profile = ProfileEntity.Create(
name,
command.PlatformType,
command.PlatformId);
await profileRepository.AddAsync(profile, ct);
}
if (profile.IsBanned)
return LoginProfileResult.Fail("Profile is banned");
profile.RecordSuccessfulLogin(command.DeviceId, auth.Name);
await profileRepository.SaveChangesAsync(ct);
var token = tokenService.GenerateProfileToken(profile);
return LoginProfileResult.Success(
profile: mapper.Map<ProfileDTO>(profile),
accessToken: token.AccessToken,
expiresIn: token.ExpiresIn
);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RecNet.Domain\RecNet.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Authentication\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace RecNet.Domain.Common;
public enum PlatformType
{
Steamworks = 0,
Meta = 1,
}

View File

@@ -0,0 +1,8 @@
namespace RecNet.Domain.Configuration;
public interface IServerConfigRepository
{
Task<ServerConfig?> GetAsync(string key, CancellationToken ct = default);
Task SetAsync(string key, string value, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,24 @@
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.Configuration;
public class ServerConfig
{
private ServerConfig(string key, string value)
{
Key = RequireValue(key, nameof(key));
Value = value;
}
public string Key { get; private set; }
public string Value { get; private set; }
public static ServerConfig Create(string key, string value)
=> new(key, value);
public void SetValue(string value)
=> Value = value;
private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
}

View File

@@ -0,0 +1,15 @@
namespace RecNet.Domain.Exceptions;
public class DomainException : Exception
{
public DomainException()
{ }
public DomainException(string message)
: base(message)
{ }
public DomainException(string message, Exception innerException)
: base(message, innerException)
{ }
}

View File

@@ -0,0 +1,31 @@
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.GameVersions;
public class GameVersion
{
private GameVersion()
{
}
private GameVersion(string version, bool isValid)
{
Version = RequireValue(version, nameof(version));
IsValid = isValid;
}
public string Version { get; private set; } = string.Empty;
public bool IsValid { get; private set; }
public static GameVersion Create(string version, bool isValid)
=> new(version, isValid);
public void MarkValid()
=> IsValid = true;
public void MarkInvalid()
=> IsValid = false;
private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
}

View File

@@ -0,0 +1,12 @@
namespace RecNet.Domain.GameVersions;
public interface IGameVersionRepository
{
Task<GameVersion?> GetAsync(string version, CancellationToken ct = default);
async Task<bool> IsValidAsync(string version, CancellationToken ct = default)
{
var gameVersion = await GetAsync(version, ct);
return gameVersion?.IsValid == true;
}
}

View File

@@ -0,0 +1,15 @@
using RecNet.Domain.Common;
namespace RecNet.Domain.Profiles;
public interface IProfileRepository
{
Task<Profile?> GetByIdAsync(Guid profileId, CancellationToken ct = default);
Task<IReadOnlyList<Profile>> GetByIdsAsync(List<Guid> profileIds, CancellationToken ct = default);
Task<Profile?> GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default);
Task AddAsync(Profile profile, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,288 @@
namespace RecNet.Domain.Profiles.Names;
public static class DefaultNameGenConfig
{
public static readonly NameGenConfig Value = new()
{
Adjectives =
[
"Adamant",
"Adorable",
"Adventurous",
"Agreeable",
"Aimless",
"Alert",
"Amused",
"Aromatic",
"Bashful",
"Beautiful",
"Bored",
"Brave",
"Bulbous",
"Busy",
"Calm",
"Carefree",
"Careless",
"Caring",
"Charming",
"Cheerful",
"Clever",
"Clumsy",
"Courageous",
"Cowardly",
"Cozy",
"Crabby",
"Cranky",
"Crawling",
"Creaky",
"Creative",
"Crispy",
"Decisive",
"Deep",
"Delightful",
"Determined",
"Diligent",
"Dull",
"Eager",
"Elated",
"Emotional",
"Enchanting",
"Encouraging",
"Endless",
"Energetic",
"Enthusiastic",
"Excited",
"Exuberant",
"Fair",
"Faithful",
"Fantastic",
"Fastidious",
"Fine",
"Fluttering",
"Fragrant",
"Friendly",
"Fulsome",
"Funny",
"Fussy",
"Fuzzy",
"Generous",
"Gentle",
"Glassy",
"Gloomy",
"Glorious",
"Radiating",
"Glowing",
"Good",
"Grand",
"Great",
"Greedy",
"Grimy",
"Happy",
"Hardworking",
"Hasty",
"Healthy",
"Heavy",
"Helpful",
"Hilarious",
"Hopeful",
"Icy",
"Important",
"Inquisitive",
"Jolly",
"Joyful",
"Joyous",
"Kind",
"Lazy",
"Lively",
"Loud",
"Lovely",
"Loyal",
"Lucky",
"Luminous",
"Lumpy",
"Majestic",
"Meek",
"Melodic",
"Mighty",
"Moody",
"Nice",
"Nimble",
"Odd",
"Optimistic",
"Perfect",
"Pervasive",
"Pleasant",
"Plucky",
"Plush",
"Polite",
"Practical",
"Proud",
"Quick",
"Quiet",
"Rapid",
"Redolent",
"Reliable",
"Relieved",
"Royal",
"Rusty",
"Scared",
"Selfish",
"Sensible",
"Sensitive",
"Shining",
"Shrill",
"Silly",
"Sincere",
"Sizzling",
"Sleepy",
"Smiling",
"Smooth",
"Snug",
"Soaring",
"Sparkling",
"Speedy",
"Spiky",
"Splendid",
"Spoiled",
"Steaming",
"Still",
"Strict",
"Stuffed",
"Sturdy",
"Successful",
"Surprised",
"Swift",
"Taciturn",
"Tense",
"Thankful",
"Thoughtful",
"Thrifty",
"Tough",
"Tricky",
"Truthful",
"Ubiquitous",
"Unusual",
"Versatile",
"Victorious",
"Wild",
"Wise",
"Witty",
"Wonderful",
"Worried",
"Wrinkly",
"Zany",
"Zealous"
],
Nouns =
[
"Aardvark",
"Alpaca",
"Ant",
"Armadillo",
"Badger",
"Bat",
"Bear",
"Bee",
"Buffalo",
"Butterfly",
"Capybara",
"Cat",
"Caterpillar",
"Chameleon",
"Cheetah",
"Chicken",
"Chimpanzee",
"Cobra",
"Coyote",
"Crane",
"Cricket",
"Crow",
"Deer",
"Dog",
"Dolphin",
"Donkey",
"Dove",
"Duck",
"Eagle",
"Echidna",
"Elephant",
"Elk",
"Emu",
"Ferret",
"Flamingo",
"Fish",
"Fox",
"Frog",
"Gazelle",
"Giraffe",
"Goat",
"Goose",
"Gorilla",
"Hamster",
"Hedgehog",
"Hippo",
"Horse",
"Hyena",
"Iguana",
"Jaguar",
"Jellyfish",
"Kangaroo",
"Kitten",
"Koala",
"Lemming",
"Leopard",
"Lion",
"Lizard",
"Llama",
"Marmoset",
"Monkey",
"Moose",
"Mouse",
"Mule",
"Newt",
"Octopus",
"Opposum",
"Ostrich",
"Otter",
"Owl",
"Oyster",
"Panda",
"Panther",
"Parrot",
"Penguin",
"Pig",
"Pigeon",
"Piranha",
"Platypus",
"Pony",
"Possum",
"Puppy",
"Quail",
"Rabbit",
"Raven",
"Salmon",
"Scorpion",
"Seal",
"Shark",
"Sheep",
"Sloth",
"Snail",
"Squid",
"Squirrel",
"Stork",
"Tapir",
"Tiger",
"Tortoise",
"Tuna",
"Turtle",
"Urchin",
"Viper",
"Vulture",
"Walrus",
"Whale",
"Wombat",
"Yak",
"Zebra"
]
};
}

View File

@@ -0,0 +1,7 @@
namespace RecNet.Domain.Profiles.Names;
public class NameGenConfig
{
public required List<string> Adjectives { get; set; }
public required List<string> Nouns { get; set; }
}

View File

@@ -0,0 +1,86 @@
using RecNet.Domain.Common;
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.Profiles;
public class Profile
{
public const int MinNameLength = 3;
public const int MaxNameLength = 32;
private Profile(
string name,
PlatformType platform,
string platformId)
{
SetName(name);
Platform = platform;
PlatformId = RequireValue(platformId, nameof(platformId));
CreatedAt = DateTimeOffset.UtcNow;
}
public Guid ProfileId { get; } = Guid.NewGuid();
public string Name { get; private set; } = string.Empty;
public PlatformType Platform { get; private set; }
public string PlatformId { get; private set; }
public List<string> DeviceIds { get; private set; } = [];
// EZ
public bool IsBanned { get; private set; }
public bool IsModerator { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public static Profile Create(
string name,
PlatformType platform,
string platformId)
=> new(name, platform, platformId);
public void SetName(string name)
{
var requiredName = RequireValue(name, nameof(name));
if (requiredName.Length is < MinNameLength or > MaxNameLength)
throw new DomainException($"Username must be between {MinNameLength} and {MaxNameLength} characters long.");
Name = requiredName;
}
public void AddDeviceId(string deviceId)
{
deviceId = RequireValue(deviceId, nameof(deviceId));
if (DeviceIds.Contains(deviceId))
return;
DeviceIds.Add(deviceId);
}
public void RecordSuccessfulLogin(string deviceId, string? platformName)
{
if (!string.IsNullOrWhiteSpace(platformName) && Name != platformName)
SetName(platformName);
AddDeviceId(deviceId);
}
public void Ban()
=> IsBanned = true;
public void Unban()
=> IsBanned = false;
public void GrantModerator()
=> IsModerator = true;
public void RevokeModerator()
=> IsModerator = false;
private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
}

View File

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

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
using RecNet.Infrastructure.Persistence;
using RecNet.Infrastructure.Persistence.Repositories;
using RecNet.Infrastructure.Services.Configuration;
using RecNet.Infrastructure.Services.Meta;
using RecNet.Infrastructure.Services.Steam;
using RecNet.Infrastructure.Services.Tokens;
namespace RecNet.Infrastructure;
public static class DependencyInjection
{
public static TBuilder AddInfrastructure<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.AddNpgsqlDbContext<DatabaseContext>("recnet");
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(options => !string.IsNullOrWhiteSpace(options.Secret), "JWT secret is required.")
.ValidateOnStart();
builder.Services
.AddOptions<SteamOptions>()
.Bind(builder.Configuration.GetSection("Steam"));
builder.Services.AddHttpClient<ISteamAuthService, SteamAuthService>(client =>
{
client.BaseAddress = new Uri("https://api.steampowered.com/");
});
builder.Services.AddScoped<IPlatformAuthValidator, SteamAuthValidator>();
builder.Services.AddScoped<IPlatformAuthValidator, MetaAuthValidator>();
builder.Services.AddScoped<IGameVersionRepository, GameVersionRepository>();
builder.Services.AddScoped<IProfileRepository, ProfileRepository>();
builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>();
builder.Services.AddScoped<IConfigService, ConfigService>();
builder.Services.AddScoped<ITokenService, TokenService>();
return builder;
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.GameVersions;
namespace RecNet.Infrastructure.Persistence.Configurations;
public class GameVersionConfiguration : IEntityTypeConfiguration<GameVersion>
{
public void Configure(EntityTypeBuilder<GameVersion> builder)
{
builder.HasKey(x => x.Version);
builder.Property(x => x.Version)
.HasMaxLength(32)
.IsRequired();
builder.Property(x => x.IsValid)
.IsRequired();
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence.Configurations;
public class ProfileConfiguration : IEntityTypeConfiguration<Profile>
{
public void Configure(EntityTypeBuilder<Profile> builder)
{
builder.HasKey(x => x.ProfileId);
builder.Property(x => x.Name)
.HasMaxLength(Profile.MaxNameLength)
.IsRequired();
builder.Property(x => x.Platform)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(x => x.PlatformId)
.HasMaxLength(50)
.IsRequired();
builder.Property(x => x.DeviceIds)
.HasColumnType("jsonb");
builder.Property(x => x.CreatedAt)
.IsRequired();
builder.HasIndex(x => new { x.Platform, x.PlatformId })
.IsUnique();
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Persistence.Configurations;
public class ServerConfigConfiguration : IEntityTypeConfiguration<ServerConfig>
{
public void Configure(EntityTypeBuilder<ServerConfig> builder)
{
builder.HasKey(x => x.Key);
builder.Property(x => x.Key)
.HasMaxLength(128)
.IsRequired();
builder.Property(x => x.Value)
.HasColumnType("jsonb")
.IsRequired();
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options)
{
public DbSet<Profile> Profiles => Set<Profile>();
public DbSet<GameVersion> GameVersions => Set<GameVersion>();
public DbSet<ServerConfig> ServerConfigs => Set<ServerConfig>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(DatabaseContext).Assembly);
}
}

View File

@@ -0,0 +1,66 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260618180145_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Profiles",
columns: table => new
{
ProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Platform = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PlatformId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
DeviceIds = table.Column<string>(type: "jsonb", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Profiles", x => x.ProfileId);
});
migrationBuilder.CreateIndex(
name: "IX_Profiles_Platform_PlatformId",
table: "Profiles",
columns: new[] { "Platform", "PlatformId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Profiles");
}
}
}

View File

@@ -0,0 +1,81 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260618183916_ServerConfigs")]
partial class ServerConfigs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Entities.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ServerConfigs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ServerConfigs",
columns: table => new
{
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerConfigs", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServerConfigs");
}
}
}

View File

@@ -0,0 +1,87 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260619025601_ProfileToggles")]
partial class ProfileToggles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Entities.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ProfileToggles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
table: "Profiles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsModerator",
table: "Profiles",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBanned",
table: "Profiles");
migrationBuilder.DropColumn(
name: "IsModerator",
table: "Profiles");
}
}
}

View File

@@ -0,0 +1,101 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260619201140_GameVersion")]
partial class GameVersion
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsValid")
.HasColumnType("boolean");
b.HasKey("Version");
b.ToTable("GameVersions");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GameVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GameVersions",
columns: table => new
{
Version = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
IsValid = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GameVersions", x => x.Version);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GameVersions");
}
}
}

View File

@@ -0,0 +1,98 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
partial class DatabaseContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsValid")
.HasColumnType("boolean");
b.HasKey("Version");
b.ToTable("GameVersions");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.GameVersions;
namespace RecNet.Infrastructure.Persistence.Repositories;
public class GameVersionRepository(DatabaseContext dbContext) : IGameVersionRepository
{
public Task<GameVersion?> GetAsync(string version, CancellationToken ct = default)
=> dbContext.GameVersions
.FirstOrDefaultAsync(x => x.Version == version, ct);
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.Common;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence.Repositories;
public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository
{
public Task<Profile?> GetByIdAsync(
Guid profileId,
CancellationToken ct = default)
=> dbContext.Profiles
.FirstOrDefaultAsync(x => x.ProfileId == profileId, ct);
public async Task<IReadOnlyList<Profile>> GetByIdsAsync(
List<Guid> profileIds,
CancellationToken ct = default)
=> await dbContext.Profiles
.Where(x => profileIds.Contains(x.ProfileId))
.ToListAsync(ct);
public Task<Profile?> GetByPlatform(
PlatformType platform,
string platformId,
CancellationToken ct = default)
=> dbContext.Profiles
.FirstOrDefaultAsync(x =>
x.Platform == platform &&
x.PlatformId == platformId,
ct);
public async Task AddAsync(
Profile profile,
CancellationToken ct = default)
=> await dbContext.Profiles.AddAsync(profile, ct);
public Task SaveChangesAsync(CancellationToken ct = default)
=> dbContext.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Persistence.Repositories;
public class ServerConfigRepository(DatabaseContext dbContext) : IServerConfigRepository
{
public Task<ServerConfig?> GetAsync(string key, CancellationToken ct = default)
=> dbContext.ServerConfigs
.FirstOrDefaultAsync(x => x.Key == key, ct);
public async Task SetAsync(string key, string value, CancellationToken ct = default)
{
var config = await GetAsync(key, ct);
if (config is null)
{
config = ServerConfig.Create(key, value);
await dbContext.ServerConfigs.AddAsync(config, ct);
return;
}
config.SetValue(value);
}
public Task SaveChangesAsync(CancellationToken ct = default)
=> dbContext.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="SteamApi.Models" Version="1.1.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RecNet.Application\RecNet.Application.csproj" />
<ProjectReference Include="..\RecNet.Domain\RecNet.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Persistence\Migrations\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Services.Configuration;
public class ConfigService(IServerConfigRepository repository) : IConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
var config = await repository.GetAsync(key, ct);
if (config == null)
return default;
return JsonSerializer.Deserialize<T>(config.Value, JsonOptions);
}
public async Task<T> GetAsync<T>(
string key,
T defaultValue,
CancellationToken ct = default)
{
var config = await repository.GetAsync(key, ct);
if (config == null)
return defaultValue;
return JsonSerializer.Deserialize<T>(config.Value, JsonOptions) ?? defaultValue;
}
public async Task SetAsync<T>(
string key,
T value,
CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
await repository.SetAsync(key, json, ct);
await repository.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,15 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.PlatformAuth;
using RecNet.Domain.Common;
namespace RecNet.Infrastructure.Services.Meta;
public class MetaAuthValidator : IPlatformAuthValidator
{
public PlatformType PlatformType
=> PlatformType.Meta;
public Task<PlatformAuthResult> ValidateAsync(string platformAuthentication, string platformId,
CancellationToken ct = default)
=> Task.FromResult(PlatformAuthResult.Success(null)); // TODO: Implement
}

View File

@@ -0,0 +1,89 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.Steam;
using SteamApi.Models.Steam.Player;
using SteamApi.Models.Steam.Responses;
namespace RecNet.Infrastructure.Services.Steam;
public class SteamAuthService(
HttpClient httpClient,
IOptions<SteamOptions> options) : ISteamAuthService
{
private readonly SteamOptions _options = options.Value;
public async Task<SteamAuthResult> AuthorizeAsync(
string ticket,
uint appId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(ticket))
return SteamAuthResult.Failure();
if (appId != _options.AppId)
return SteamAuthResult.Failure();
var steamId = await AuthenticateTicketAsync(ticket, ct);
if (steamId is null)
return SteamAuthResult.Failure();
var profile = await GetProfileAsync(steamId, ct);
return SteamAuthResult.Success(
steamId,
profile?.PersonaName);
}
private async Task<string?> AuthenticateTicketAsync(
string ticket,
CancellationToken ct)
{
var url =
"ISteamUserAuth/AuthenticateUserTicket/v0001/" +
$"?key={Uri.EscapeDataString(_options.ApiKey)}" +
$"&appid={_options.AppId}" +
$"&ticket={Uri.EscapeDataString(ticket)}";
var response = await httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
return null;
var body = await response.Content.ReadFromJsonAsync<SteamResponse<SteamAuthResponse>>(
cancellationToken: ct);
return body?.Response?.Params?.SteamId;
}
private async Task<PlayerSummary?> GetProfileAsync(
string steamId,
CancellationToken ct)
{
var url =
"ISteamUser/GetPlayerSummaries/v0002/" +
$"?key={Uri.EscapeDataString(_options.ApiKey)}" +
$"&steamids={Uri.EscapeDataString(steamId)}";
var response = await httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
return null;
var body = await response.Content.ReadFromJsonAsync<SteamResponse<PlayerSummariesResponse>>(
cancellationToken: ct);
return body?.Response?.Players.FirstOrDefault();
}
private class SteamAuthResponse
{
[JsonPropertyName("params")]
public SteamAuthParamsResponse? Params { get; set; }
}
private class SteamAuthParamsResponse
{
[JsonPropertyName("steamid")]
public required string SteamId { get; set; }
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.PlatformAuth;
using RecNet.Domain.Common;
namespace RecNet.Infrastructure.Services.Steam;
public class SteamAuthValidator(ISteamAuthService steamAuthService) : IPlatformAuthValidator
{
public PlatformType PlatformType
=> PlatformType.Steamworks;
public async Task<PlatformAuthResult> ValidateAsync(
string platformAuthentication,
string platformId,
CancellationToken ct = default)
{
SteamPlatformAuth? steamParams;
try
{
steamParams = JsonSerializer.Deserialize<SteamPlatformAuth>(platformAuthentication);
}
catch (JsonException)
{
return PlatformAuthResult.Failure("Invalid authentication");
}
if (steamParams?.AppId is null || string.IsNullOrWhiteSpace(steamParams.Ticket))
return PlatformAuthResult.Failure("Invalid authentication");
var result = await steamAuthService.AuthorizeAsync(steamParams.Ticket, steamParams.AppId.AppId, ct);
if (!result.Succeeded || result.SteamId is null)
return PlatformAuthResult.Failure("Invalid authentication");
if (!string.IsNullOrWhiteSpace(platformId) &&
platformId != result.SteamId)
return PlatformAuthResult.Failure("Nice try");
return PlatformAuthResult.Success(result.DisplayName);
}
private sealed class SteamPlatformAuth
{
public required string Ticket { get; set; }
public required SteamAppIdAuth AppId { get; set; }
}
// Splooty, why did you set it up like this ;-;
private sealed class SteamAppIdAuth
{
[JsonPropertyName("m_AppId")]
public required uint AppId { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
namespace RecNet.Infrastructure.Services.Steam;
public class SteamOptions
{
public string ApiKey { get; set; } = string.Empty;
public uint AppId { get; set; } = 480; // Spacewar
public string Identity { get; set; } = "recnet";
}

View File

@@ -0,0 +1,9 @@
namespace RecNet.Infrastructure.Services.Tokens;
public class JwtOptions
{
public string Secret { get; set; } = string.Empty;
public string Issuer { get; set; } = "http://localhost:5155";
public string Audience { get; set; } = "TenWholeYears";
public int ExpiryMinutes { get; set; } = 60;
}

View File

@@ -0,0 +1,96 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.Tokens;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Services.Tokens;
public class TokenService(IOptions<JwtOptions> options) : ITokenService
{
public TokenResult GenerateProfileToken(Profile profile)
{
var jwtOptions = options.Value;
if (string.IsNullOrWhiteSpace(jwtOptions.Secret))
throw new InvalidOperationException("JWT secret is not configured.");
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(jwtOptions.ExpiryMinutes);
var signingCredentials = new SigningCredentials(
CreateSecurityKey(jwtOptions),
SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, profile.ProfileId.ToString()),
new("rn.plat", ((int)profile.Platform).ToString()),
new("rn.platid", profile.PlatformId)
};
if (profile.IsModerator)
claims.Add(new Claim("role", "moderator"));
var token = new JwtSecurityToken(
issuer: jwtOptions.Issuer,
audience: jwtOptions.Audience,
claims: claims,
expires: expiresAt.UtcDateTime,
signingCredentials: signingCredentials);
return new TokenResult(
new JwtSecurityTokenHandler().WriteToken(token),
jwtOptions.ExpiryMinutes * 60);
}
public TokenVerifyResult VerifyToken(string token)
{
var jwtOptions = options.Value;
if (string.IsNullOrWhiteSpace(jwtOptions.Secret))
throw new InvalidOperationException("JWT secret is not configured.");
if (string.IsNullOrWhiteSpace(token))
return TokenVerifyResult.Failure();
var tokenHandler = new JwtSecurityTokenHandler();
if (!tokenHandler.CanReadToken(token))
return TokenVerifyResult.Failure();
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = CreateSecurityKey(jwtOptions),
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
try
{
var principal = tokenHandler.ValidateToken(token, validationParameters, out _);
var profileIdClaim =
principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ??
principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(profileIdClaim, out var profileId)
? TokenVerifyResult.Success(profileId)
: TokenVerifyResult.Failure();
}
catch (SecurityTokenException)
{
return TokenVerifyResult.Failure();
}
catch (ArgumentException)
{
return TokenVerifyResult.Failure();
}
}
private static SymmetricSecurityKey CreateSecurityKey(JwtOptions jwtOptions)
=> new(Encoding.UTF8.GetBytes(jwtOptions.Secret));
}

View File

@@ -0,0 +1,16 @@
using RecNet.MigrationService;
using RecNet.Infrastructure.Persistence;
using RecNet.ServiceDefaults;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddHostedService<Worker>();
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName));
builder.AddNpgsqlDbContext<DatabaseContext>("recnet");
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"RecNet.MigrationService": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-RecNet.MigrationService-78833cdb-5790-420f-94d2-bec6860bfe4c</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RecNet.Infrastructure\RecNet.Infrastructure.csproj" />
<ProjectReference Include="..\RecNet.ServiceDefaults\RecNet.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
using RecNet.Infrastructure.Persistence;
namespace RecNet.MigrationService;
public class Worker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
{
public const string ActivitySourceName = "Migrations";
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
protected override async Task ExecuteAsync(
CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity(
"Migrating database", ActivityKind.Client);
try
{
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
await RunMigrationAsync(dbContext, cancellationToken);
}
catch (Exception ex)
{
activity?.AddException(ex);
throw;
}
hostApplicationLifetime.StopApplication();
}
private static async Task RunMigrationAsync(
DatabaseContext dbContext, CancellationToken cancellationToken)
{
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Run migration in a transaction to avoid partial migration if it fails.
await dbContext.Database.MigrateAsync(cancellationToken);
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace RecNet.ServiceDefaults;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
// Exclude health check requests from tracing
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1"/>
</ItemGroup>
</Project>