Recreated solution with web app template. Improved theme. Switched to ShadcnBlazor library
This commit is contained in:
10
Moonlight.Api/Configuration/DatabaseOptions.cs
Normal file
10
Moonlight.Api/Configuration/DatabaseOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Moonlight.Api.Configuration;
|
||||
|
||||
public class DatabaseOptions
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public int Port { get; set; } = 5432;
|
||||
public string Username { get; set; }
|
||||
public string Password { get; set; }
|
||||
public string Database { get; set; }
|
||||
}
|
||||
11
Moonlight.Api/Configuration/OidcOptions.cs
Normal file
11
Moonlight.Api/Configuration/OidcOptions.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Moonlight.Api.Configuration;
|
||||
|
||||
public class OidcOptions
|
||||
{
|
||||
public string Authority { get; set; }
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public string ResponseType { get; set; } = "code";
|
||||
public string[]? Scopes { get; set; }
|
||||
public string ClientId { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
}
|
||||
32
Moonlight.Api/Database/DataContext.cs
Normal file
32
Moonlight.Api/Database/DataContext.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Api.Configuration;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
|
||||
namespace Moonlight.Api.Database;
|
||||
|
||||
public class DataContext : DbContext
|
||||
{
|
||||
public DbSet<User> Users { get; set; }
|
||||
|
||||
private readonly IOptions<DatabaseOptions> Options;
|
||||
|
||||
public DataContext(IOptions<DatabaseOptions> options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
if (optionsBuilder.IsConfigured)
|
||||
return;
|
||||
|
||||
optionsBuilder.UseNpgsql(
|
||||
$"Host={Options.Value.Host};" +
|
||||
$"Port={Options.Value.Port};" +
|
||||
$"Username={Options.Value.Username};" +
|
||||
$"Password={Options.Value.Password};" +
|
||||
$"Database={Options.Value.Database}"
|
||||
);
|
||||
}
|
||||
}
|
||||
36
Moonlight.Api/Database/DatabaseRepository.cs
Normal file
36
Moonlight.Api/Database/DatabaseRepository.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Moonlight.Api.Database;
|
||||
|
||||
public class DatabaseRepository<T> where T : class
|
||||
{
|
||||
private readonly DataContext DataContext;
|
||||
private readonly DbSet<T> Set;
|
||||
|
||||
public DatabaseRepository(DataContext dataContext)
|
||||
{
|
||||
DataContext = dataContext;
|
||||
Set = DataContext.Set<T>();
|
||||
}
|
||||
|
||||
public IQueryable<T> Query() => Set;
|
||||
|
||||
public async Task<T> AddAsync(T entity)
|
||||
{
|
||||
var final = Set.Add(entity);
|
||||
await DataContext.SaveChangesAsync();
|
||||
return final.Entity;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(T entity)
|
||||
{
|
||||
Set.Update(entity);
|
||||
await DataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(T entity)
|
||||
{
|
||||
Set.Remove(entity);
|
||||
await DataContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
11
Moonlight.Api/Database/Entities/User.cs
Normal file
11
Moonlight.Api/Database/Entities/User.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Moonlight.Api.Database.Entities;
|
||||
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Username { get; set; }
|
||||
public string Email { get; set; }
|
||||
|
||||
public DateTimeOffset InvalidateTimestamp { get; set; }
|
||||
}
|
||||
55
Moonlight.Api/Database/Migrations/20251216083232_AddedUsers.Designer.cs
generated
Normal file
55
Moonlight.Api/Database/Migrations/20251216083232_AddedUsers.Designer.cs
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
// <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 Moonlight.Api.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.Api.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20251216083232_AddedUsers")]
|
||||
partial class AddedUsers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("InvalidateTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.Api.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedUsers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy",
|
||||
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Username = table.Column<string>(type: "text", nullable: false),
|
||||
Email = table.Column<string>(type: "text", nullable: false),
|
||||
InvalidateTimestamp =
|
||||
table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table => { table.PrimaryKey("PK_Users", x => x.Id); });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// <auto-generated />
|
||||
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Moonlight.Api.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.Api.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
partial class DataContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("InvalidateTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Moonlight.Api/Helpers/AppConsoleFormatter.cs
Normal file
77
Moonlight.Api/Helpers/AppConsoleFormatter.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
namespace Moonlight.Api.Helpers;
|
||||
|
||||
public class AppConsoleFormatter : ConsoleFormatter
|
||||
{
|
||||
private const string TimestampColor = "\e[38;2;148;148;148m";
|
||||
private const string CategoryColor = "\e[38;2;198;198;198m";
|
||||
private const string MessageColor = "\e[38;2;255;255;255m";
|
||||
private const string Bold = "\e[1m";
|
||||
|
||||
// Pre-computed ANSI color codes for each log level
|
||||
private const string CriticalColor = "\e[38;2;255;0;0m";
|
||||
private const string ErrorColor = "\e[38;2;255;0;0m";
|
||||
private const string WarningColor = "\e[38;2;215;215;0m";
|
||||
private const string InfoColor = "\e[38;2;135;215;255m";
|
||||
private const string DebugColor = "\e[38;2;198;198;198m";
|
||||
private const string TraceColor = "\e[38;2;68;68;68m";
|
||||
|
||||
public AppConsoleFormatter() : base(nameof(AppConsoleFormatter))
|
||||
{
|
||||
}
|
||||
|
||||
public override void Write<TState>(
|
||||
in LogEntry<TState> logEntry,
|
||||
IExternalScopeProvider? scopeProvider,
|
||||
TextWriter textWriter)
|
||||
{
|
||||
var message = logEntry.Formatter(logEntry.State, logEntry.Exception);
|
||||
|
||||
// Timestamp
|
||||
textWriter.Write(TimestampColor);
|
||||
textWriter.Write(DateTime.Now.ToString("dd.MM.yy HH:mm:ss"));
|
||||
textWriter.Write(' ');
|
||||
|
||||
// Log level with color and bold
|
||||
var (levelText, levelColor) = GetLevelInfo(logEntry.LogLevel);
|
||||
textWriter.Write(levelColor);
|
||||
textWriter.Write(Bold);
|
||||
textWriter.Write(levelText);
|
||||
textWriter.Write(' ');
|
||||
|
||||
// Category
|
||||
textWriter.Write(CategoryColor);
|
||||
textWriter.Write(logEntry.Category);
|
||||
|
||||
// Message
|
||||
textWriter.Write(MessageColor);
|
||||
textWriter.Write(": ");
|
||||
textWriter.Write(message);
|
||||
|
||||
// Exception
|
||||
if (logEntry.Exception != null)
|
||||
{
|
||||
textWriter.Write(MessageColor);
|
||||
textWriter.WriteLine(logEntry.Exception.ToString());
|
||||
}
|
||||
else
|
||||
textWriter.WriteLine();
|
||||
}
|
||||
|
||||
private static (string text, string color) GetLevelInfo(LogLevel logLevel)
|
||||
{
|
||||
return logLevel switch
|
||||
{
|
||||
LogLevel.Critical => ("CRIT", CriticalColor),
|
||||
LogLevel.Error => ("ERRO", ErrorColor),
|
||||
LogLevel.Warning => ("WARN", WarningColor),
|
||||
LogLevel.Information => ("INFO", InfoColor),
|
||||
LogLevel.Debug => ("DEBG", DebugColor),
|
||||
LogLevel.Trace => ("TRCE", TraceColor),
|
||||
_ => ("NONE", "")
|
||||
};
|
||||
}
|
||||
}
|
||||
65
Moonlight.Api/Http/Controllers/AuthController.cs
Normal file
65
Moonlight.Api/Http/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
namespace Moonlight.Api.Http.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : Controller
|
||||
{
|
||||
private readonly IAuthenticationSchemeProvider SchemeProvider;
|
||||
|
||||
public AuthController(IAuthenticationSchemeProvider schemeProvider)
|
||||
{
|
||||
SchemeProvider = schemeProvider;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<SchemeResponse[]>> GetSchemesAsync()
|
||||
{
|
||||
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
||||
|
||||
return schemes
|
||||
.Where(scheme => !string.IsNullOrWhiteSpace(scheme.DisplayName))
|
||||
.Select(scheme => new SchemeResponse(scheme.Name, scheme.DisplayName!))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("{schemeName:alpha}")]
|
||||
public async Task<ActionResult> StartAsync([FromRoute] string schemeName)
|
||||
{
|
||||
var scheme = await SchemeProvider.GetSchemeAsync(schemeName);
|
||||
|
||||
if (scheme == null || string.IsNullOrWhiteSpace(scheme.DisplayName))
|
||||
return Problem("Invalid authentication scheme name", statusCode: 400);
|
||||
|
||||
return Challenge(new AuthenticationProperties()
|
||||
{
|
||||
RedirectUri = "/"
|
||||
}, scheme.Name);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("claims")]
|
||||
public Task<ActionResult<ClaimResponse[]>> GetClaimsAsync()
|
||||
{
|
||||
var result = User.Claims
|
||||
.Select(claim => new ClaimResponse(claim.Type, claim.Value))
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<ActionResult<ClaimResponse[]>>(result);
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public Task<ActionResult> LogoutAsync()
|
||||
{
|
||||
return Task.FromResult<ActionResult>(
|
||||
SignOut(new AuthenticationProperties()
|
||||
{
|
||||
RedirectUri = "/"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
126
Moonlight.Api/Http/Controllers/UsersController.cs
Normal file
126
Moonlight.Api/Http/Controllers/UsersController.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Shared.Http.Requests;
|
||||
using Moonlight.Shared.Http.Requests.Users;
|
||||
using Moonlight.Shared.Http.Responses;
|
||||
using Moonlight.Shared.Http.Responses.Users;
|
||||
using Moonlight.Api.Database;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
using Moonlight.Api.Mappers;
|
||||
|
||||
namespace Moonlight.Api.Http.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
|
||||
public UsersController(DatabaseRepository<User> userRepository)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedData<UserResponse>>> GetAsync(
|
||||
[FromQuery] int startIndex,
|
||||
[FromQuery] int length,
|
||||
[FromQuery] FilterOptions? filterOptions
|
||||
)
|
||||
{
|
||||
// Validation
|
||||
if (startIndex < 0)
|
||||
return Problem("Invalid start index specified", statusCode: 400);
|
||||
|
||||
if (length is < 1 or > 100)
|
||||
return Problem("Invalid length specified");
|
||||
|
||||
var query = UserRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
{
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
{
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Database.Entities.User.Email) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Database.Entities.User.Username) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.ProjectToResponse()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<UserResponse>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<UserResponse>> GetAsync([FromRoute] int id)
|
||||
{
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
return UserMapper.MapToResponse(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserResponse>> CreateAsync([FromBody] CreateUserRequest request)
|
||||
{
|
||||
var user = UserMapper.MapToUser(request);
|
||||
user.InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
|
||||
var finalUser = await UserRepository.AddAsync(user);
|
||||
|
||||
return UserMapper.MapToResponse(finalUser);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
public async Task<ActionResult<UserResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
UserMapper.Merge(user, request);
|
||||
await UserRepository.UpdateAsync(user);
|
||||
|
||||
return UserMapper.MapToResponse(user);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
|
||||
{
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(user => user.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
await UserRepository.RemoveAsync(user);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
21
Moonlight.Api/Mappers/UserMapper.cs
Normal file
21
Moonlight.Api/Mappers/UserMapper.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
using Moonlight.Shared.Http.Requests.Users;
|
||||
using Moonlight.Shared.Http.Responses.Users;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
|
||||
namespace Moonlight.Api.Mappers;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class UserMapper
|
||||
{
|
||||
public static partial IQueryable<UserResponse> ProjectToResponse(this IQueryable<User> users);
|
||||
|
||||
public static partial UserResponse MapToResponse(User user);
|
||||
|
||||
public static partial void Merge([MappingTarget] User user, UpdateUserRequest request);
|
||||
|
||||
public static partial User MapToUser(CreateUserRequest request);
|
||||
}
|
||||
31
Moonlight.Api/Moonlight.Api.csproj
Normal file
31
Moonlight.Api/Moonlight.Api.csproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Database\Migrations\"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="@(Content)">
|
||||
<Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
14
Moonlight.Api/Properties/launchSettings.json
Normal file
14
Moonlight.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5185",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Moonlight.Api/Services/DbMigrationService.cs
Normal file
49
Moonlight.Api/Services/DbMigrationService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moonlight.Api.Database;
|
||||
|
||||
namespace Moonlight.Api.Services;
|
||||
|
||||
public class DbMigrationService : IHostedLifecycleService
|
||||
{
|
||||
private readonly ILogger<DbMigrationService> Logger;
|
||||
private readonly IServiceProvider ServiceProvider;
|
||||
|
||||
public DbMigrationService(ILogger<DbMigrationService> logger, IServiceProvider serviceProvider)
|
||||
{
|
||||
Logger = logger;
|
||||
ServiceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public async Task StartingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.LogTrace("Checking for pending migrations");
|
||||
|
||||
await using var scope = ServiceProvider.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
var migrationNames = pendingMigrations.ToArray();
|
||||
|
||||
if (migrationNames.Length == 0)
|
||||
{
|
||||
Logger.LogDebug("No pending migrations found");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("Pending migrations: {names}", string.Join(", ", migrationNames));
|
||||
Logger.LogInformation("Migration started");
|
||||
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
Logger.LogInformation("Migration complete");
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
99
Moonlight.Api/Services/UserAuthService.cs
Normal file
99
Moonlight.Api/Services/UserAuthService.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moonlight.Api.Database;
|
||||
using Moonlight.Api.Database.Entities;
|
||||
|
||||
namespace Moonlight.Api.Services;
|
||||
|
||||
public class UserAuthService
|
||||
{
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
private readonly ILogger<UserAuthService> Logger;
|
||||
|
||||
private const string UserIdClaim = "UserId";
|
||||
private const string IssuedAtClaim = "IssuedAt";
|
||||
|
||||
public UserAuthService(DatabaseRepository<User> userRepository, ILogger<UserAuthService> logger)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
return false;
|
||||
|
||||
var username = principal.FindFirstValue(ClaimTypes.Name);
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
Logger.LogWarning("Unable to sync user to database as name and/or email claims are missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
// We use email as the primary identifier here
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(user => user.Email == email);
|
||||
|
||||
if (user == null) // Sync user if not already existing in the database
|
||||
{
|
||||
user = await UserRepository.AddAsync(new User()
|
||||
{
|
||||
Username = username,
|
||||
Email = email,
|
||||
InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
});
|
||||
}
|
||||
else // Update properties of existing user
|
||||
{
|
||||
user.Username = username;
|
||||
|
||||
await UserRepository.UpdateAsync(user);
|
||||
}
|
||||
|
||||
principal.Identities.First().AddClaims([
|
||||
new Claim(UserIdClaim, user.Id.ToString()),
|
||||
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(ClaimsPrincipal? principal)
|
||||
{
|
||||
// Ignore malformed claims principal
|
||||
if (principal is not { Identity.IsAuthenticated: true })
|
||||
return false;
|
||||
|
||||
var userIdString = principal.FindFirstValue(UserIdClaim);
|
||||
|
||||
if (!int.TryParse(userIdString, out var userId))
|
||||
return false;
|
||||
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(user => user.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
|
||||
|
||||
if (!long.TryParse(issuedAtString, out var issuedAtUnix))
|
||||
return false;
|
||||
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime();
|
||||
|
||||
// If the issued at timestamp is greater than the token validation timestamp
|
||||
// everything is fine. If not it means that the token should be invalidated
|
||||
// as it is too old
|
||||
|
||||
return issuedAt > user.InvalidateTimestamp;
|
||||
}
|
||||
}
|
||||
10
Moonlight.Api/Startup/IAppStartup.cs
Normal file
10
Moonlight.Api/Startup/IAppStartup.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
public interface IAppStartup
|
||||
{
|
||||
public void PreBuild(WebApplicationBuilder builder);
|
||||
public void PostBuild(WebApplication application);
|
||||
public void PostMiddleware(WebApplication application);
|
||||
}
|
||||
91
Moonlight.Api/Startup/Startup.Auth.cs
Normal file
91
Moonlight.Api/Startup/Startup.Auth.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moonlight.Api.Configuration;
|
||||
using Moonlight.Api.Services;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
public partial class Startup
|
||||
{
|
||||
private static void AddAuth(WebApplicationBuilder builder)
|
||||
{
|
||||
var oidcOptions = new OidcOptions();
|
||||
builder.Configuration.GetSection("WebApp:Oidc").Bind(oidcOptions);
|
||||
|
||||
builder.Services.AddScoped<UserAuthService>();
|
||||
|
||||
builder.Services.AddAuthentication("Session")
|
||||
.AddCookie("Session", null, options =>
|
||||
{
|
||||
options.Events.OnSigningIn += async context =>
|
||||
{
|
||||
var authService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<UserAuthService>();
|
||||
|
||||
var result = await authService.SyncAsync(context.Principal);
|
||||
|
||||
if (result)
|
||||
context.Properties.IsPersistent = true;
|
||||
else
|
||||
context.Principal = new ClaimsPrincipal();
|
||||
};
|
||||
|
||||
options.Events.OnValidatePrincipal += async context =>
|
||||
{
|
||||
var authService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<UserAuthService>();
|
||||
|
||||
var result = await authService.ValidateAsync(context.Principal);
|
||||
|
||||
if (!result)
|
||||
context.RejectPrincipal();
|
||||
};
|
||||
|
||||
options.Cookie = new CookieBuilder()
|
||||
{
|
||||
Name = "token",
|
||||
Path = "/",
|
||||
IsEssential = true,
|
||||
SecurePolicy = CookieSecurePolicy.SameAsRequest
|
||||
};
|
||||
})
|
||||
.AddOpenIdConnect("OIDC", "OpenID Connect", options =>
|
||||
{
|
||||
options.Authority = oidcOptions.Authority;
|
||||
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
|
||||
|
||||
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
|
||||
|
||||
options.Scope.Clear();
|
||||
|
||||
foreach (var scope in scopes)
|
||||
options.Scope.Add(scope);
|
||||
|
||||
options.ResponseType = oidcOptions.ResponseType;
|
||||
options.ClientId = oidcOptions.ClientId;
|
||||
options.ClientSecret = oidcOptions.ClientSecret;
|
||||
|
||||
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
|
||||
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username");
|
||||
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
|
||||
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
}
|
||||
|
||||
private static void UseAuth(WebApplication application)
|
||||
{
|
||||
application.UseAuthentication();
|
||||
application.UseAuthorization();
|
||||
}
|
||||
}
|
||||
36
Moonlight.Api/Startup/Startup.Base.cs
Normal file
36
Moonlight.Api/Startup/Startup.Base.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
using Moonlight.Shared.Http;
|
||||
using Moonlight.Api.Helpers;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
public partial class Startup
|
||||
{
|
||||
private static void AddBase(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
|
||||
});
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
|
||||
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
|
||||
}
|
||||
|
||||
private static void UseBase(WebApplication application)
|
||||
{
|
||||
|
||||
application.UseRouting();
|
||||
}
|
||||
|
||||
private static void MapBase(WebApplication application)
|
||||
{
|
||||
application.MapControllers();
|
||||
|
||||
application.MapFallbackToFile("index.html");
|
||||
}
|
||||
}
|
||||
19
Moonlight.Api/Startup/Startup.Database.cs
Normal file
19
Moonlight.Api/Startup/Startup.Database.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moonlight.Api.Configuration;
|
||||
using Moonlight.Api.Database;
|
||||
using Moonlight.Api.Services;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
public partial class Startup
|
||||
{
|
||||
private static void AddDatabase(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddOptions<DatabaseOptions>().BindConfiguration("WebApp:Database");
|
||||
|
||||
builder.Services.AddDbContext<DataContext>();
|
||||
builder.Services.AddScoped(typeof(DatabaseRepository<>));
|
||||
builder.Services.AddHostedService<DbMigrationService>();
|
||||
}
|
||||
}
|
||||
24
Moonlight.Api/Startup/Startup.cs
Normal file
24
Moonlight.Api/Startup/Startup.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Moonlight.Api.Startup;
|
||||
|
||||
public partial class Startup : IAppStartup
|
||||
{
|
||||
public void PreBuild(WebApplicationBuilder builder)
|
||||
{
|
||||
AddBase(builder);
|
||||
AddAuth(builder);
|
||||
AddDatabase(builder);
|
||||
}
|
||||
|
||||
public void PostBuild(WebApplication application)
|
||||
{
|
||||
UseBase(application);
|
||||
UseAuth(application);
|
||||
}
|
||||
|
||||
public void PostMiddleware(WebApplication application)
|
||||
{
|
||||
MapBase(application);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user