Refactored project to module structure

This commit is contained in:
2026-03-12 22:50:15 +01:00
parent 93de9c5d00
commit 1257e8b950
219 changed files with 1231 additions and 1259 deletions

View File

@@ -0,0 +1,136 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared;
using Moonlight.Shared.Admin.Sys.ApiKeys;
using Moonlight.Shared.Shared;
namespace Moonlight.Api.Admin.Sys.ApiKeys;
[Authorize]
[ApiController]
[Route("api/admin/apiKeys")]
public class ApiKeyController : Controller
{
private readonly HybridCache HybridCache;
private readonly DatabaseRepository<ApiKey> KeyRepository;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository, HybridCache hybridCache)
{
KeyRepository = keyRepository;
HybridCache = hybridCache;
}
[HttpGet]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<PagedData<ApiKeyDto>>> 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");
// Query building
var query = KeyRepository.Query();
// Filters
if (filterOptions != null)
foreach (var filterOption in filterOptions.Filters)
query = filterOption.Key switch
{
nameof(ApiKey.Name) =>
query.Where(k => EF.Functions.ILike(k.Name, $"%{filterOption.Value}%")),
nameof(ApiKey.Description) =>
query.Where(k => EF.Functions.ILike(k.Description, $"%{filterOption.Value}%")),
_ => query
};
// Pagination
var data = await query
.OrderBy(k => k.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<ApiKeyDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<ApiKeyDto>> GetAsync([FromRoute] int id)
{
var key = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (key == null)
return Problem("No API key with this id found", statusCode: 404);
return ApiKeyMapper.ToDto(key);
}
[HttpPost]
[Authorize(Policy = Permissions.ApiKeys.Create)]
public async Task<ActionResult<ApiKeyDto>> CreateAsync([FromBody] CreateApiKeyDto request)
{
var apiKey = ApiKeyMapper.ToEntity(request);
apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32);
var finalKey = await KeyRepository.AddAsync(apiKey);
return ApiKeyMapper.ToDto(finalKey);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Edit)]
public async Task<ActionResult<ApiKeyDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyDto request)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return ApiKeyMapper.ToDto(apiKey);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return NoContent();
}
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared.Admin.Sys.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Admin.Sys.ApiKeys;
[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 ApiKeyMapper
{
public static partial IQueryable<ApiKeyDto> ProjectToDto(this IQueryable<ApiKey> apiKeys);
public static partial ApiKeyDto ToDto(ApiKey apiKey);
public static partial void Merge([MappingTarget] ApiKey apiKey, UpdateApiKeyDto request);
public static partial ApiKey ToEntity(CreateApiKeyDto request);
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Admin.Sys.ApiKeys;
public class ApiOptions
{
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -0,0 +1,78 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared;
namespace Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly HybridCache HybridCache;
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository,
HybridCache hybridCache
) : base(options, logger, encoder)
{
ApiKeyRepository = apiKeyRepository;
HybridCache = hybridCache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authHeaderValue = Request.Headers.Authorization.FirstOrDefault() ?? null;
if (string.IsNullOrWhiteSpace(authHeaderValue))
return AuthenticateResult.NoResult();
if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified");
var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
var apiKey = await HybridCache.GetOrCreateAsync<ApiKeySession?>(
cacheKey,
async ct =>
{
return await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync(ct);
},
new HybridCacheEntryOptions
{
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
return AuthenticateResult.Fail("Api key expired");
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(
apiKey.Permissions.Select(x => new Claim(Permissions.ClaimType, x)).ToArray()
)
),
Scheme.Name
));
}
private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authentication;
namespace Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public TimeSpan LookupL1CacheTime { get; set; }
public TimeSpan LookupL2CacheTime { get; set; }
}