Refactored project to module structure
This commit is contained in:
128
Moonlight.Api/Admin/Setup/SetupController.cs
Normal file
128
Moonlight.Api/Admin/Setup/SetupController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Admin.Sys.Settings;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Setup;
|
||||
|
||||
namespace Moonlight.Api.Admin.Setup;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/setup")]
|
||||
public class SetupController : Controller
|
||||
{
|
||||
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
|
||||
private readonly DatabaseRepository<Role> RolesRepository;
|
||||
private readonly SettingsService SettingsService;
|
||||
private readonly DatabaseRepository<User> UsersRepository;
|
||||
|
||||
public SetupController(
|
||||
SettingsService settingsService,
|
||||
DatabaseRepository<User> usersRepository,
|
||||
DatabaseRepository<Role> rolesRepository
|
||||
)
|
||||
{
|
||||
SettingsService = settingsService;
|
||||
UsersRepository = usersRepository;
|
||||
RolesRepository = rolesRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetSetupAsync()
|
||||
{
|
||||
var hasBeenSetup = await SettingsService.GetValueAsync<bool>(StateSettingsKey);
|
||||
|
||||
if (hasBeenSetup)
|
||||
return Problem("This instance is already configured", statusCode: 405);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> ApplySetupAsync([FromBody] ApplySetupDto dto)
|
||||
{
|
||||
var adminRole = await RolesRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Name == "Administrators");
|
||||
|
||||
if (adminRole == null)
|
||||
adminRole = await RolesRepository.AddAsync(new Role
|
||||
{
|
||||
Name = "Administrators",
|
||||
Description = "Automatically generated group for full administrator permissions",
|
||||
Permissions =
|
||||
[
|
||||
Permissions.ApiKeys.View,
|
||||
Permissions.ApiKeys.Create,
|
||||
Permissions.ApiKeys.Edit,
|
||||
Permissions.ApiKeys.Delete,
|
||||
|
||||
Permissions.Roles.View,
|
||||
Permissions.Roles.Create,
|
||||
Permissions.Roles.Edit,
|
||||
Permissions.Roles.Delete,
|
||||
Permissions.Roles.Members,
|
||||
|
||||
Permissions.Users.View,
|
||||
Permissions.Users.Create,
|
||||
Permissions.Users.Edit,
|
||||
Permissions.Users.Delete,
|
||||
Permissions.Users.Logout,
|
||||
|
||||
Permissions.Themes.View,
|
||||
Permissions.Themes.Create,
|
||||
Permissions.Themes.Edit,
|
||||
Permissions.Themes.Delete,
|
||||
|
||||
Permissions.System.Info,
|
||||
Permissions.System.Diagnose,
|
||||
Permissions.System.Versions,
|
||||
Permissions.System.Instance
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
var user = await UsersRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(u => u.Email == dto.AdminEmail);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
await UsersRepository.AddAsync(new User
|
||||
{
|
||||
Email = dto.AdminEmail,
|
||||
Username = dto.AdminUsername,
|
||||
RoleMemberships =
|
||||
[
|
||||
new RoleMember
|
||||
{
|
||||
Role = adminRole,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
],
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
user.RoleMemberships.Add(new RoleMember
|
||||
{
|
||||
Role = adminRole,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await UsersRepository.UpdateAsync(user);
|
||||
}
|
||||
|
||||
await SettingsService.SetValueAsync(StateSettingsKey, true);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
136
Moonlight.Api/Admin/Sys/ApiKeys/ApiKeyController.cs
Normal file
136
Moonlight.Api/Admin/Sys/ApiKeys/ApiKeyController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
20
Moonlight.Api/Admin/Sys/ApiKeys/ApiKeyMapper.cs
Normal file
20
Moonlight.Api/Admin/Sys/ApiKeys/ApiKeyMapper.cs
Normal 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);
|
||||
}
|
||||
7
Moonlight.Api/Admin/Sys/ApiKeys/ApiOptions.cs
Normal file
7
Moonlight.Api/Admin/Sys/ApiKeys/ApiOptions.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
89
Moonlight.Api/Admin/Sys/ApplicationService.cs
Normal file
89
Moonlight.Api/Admin/Sys/ApplicationService.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VersionService = Moonlight.Api.Admin.Sys.Versions.VersionService;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys;
|
||||
|
||||
public class ApplicationService : IHostedService
|
||||
{
|
||||
private readonly ILogger<ApplicationService> Logger;
|
||||
private readonly VersionService VersionService;
|
||||
|
||||
public ApplicationService(VersionService versionService, ILogger<ApplicationService> logger)
|
||||
{
|
||||
VersionService = versionService;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public DateTimeOffset StartedAt { get; private set; }
|
||||
public string VersionName { get; private set; } = "N/A";
|
||||
public bool IsUpToDate { get; set; } = true;
|
||||
public string OperatingSystem { get; private set; } = "N/A";
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
OperatingSystem = OsHelper.GetName();
|
||||
|
||||
try
|
||||
{
|
||||
var currentVersion = await VersionService.GetInstanceVersionAsync();
|
||||
var latestVersion = await VersionService.GetLatestVersionAsync();
|
||||
|
||||
VersionName = currentVersion.Identifier;
|
||||
IsUpToDate = latestVersion == null || currentVersion.Identifier == latestVersion.Identifier;
|
||||
|
||||
Logger.LogInformation("Running Moonlight Panel {version} on {operatingSystem}", VersionName,
|
||||
OperatingSystem);
|
||||
|
||||
if (!IsUpToDate)
|
||||
Logger.LogWarning("Your instance is not up-to-date");
|
||||
|
||||
if (currentVersion.IsDevelopment)
|
||||
Logger.LogWarning("Your instance is running a development version");
|
||||
|
||||
if (currentVersion.IsPreRelease)
|
||||
Logger.LogWarning("Your instance is running a pre-release version");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "An unhandled exception occurred while fetching version details");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> GetMemoryUsageAsync()
|
||||
{
|
||||
using var currentProcess = Process.GetCurrentProcess();
|
||||
return Task.FromResult(currentProcess.WorkingSet64);
|
||||
}
|
||||
|
||||
public async Task<double> GetCpuUsageAsync()
|
||||
{
|
||||
using var currentProcess = Process.GetCurrentProcess();
|
||||
|
||||
// Get initial values
|
||||
var startCpuTime = currentProcess.TotalProcessorTime;
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
// Wait a bit to calculate the diff
|
||||
await Task.Delay(500);
|
||||
|
||||
// New values
|
||||
var endCpuTime = currentProcess.TotalProcessorTime;
|
||||
var endTime = DateTime.UtcNow;
|
||||
|
||||
// Calculate CPU usage
|
||||
var cpuUsedMs = (endCpuTime - startCpuTime).TotalMilliseconds;
|
||||
var totalMsPassed = (endTime - startTime).TotalMilliseconds;
|
||||
var cpuUsagePercent = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed) * 100;
|
||||
|
||||
return Math.Round(cpuUsagePercent, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.ContainerHelper;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/ch")]
|
||||
[Authorize(Policy = Permissions.System.Instance)]
|
||||
public class ContainerHelperController : Controller
|
||||
{
|
||||
private readonly ContainerHelperService ContainerHelperService;
|
||||
private readonly IOptions<ContainerHelperOptions> Options;
|
||||
|
||||
public ContainerHelperController(ContainerHelperService containerHelperService,
|
||||
IOptions<ContainerHelperOptions> options)
|
||||
{
|
||||
ContainerHelperService = containerHelperService;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<ContainerHelperStatusDto>> GetStatusAsync()
|
||||
{
|
||||
if (!Options.Value.IsEnabled)
|
||||
return new ContainerHelperStatusDto(false, false);
|
||||
|
||||
var status = await ContainerHelperService.CheckConnectionAsync();
|
||||
|
||||
return new ContainerHelperStatusDto(true, status);
|
||||
}
|
||||
|
||||
[HttpPost("rebuild")]
|
||||
public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
|
||||
{
|
||||
var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
|
||||
var mappedResult = result.Select(ContainerHelperMapper.ToDto);
|
||||
|
||||
return Task.FromResult<IResult>(
|
||||
TypedResults.ServerSentEvents(mappedResult)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("version")]
|
||||
public async Task<ActionResult> SetVersionAsync([FromBody] SetVersionDto request)
|
||||
{
|
||||
await ContainerHelperService.SetVersionAsync(request.Version);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Sys.ContainerHelper;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper;
|
||||
|
||||
[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 ContainerHelperMapper
|
||||
{
|
||||
public static partial RebuildEventDto ToDto(Models.Events.RebuildEventDto rebuildEventDto);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper;
|
||||
|
||||
public class ContainerHelperOptions
|
||||
{
|
||||
public bool IsEnabled { get; set; }
|
||||
public string Url { get; set; } = "http://helper:8080";
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Moonlight.Api.Admin.Sys.ContainerHelper.Models;
|
||||
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
|
||||
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper;
|
||||
|
||||
public class ContainerHelperService
|
||||
{
|
||||
private readonly IHttpClientFactory HttpClientFactory;
|
||||
|
||||
public ContainerHelperService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
HttpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckConnectionAsync()
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("ContainerHelper");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync("api/ping");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("ContainerHelper");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
|
||||
|
||||
request.Content = JsonContent.Create(
|
||||
new RequestRebuildDto(noBuildCache),
|
||||
null,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
yield return new RebuildEventDto
|
||||
{
|
||||
Type = RebuildEventType.Failed,
|
||||
Data = responseText
|
||||
};
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using var streamReader = new StreamReader(responseStream);
|
||||
|
||||
do
|
||||
{
|
||||
var line = await streamReader.ReadLineAsync();
|
||||
|
||||
if (line == null)
|
||||
break;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var data = line.Trim("data: ");
|
||||
var deserializedData =
|
||||
JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
|
||||
|
||||
yield return deserializedData;
|
||||
|
||||
// Exit if service will go down for a clean exit
|
||||
if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" })
|
||||
yield break;
|
||||
} while (true);
|
||||
|
||||
yield return new RebuildEventDto
|
||||
{
|
||||
Type = RebuildEventType.Succeeded,
|
||||
Data = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetVersionAsync(string version)
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("ContainerHelper");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"api/configuration/version",
|
||||
new SetVersionDto(version),
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var problemDetails =
|
||||
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.Default.Options);
|
||||
|
||||
if (problemDetails == null)
|
||||
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");
|
||||
|
||||
throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
|
||||
|
||||
public struct RebuildEventDto
|
||||
{
|
||||
[JsonPropertyName("type")] public RebuildEventType Type { get; set; }
|
||||
|
||||
[JsonPropertyName("data")] public string Data { get; set; }
|
||||
}
|
||||
|
||||
public enum RebuildEventType
|
||||
{
|
||||
Log = 0,
|
||||
Failed = 1,
|
||||
Succeeded = 2,
|
||||
Step = 3
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models;
|
||||
|
||||
public class ProblemDetails
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public Dictionary<string, string[]>? Errors { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
|
||||
|
||||
public record RequestRebuildDto(bool NoBuildCache);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
|
||||
|
||||
public record SetVersionDto(string Version);
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
|
||||
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models;
|
||||
|
||||
[JsonSerializable(typeof(SetVersionDto))]
|
||||
[JsonSerializable(typeof(ProblemDetails))]
|
||||
[JsonSerializable(typeof(RebuildEventDto))]
|
||||
[JsonSerializable(typeof(RequestRebuildDto))]
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||
public partial class SerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
30
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseController.cs
Normal file
30
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseController.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.Diagnose;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Diagnose;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = Permissions.System.Diagnose)]
|
||||
[Route("api/admin/system/diagnose")]
|
||||
public class DiagnoseController : Controller
|
||||
{
|
||||
private readonly DiagnoseService DiagnoseService;
|
||||
|
||||
public DiagnoseController(DiagnoseService diagnoseService)
|
||||
{
|
||||
DiagnoseService = diagnoseService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DiagnoseResultDto[]>> GetAsync()
|
||||
{
|
||||
var results = await DiagnoseService.DiagnoseAsync();
|
||||
|
||||
return results
|
||||
.OrderBy(x => x.Level)
|
||||
.ToDto()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
17
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseResult.cs
Normal file
17
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseResult.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Moonlight.Api.Admin.Sys.Diagnose;
|
||||
|
||||
public record DiagnoseResult(
|
||||
DiagnoseLevel Level,
|
||||
string Title,
|
||||
string[] Tags,
|
||||
string? Message,
|
||||
string? StackStrace,
|
||||
string? SolutionUrl,
|
||||
string? ReportUrl);
|
||||
|
||||
public enum DiagnoseLevel
|
||||
{
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Healthy = 2
|
||||
}
|
||||
13
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseResultMapper.cs
Normal file
13
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseResultMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Sys.Diagnose;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Diagnose;
|
||||
|
||||
[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 DiagnoseResultMapper
|
||||
{
|
||||
public static partial IEnumerable<DiagnoseResultDto> ToDto(this IEnumerable<DiagnoseResult> results);
|
||||
}
|
||||
35
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseService.cs
Normal file
35
Moonlight.Api/Admin/Sys/Diagnose/DiagnoseService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Diagnose;
|
||||
|
||||
public class DiagnoseService
|
||||
{
|
||||
private readonly ILogger<DiagnoseService> Logger;
|
||||
private readonly IEnumerable<IDiagnoseProvider> Providers;
|
||||
|
||||
public DiagnoseService(IEnumerable<IDiagnoseProvider> providers, ILogger<DiagnoseService> logger)
|
||||
{
|
||||
Providers = providers;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DiagnoseResult[]> DiagnoseAsync()
|
||||
{
|
||||
var results = new List<DiagnoseResult>();
|
||||
|
||||
foreach (var provider in Providers)
|
||||
try
|
||||
{
|
||||
results.AddRange(
|
||||
await provider.DiagnoseAsync()
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "An unhandled error occured while processing provider");
|
||||
}
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
88
Moonlight.Api/Admin/Sys/OsHelper.cs
Normal file
88
Moonlight.Api/Admin/Sys/OsHelper.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys;
|
||||
|
||||
public class OsHelper
|
||||
{
|
||||
public static string GetName()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return GetWindowsVersion();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return GetLinuxVersion();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return "Goofy OS";
|
||||
|
||||
return "Unknown OS";
|
||||
}
|
||||
|
||||
private static string GetWindowsVersion()
|
||||
{
|
||||
var version = Environment.OSVersion.Version;
|
||||
|
||||
// Windows 11 is version 10.0 build 22000+
|
||||
if (version.Major == 10 && version.Build >= 22000)
|
||||
return $"Windows 11 ({version.Build})";
|
||||
|
||||
if (version.Major == 10)
|
||||
return $"Windows 10 ({version.Build})";
|
||||
|
||||
if (version.Major == 6 && version.Minor == 3)
|
||||
return "Windows 8.1";
|
||||
|
||||
if (version.Major == 6 && version.Minor == 2)
|
||||
return "Windows 8";
|
||||
|
||||
if (version.Major == 6 && version.Minor == 1)
|
||||
return "Windows 7";
|
||||
|
||||
return $"Windows {version.Major}.{version.Minor}";
|
||||
}
|
||||
|
||||
private static string GetLinuxVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Read /etc/os-release, should work everywhere
|
||||
if (File.Exists("/etc/os-release"))
|
||||
{
|
||||
var lines = File.ReadAllLines("/etc/os-release");
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
if (line.StartsWith("NAME="))
|
||||
name = line.Substring(5).Trim('"');
|
||||
else if (line.StartsWith("VERSION_ID="))
|
||||
version = line.Substring(11).Trim('"');
|
||||
|
||||
if (!string.IsNullOrEmpty(name)) return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
|
||||
}
|
||||
|
||||
//If for some weird reason it still uses lsb release
|
||||
if (File.Exists("/etc/lsb-release"))
|
||||
{
|
||||
var lines = File.ReadAllLines("/etc/lsb-release");
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
if (line.StartsWith("DISTRIB_ID="))
|
||||
name = line.Substring(11);
|
||||
else if (line.StartsWith("DISTRIB_RELEASE="))
|
||||
version = line.Substring(16);
|
||||
|
||||
if (!string.IsNullOrEmpty(name)) return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return $"Linux {Environment.OSVersion.Version}";
|
||||
}
|
||||
}
|
||||
7
Moonlight.Api/Admin/Sys/Settings/SettingsOptions.cs
Normal file
7
Moonlight.Api/Admin/Sys/Settings/SettingsOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Sys.Settings;
|
||||
|
||||
public class SettingsOptions
|
||||
{
|
||||
public TimeSpan LookupL1CacheTime { get; set; } = TimeSpan.FromMinutes(1);
|
||||
public TimeSpan LookupL2CacheTime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
83
Moonlight.Api/Admin/Sys/Settings/SettingsService.cs
Normal file
83
Moonlight.Api/Admin/Sys/Settings/SettingsService.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Settings;
|
||||
|
||||
public class SettingsService
|
||||
{
|
||||
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly IOptions<SettingsOptions> Options;
|
||||
private readonly DatabaseRepository<SettingsOption> Repository;
|
||||
|
||||
public SettingsService(
|
||||
DatabaseRepository<SettingsOption> repository,
|
||||
IOptions<SettingsOptions> options,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
Repository = repository;
|
||||
HybridCache = hybridCache;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public async Task<T?> GetValueAsync<T>(string key)
|
||||
{
|
||||
var cacheKey = string.Format(CacheKey, key);
|
||||
|
||||
var value = await HybridCache.GetOrCreateAsync<string?>(
|
||||
cacheKey,
|
||||
async ct =>
|
||||
{
|
||||
return await Repository
|
||||
.Query()
|
||||
.Where(x => x.Key == key)
|
||||
.Select(o => o.ValueJson)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
},
|
||||
new HybridCacheEntryOptions
|
||||
{
|
||||
LocalCacheExpiration = Options.Value.LookupL1CacheTime,
|
||||
Expiration = Options.Value.LookupL2CacheTime
|
||||
}
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return default;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(value);
|
||||
}
|
||||
|
||||
public async Task SetValueAsync<T>(string key, T value)
|
||||
{
|
||||
var cacheKey = string.Format(CacheKey, key);
|
||||
|
||||
var option = await Repository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Key == key);
|
||||
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
|
||||
if (option != null)
|
||||
{
|
||||
option.ValueJson = json;
|
||||
await Repository.UpdateAsync(option);
|
||||
}
|
||||
else
|
||||
{
|
||||
option = new SettingsOption
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = json
|
||||
};
|
||||
|
||||
await Repository.AddAsync(option);
|
||||
}
|
||||
|
||||
await HybridCache.RemoveAsync(cacheKey);
|
||||
}
|
||||
}
|
||||
47
Moonlight.Api/Admin/Sys/Settings/WhiteLabelingController.cs
Normal file
47
Moonlight.Api/Admin/Sys/Settings/WhiteLabelingController.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Api.Shared.Frontend;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.Settings;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Settings;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = Permissions.System.Settings)]
|
||||
[Route("api/admin/system/settings/whiteLabeling")]
|
||||
public class WhiteLabelingController : Controller
|
||||
{
|
||||
private readonly FrontendService FrontendService;
|
||||
private readonly SettingsService SettingsService;
|
||||
|
||||
public WhiteLabelingController(SettingsService settingsService, FrontendService frontendService)
|
||||
{
|
||||
SettingsService = settingsService;
|
||||
FrontendService = frontendService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<WhiteLabelingDto>> GetAsync()
|
||||
{
|
||||
var dto = new WhiteLabelingDto
|
||||
{
|
||||
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
|
||||
};
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<WhiteLabelingDto>> PostAsync([FromBody] SetWhiteLabelingDto request)
|
||||
{
|
||||
await SettingsService.SetValueAsync(FrontendSettingConstants.Name, request.Name);
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
var dto = new WhiteLabelingDto
|
||||
{
|
||||
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
|
||||
};
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
35
Moonlight.Api/Admin/Sys/SystemController.cs
Normal file
35
Moonlight.Api/Admin/Sys/SystemController.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/system")]
|
||||
public class SystemController : Controller
|
||||
{
|
||||
private readonly ApplicationService ApplicationService;
|
||||
|
||||
public SystemController(ApplicationService applicationService)
|
||||
{
|
||||
ApplicationService = applicationService;
|
||||
}
|
||||
|
||||
[HttpGet("info")]
|
||||
[Authorize(Policy = Permissions.System.Info)]
|
||||
public async Task<ActionResult<SystemInfoDto>> GetInfoAsync()
|
||||
{
|
||||
var cpuUsage = await ApplicationService.GetCpuUsageAsync();
|
||||
var memoryUsage = await ApplicationService.GetMemoryUsageAsync();
|
||||
|
||||
return new SystemInfoDto(
|
||||
cpuUsage,
|
||||
memoryUsage,
|
||||
ApplicationService.OperatingSystem,
|
||||
DateTimeOffset.UtcNow - ApplicationService.StartedAt,
|
||||
ApplicationService.VersionName,
|
||||
ApplicationService.IsUpToDate
|
||||
);
|
||||
}
|
||||
}
|
||||
20
Moonlight.Api/Admin/Sys/Themes/ThemeMapper.cs
Normal file
20
Moonlight.Api/Admin/Sys/Themes/ThemeMapper.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared.Admin.Sys.Themes;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Themes;
|
||||
|
||||
[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 ThemeMapper
|
||||
{
|
||||
public static partial IQueryable<ThemeDto> ProjectToDto(this IQueryable<Theme> themes);
|
||||
|
||||
public static partial ThemeDto ToDto(Theme theme);
|
||||
|
||||
public static partial void Merge([MappingTarget] Theme theme, UpdateThemeDto request);
|
||||
|
||||
public static partial Theme ToEntity(CreateThemeDto request);
|
||||
}
|
||||
12
Moonlight.Api/Admin/Sys/Themes/ThemeTransferModel.cs
Normal file
12
Moonlight.Api/Admin/Sys/Themes/ThemeTransferModel.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using VYaml.Annotations;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Themes;
|
||||
|
||||
[YamlObject]
|
||||
public partial class ThemeTransferModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Version { get; set; }
|
||||
public string Author { get; set; }
|
||||
public string CssContent { get; set; }
|
||||
}
|
||||
138
Moonlight.Api/Admin/Sys/Themes/ThemesController.cs
Normal file
138
Moonlight.Api/Admin/Sys/Themes/ThemesController.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Shared.Frontend;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.Themes;
|
||||
using Moonlight.Shared.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Themes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/themes")]
|
||||
public class ThemesController : Controller
|
||||
{
|
||||
private readonly FrontendService FrontendService;
|
||||
private readonly DatabaseRepository<Theme> ThemeRepository;
|
||||
|
||||
public ThemesController(DatabaseRepository<Theme> themeRepository, FrontendService frontendService)
|
||||
{
|
||||
ThemeRepository = themeRepository;
|
||||
FrontendService = frontendService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Permissions.Themes.View)]
|
||||
public async Task<ActionResult<PagedData<ThemeDto>>> 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 = ThemeRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Theme.Name) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Name, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Theme.Version) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Version, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Theme.Author) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Author, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.OrderBy(x => x.Id)
|
||||
.ProjectToDto()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<ThemeDto>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.View)]
|
||||
public async Task<ActionResult<ThemeDto>> GetAsync([FromRoute] int id)
|
||||
{
|
||||
var item = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (item == null)
|
||||
return Problem("No theme with this id", statusCode: 404);
|
||||
|
||||
return ThemeMapper.ToDto(item);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Permissions.Themes.Create)]
|
||||
public async Task<ActionResult<ThemeDto>> CreateAsync([FromBody] CreateThemeDto request)
|
||||
{
|
||||
var theme = ThemeMapper.ToEntity(request);
|
||||
|
||||
var finalTheme = await ThemeRepository.AddAsync(theme);
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return ThemeMapper.ToDto(finalTheme);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.Edit)]
|
||||
public async Task<ActionResult<ThemeDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeDto request)
|
||||
{
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (theme == null)
|
||||
return Problem("No theme with this id found", statusCode: 404);
|
||||
|
||||
ThemeMapper.Merge(theme, request);
|
||||
await ThemeRepository.UpdateAsync(theme);
|
||||
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return ThemeMapper.ToDto(theme);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Themes.Delete)]
|
||||
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
|
||||
{
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (theme == null)
|
||||
return Problem("No theme with this id found", statusCode: 404);
|
||||
|
||||
await ThemeRepository.RemoveAsync(theme);
|
||||
|
||||
await FrontendService.ResetCacheAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
74
Moonlight.Api/Admin/Sys/Themes/TransferController.cs
Normal file
74
Moonlight.Api/Admin/Sys/Themes/TransferController.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.Themes;
|
||||
using VYaml.Serialization;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Themes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/themes")]
|
||||
[Authorize(Policy = Permissions.Themes.View)]
|
||||
public class TransferController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<Theme> ThemeRepository;
|
||||
|
||||
public TransferController(DatabaseRepository<Theme> themeRepository)
|
||||
{
|
||||
ThemeRepository = themeRepository;
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/export")]
|
||||
public async Task<ActionResult> ExportAsync([FromRoute] int id)
|
||||
{
|
||||
var theme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (theme == null)
|
||||
return Problem("No theme with that id found", statusCode: 404);
|
||||
|
||||
var yml = YamlSerializer.Serialize(new ThemeTransferModel
|
||||
{
|
||||
Name = theme.Name,
|
||||
Author = theme.Author,
|
||||
CssContent = theme.CssContent,
|
||||
Version = theme.Version
|
||||
});
|
||||
|
||||
return File(yml.ToArray(), "text/yaml", $"{theme.Name}.yml");
|
||||
}
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<ActionResult<ThemeDto>> ImportAsync()
|
||||
{
|
||||
var themeToImport = await YamlSerializer.DeserializeAsync<ThemeTransferModel>(Request.Body);
|
||||
|
||||
var existingTheme = await ThemeRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Name == themeToImport.Name && x.Author == themeToImport.Author);
|
||||
|
||||
if (existingTheme == null)
|
||||
{
|
||||
var finalTheme = await ThemeRepository.AddAsync(new Theme
|
||||
{
|
||||
Name = themeToImport.Name,
|
||||
Author = themeToImport.Author,
|
||||
CssContent = themeToImport.CssContent,
|
||||
Version = themeToImport.Version
|
||||
});
|
||||
|
||||
return ThemeMapper.ToDto(finalTheme);
|
||||
}
|
||||
|
||||
existingTheme.CssContent = themeToImport.CssContent;
|
||||
existingTheme.Version = themeToImport.Version;
|
||||
|
||||
await ThemeRepository.UpdateAsync(existingTheme);
|
||||
|
||||
return ThemeMapper.ToDto(existingTheme);
|
||||
}
|
||||
}
|
||||
7
Moonlight.Api/Admin/Sys/Versions/FrontendOptions.cs
Normal file
7
Moonlight.Api/Admin/Sys/Versions/FrontendOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
public class FrontendOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int CacheMinutes { get; set; } = 3;
|
||||
}
|
||||
12
Moonlight.Api/Admin/Sys/Versions/MoonlightVersion.cs
Normal file
12
Moonlight.Api/Admin/Sys/Versions/MoonlightVersion.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
// Notes:
|
||||
// Identifier - This needs to be the branch to clone to build this version if
|
||||
// you want to use the container helper
|
||||
|
||||
public record MoonlightVersion(
|
||||
string Identifier,
|
||||
bool IsPreRelease,
|
||||
bool IsDevelopment,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
14
Moonlight.Api/Admin/Sys/Versions/VersionMapper.cs
Normal file
14
Moonlight.Api/Admin/Sys/Versions/VersionMapper.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Sys.Versions;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
|
||||
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
|
||||
public static partial class VersionMapper
|
||||
{
|
||||
public static partial IEnumerable<VersionDto> ToDtos(IEnumerable<MoonlightVersion> versions);
|
||||
public static partial VersionDto ToDto(MoonlightVersion version);
|
||||
}
|
||||
7
Moonlight.Api/Admin/Sys/Versions/VersionOptions.cs
Normal file
7
Moonlight.Api/Admin/Sys/Versions/VersionOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
public class VersionOptions
|
||||
{
|
||||
public bool OfflineMode { get; set; }
|
||||
public string? CurrentOverride { get; set; }
|
||||
}
|
||||
135
Moonlight.Api/Admin/Sys/Versions/VersionService.cs
Normal file
135
Moonlight.Api/Admin/Sys/Versions/VersionService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
public partial class VersionService
|
||||
{
|
||||
private const string VersionPath = "/app/version";
|
||||
private const string GiteaServer = "https://git.battlestati.one";
|
||||
private const string GiteaRepository = "Moonlight-Panel/Moonlight";
|
||||
private readonly IHttpClientFactory HttpClientFactory;
|
||||
private readonly IOptions<VersionOptions> Options;
|
||||
|
||||
public VersionService(
|
||||
IOptions<VersionOptions> options,
|
||||
IHttpClientFactory httpClientFactory
|
||||
)
|
||||
{
|
||||
Options = options;
|
||||
HttpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")]
|
||||
private static partial Regex RegexFilter();
|
||||
|
||||
public async Task<MoonlightVersion[]> GetVersionsAsync()
|
||||
{
|
||||
if (Options.Value.OfflineMode)
|
||||
return [];
|
||||
|
||||
var versions = new List<MoonlightVersion>();
|
||||
var httpClient = HttpClientFactory.CreateClient();
|
||||
|
||||
// Tags
|
||||
const string tagsPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/tags";
|
||||
|
||||
await using var tagsJsonStream = await httpClient.GetStreamAsync(tagsPath);
|
||||
var tagsJson = await JsonNode.ParseAsync(tagsJsonStream);
|
||||
|
||||
if (tagsJson != null)
|
||||
foreach (var node in tagsJson.AsArray())
|
||||
{
|
||||
if (node == null)
|
||||
continue;
|
||||
|
||||
var name = node["name"]?.GetValue<string>() ?? "N/A";
|
||||
var createdAt = node["createdAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
|
||||
|
||||
if (!RegexFilter().IsMatch(name))
|
||||
continue;
|
||||
|
||||
versions.Add(new MoonlightVersion(
|
||||
name,
|
||||
name.EndsWith('b'),
|
||||
false,
|
||||
createdAt
|
||||
));
|
||||
}
|
||||
|
||||
// Branches
|
||||
const string branchesPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/branches";
|
||||
|
||||
await using var branchesJsonStream = await httpClient.GetStreamAsync(branchesPath);
|
||||
var branchesJson = await JsonNode.ParseAsync(branchesJsonStream);
|
||||
|
||||
if (branchesJson != null)
|
||||
foreach (var node in branchesJson.AsArray())
|
||||
{
|
||||
if (node == null)
|
||||
continue;
|
||||
|
||||
var name = node["name"]?.GetValue<string>() ?? "N/A";
|
||||
var commit = node["commit"];
|
||||
|
||||
if (commit == null)
|
||||
continue;
|
||||
|
||||
var createdAt = commit["timestamp"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
|
||||
|
||||
if (!RegexFilter().IsMatch(name))
|
||||
continue;
|
||||
|
||||
versions.Add(new MoonlightVersion(
|
||||
name,
|
||||
name.EndsWith('b'),
|
||||
true,
|
||||
createdAt
|
||||
));
|
||||
}
|
||||
|
||||
return versions.ToArray();
|
||||
}
|
||||
|
||||
public async Task<MoonlightVersion> GetInstanceVersionAsync()
|
||||
{
|
||||
var knownVersions = await GetVersionsAsync();
|
||||
|
||||
string versionIdentifier;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Options.Value.CurrentOverride))
|
||||
{
|
||||
versionIdentifier = Options.Value.CurrentOverride;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (File.Exists(VersionPath))
|
||||
versionIdentifier = await File.ReadAllTextAsync(VersionPath);
|
||||
else
|
||||
versionIdentifier = "unknown";
|
||||
}
|
||||
|
||||
var version = knownVersions.FirstOrDefault(x => x.Identifier == versionIdentifier);
|
||||
|
||||
if (version != null)
|
||||
return version;
|
||||
|
||||
return new MoonlightVersion(
|
||||
versionIdentifier,
|
||||
false,
|
||||
false,
|
||||
DateTimeOffset.UtcNow
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<MoonlightVersion?> GetLatestVersionAsync()
|
||||
{
|
||||
var versions = await GetVersionsAsync();
|
||||
|
||||
return versions
|
||||
.Where(x => x is { IsDevelopment: false, IsPreRelease: false })
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
44
Moonlight.Api/Admin/Sys/Versions/VersionsController.cs
Normal file
44
Moonlight.Api/Admin/Sys/Versions/VersionsController.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Sys.Versions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Sys.Versions;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/versions")]
|
||||
[Authorize(Policy = Permissions.System.Versions)]
|
||||
public class VersionsController : Controller
|
||||
{
|
||||
private readonly VersionService VersionService;
|
||||
|
||||
public VersionsController(VersionService versionService)
|
||||
{
|
||||
VersionService = versionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<VersionDto[]>> GetAsync()
|
||||
{
|
||||
var versions = await VersionService.GetVersionsAsync();
|
||||
return VersionMapper.ToDtos(versions).ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("instance")]
|
||||
public async Task<ActionResult<VersionDto>> GetInstanceAsync()
|
||||
{
|
||||
var version = await VersionService.GetInstanceVersionAsync();
|
||||
return VersionMapper.ToDto(version);
|
||||
}
|
||||
|
||||
[HttpGet("latest")]
|
||||
public async Task<ActionResult<VersionDto>> GetLatestAsync()
|
||||
{
|
||||
var version = await VersionService.GetLatestVersionAsync();
|
||||
|
||||
if (version == null)
|
||||
return Problem("Unable to retrieve latest version", statusCode: 404);
|
||||
|
||||
return VersionMapper.ToDto(version);
|
||||
}
|
||||
}
|
||||
20
Moonlight.Api/Admin/Users/Roles/RoleMapper.cs
Normal file
20
Moonlight.Api/Admin/Users/Roles/RoleMapper.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared.Admin.Users.Roles;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Roles;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
|
||||
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
|
||||
public static partial class RoleMapper
|
||||
{
|
||||
[MapProperty([nameof(Role.Members), nameof(Role.Members.Count)], nameof(RoleDto.MemberCount))]
|
||||
public static partial RoleDto ToDto(Role role);
|
||||
|
||||
public static partial Role ToEntity(CreateRoleDto request);
|
||||
public static partial void Merge([MappingTarget] Role role, UpdateRoleDto request);
|
||||
|
||||
public static partial IQueryable<RoleDto> ProjectToDto(this IQueryable<Role> roles);
|
||||
}
|
||||
165
Moonlight.Api/Admin/Users/Roles/RoleMembersController.cs
Normal file
165
Moonlight.Api/Admin/Users/Roles/RoleMembersController.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Admin.Users.Users;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Moonlight.Shared.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Roles;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = Permissions.Roles.Members)]
|
||||
[Route("api/admin/roles/{roleId:int}/members")]
|
||||
public class RoleMembersController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<RoleMember> RoleMembersRepository;
|
||||
private readonly DatabaseRepository<Role> RolesRepository;
|
||||
private readonly DatabaseRepository<User> UsersRepository;
|
||||
|
||||
public RoleMembersController(
|
||||
DatabaseRepository<User> usersRepository,
|
||||
DatabaseRepository<Role> rolesRepository,
|
||||
DatabaseRepository<RoleMember> roleMembersRepository
|
||||
)
|
||||
{
|
||||
UsersRepository = usersRepository;
|
||||
RolesRepository = rolesRepository;
|
||||
RoleMembersRepository = roleMembersRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
|
||||
[FromRoute] int roleId,
|
||||
[FromQuery] int startIndex, [FromQuery] int length,
|
||||
[FromQuery] string? searchTerm
|
||||
)
|
||||
{
|
||||
// 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 = RoleMembersRepository
|
||||
.Query()
|
||||
.Where(x => x.Role.Id == roleId)
|
||||
.Select(x => x.User);
|
||||
|
||||
// Filtering
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
|
||||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
|
||||
);
|
||||
|
||||
// Pagination
|
||||
var items = UserMapper.ProjectToDto(query
|
||||
.OrderBy(x => x.Id)
|
||||
.Skip(startIndex)
|
||||
.Take(length))
|
||||
.ToArray();
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
return new PagedData<UserDto>(items, totalCount);
|
||||
}
|
||||
|
||||
[HttpGet("available")]
|
||||
public async Task<ActionResult<PagedData<UserDto>>> GetAvailableAsync(
|
||||
[FromRoute] int roleId,
|
||||
[FromQuery] int startIndex, [FromQuery] int length,
|
||||
[FromQuery] string? searchTerm
|
||||
)
|
||||
{
|
||||
// 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 = UsersRepository
|
||||
.Query()
|
||||
.Where(x => x.RoleMemberships.All(y => y.Role.Id != roleId));
|
||||
|
||||
// Filtering
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
|
||||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
|
||||
);
|
||||
|
||||
// Pagination
|
||||
var items = UserMapper.ProjectToDto(query
|
||||
.OrderBy(x => x.Id)
|
||||
.Skip(startIndex)
|
||||
.Take(length))
|
||||
.ToArray();
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
return new PagedData<UserDto>(items, totalCount);
|
||||
}
|
||||
|
||||
[HttpPut("{userId:int}")]
|
||||
public async Task<ActionResult> AddMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
|
||||
{
|
||||
// Check and load role
|
||||
var role = await RolesRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == roleId);
|
||||
|
||||
if (role == null)
|
||||
return Problem("Role not found", statusCode: 404);
|
||||
|
||||
// Check and load user
|
||||
var user = await UsersRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
return Problem("User not found", statusCode: 404);
|
||||
|
||||
// Check if a role member already exists with these details
|
||||
var isUserInRole = await RoleMembersRepository
|
||||
.Query()
|
||||
.AnyAsync(x => x.Role.Id == roleId && x.User.Id == userId);
|
||||
|
||||
if (isUserInRole)
|
||||
return Problem("User is already a member of this role", statusCode: 400);
|
||||
|
||||
var roleMember = new RoleMember
|
||||
{
|
||||
Role = role,
|
||||
User = user
|
||||
};
|
||||
|
||||
await RoleMembersRepository.AddAsync(roleMember);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{userId:int}")]
|
||||
public async Task<ActionResult> RemoveMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
|
||||
{
|
||||
var roleMember = await RoleMembersRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.User.Id == userId && x.Role.Id == roleId);
|
||||
|
||||
if (roleMember == null)
|
||||
return Problem("User is not a member of this role, the role does not exist or the user does not exist",
|
||||
statusCode: 404);
|
||||
|
||||
await RoleMembersRepository.RemoveAsync(roleMember);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
124
Moonlight.Api/Admin/Users/Roles/RolesController.cs
Normal file
124
Moonlight.Api/Admin/Users/Roles/RolesController.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Users.Roles;
|
||||
using Moonlight.Shared.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Roles;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/roles")]
|
||||
public class RolesController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<Role> RoleRepository;
|
||||
|
||||
public RolesController(DatabaseRepository<Role> roleRepository)
|
||||
{
|
||||
RoleRepository = roleRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Permissions.Roles.View)]
|
||||
public async Task<ActionResult<PagedData<RoleDto>>> 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 = RoleRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Role.Name) =>
|
||||
query.Where(role => EF.Functions.ILike(role.Name, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.OrderBy(x => x.Id)
|
||||
.ProjectToDto()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<RoleDto>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Roles.View)]
|
||||
public async Task<ActionResult<RoleDto>> GetAsync([FromRoute] int id)
|
||||
{
|
||||
var role = await RoleRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (role == null)
|
||||
return Problem("No role with this id found", statusCode: 404);
|
||||
|
||||
return RoleMapper.ToDto(role);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Permissions.Roles.Create)]
|
||||
public async Task<ActionResult<RoleDto>> CreateAsync([FromBody] CreateRoleDto request)
|
||||
{
|
||||
var role = RoleMapper.ToEntity(request);
|
||||
|
||||
var finalRole = await RoleRepository.AddAsync(role);
|
||||
|
||||
return RoleMapper.ToDto(finalRole);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Roles.Edit)]
|
||||
public async Task<ActionResult<RoleDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleDto request)
|
||||
{
|
||||
var role = await RoleRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (role == null)
|
||||
return Problem("No role with this id found", statusCode: 404);
|
||||
|
||||
RoleMapper.Merge(role, request);
|
||||
|
||||
await RoleRepository.UpdateAsync(role);
|
||||
|
||||
return RoleMapper.ToDto(role);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Roles.Delete)]
|
||||
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
|
||||
{
|
||||
var role = await RoleRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (role == null)
|
||||
return Problem("No role with this id found", statusCode: 404);
|
||||
|
||||
await RoleRepository.RemoveAsync(role);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
155
Moonlight.Api/Admin/Users/Users/UserAuthService.cs
Normal file
155
Moonlight.Api/Admin/Users/Users/UserAuthService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.Security.Claims;
|
||||
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.Api.Infrastructure.Hooks;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserAuthService
|
||||
{
|
||||
private const string UserIdClaim = "UserId";
|
||||
private const string IssuedAtClaim = "IssuedAt";
|
||||
|
||||
public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
|
||||
private readonly IEnumerable<IUserAuthHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly ILogger<UserAuthService> Logger;
|
||||
private readonly IOptions<UserOptions> Options;
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
|
||||
public UserAuthService(
|
||||
DatabaseRepository<User> userRepository,
|
||||
ILogger<UserAuthService> logger,
|
||||
IOptions<UserOptions> options,
|
||||
IEnumerable<IUserAuthHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
Logger = logger;
|
||||
Options = options;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
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()
|
||||
.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())
|
||||
]);
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
// Run every hook, and if any returns false, we return false as well
|
||||
if (!await hook.SyncAsync(principal, user))
|
||||
return false;
|
||||
|
||||
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 cacheKey = string.Format(CacheKeyPattern, userId);
|
||||
|
||||
var user = await HybridCache.GetOrCreateAsync<UserSession?>(
|
||||
cacheKey,
|
||||
async ct =>
|
||||
{
|
||||
return await UserRepository
|
||||
.Query()
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new UserSession(
|
||||
u.InvalidateTimestamp,
|
||||
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
|
||||
)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
},
|
||||
new HybridCacheEntryOptions
|
||||
{
|
||||
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
|
||||
Expiration = Options.Value.ValidationCacheL2Expiry
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
if (issuedAt < user.InvalidateTimestamp)
|
||||
return false;
|
||||
|
||||
// Load every permission as claim
|
||||
principal.Identities.First().AddClaims(
|
||||
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
|
||||
);
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
// Run every hook, and if any returns false we return false as well
|
||||
if (!await hook.ValidateAsync(principal, userId))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// A small model which contains data queried per session validation after the defined cache time.
|
||||
// Used for projection
|
||||
private record UserSession(DateTimeOffset InvalidateTimestamp, string[] Permissions);
|
||||
}
|
||||
52
Moonlight.Api/Admin/Users/Users/UserDeletionController.cs
Normal file
52
Moonlight.Api/Admin/Users/Users/UserDeletionController.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
[Authorize(Policy = Permissions.Users.Delete)]
|
||||
public class UserDeletionController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
private readonly UserDeletionService UserDeletionService;
|
||||
|
||||
public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository<User> repository)
|
||||
{
|
||||
UserDeletionService = userDeletionService;
|
||||
Repository = repository;
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
|
||||
{
|
||||
var userExists = await Repository
|
||||
.Query()
|
||||
.AnyAsync(user => user.Id == id);
|
||||
|
||||
if (!userExists)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
var validationResult = await UserDeletionService.ValidateAsync(id);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
return ValidationProblem(
|
||||
new ValidationProblemDetails(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
{
|
||||
string.Empty,
|
||||
validationResult.ErrorMessages.ToArray()
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
await UserDeletionService.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
66
Moonlight.Api/Admin/Users/Users/UserDeletionService.cs
Normal file
66
Moonlight.Api/Admin/Users/Users/UserDeletionService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserDeletionService
|
||||
{
|
||||
private readonly IEnumerable<IUserDeletionHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
|
||||
public UserDeletionService(
|
||||
DatabaseRepository<User> repository,
|
||||
IEnumerable<IUserDeletionHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
Repository = repository;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
|
||||
{
|
||||
var user = await Repository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
throw new AggregateException($"User with id {userId} not found");
|
||||
|
||||
var errorMessages = new List<string>();
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
{
|
||||
if (await hook.ValidateAsync(user, errorMessages))
|
||||
continue;
|
||||
|
||||
return new UserDeletionValidationResult(false, errorMessages);
|
||||
}
|
||||
|
||||
return new UserDeletionValidationResult(true, []);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int userId)
|
||||
{
|
||||
var user = await Repository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
throw new AggregateException($"User with id {userId} not found");
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
await hook.ExecuteAsync(user);
|
||||
|
||||
await Repository.RemoveAsync(user);
|
||||
|
||||
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
|
||||
}
|
||||
}
|
||||
|
||||
public record UserDeletionValidationResult(bool IsValid, IEnumerable<string> ErrorMessages);
|
||||
40
Moonlight.Api/Admin/Users/Users/UserLogoutController.cs
Normal file
40
Moonlight.Api/Admin/Users/Users/UserLogoutController.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/users/{id:int}/logout")]
|
||||
[Authorize(Policy = Permissions.Users.Logout)]
|
||||
public class UserLogoutController : Controller
|
||||
{
|
||||
private readonly UserLogoutService LogoutService;
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
|
||||
public UserLogoutController(
|
||||
UserLogoutService logoutService,
|
||||
DatabaseRepository<User> repository
|
||||
)
|
||||
{
|
||||
LogoutService = logoutService;
|
||||
Repository = repository;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
|
||||
{
|
||||
var userExists = await Repository
|
||||
.Query()
|
||||
.AnyAsync(user => user.Id == id);
|
||||
|
||||
if (!userExists)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
await LogoutService.LogoutAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
43
Moonlight.Api/Admin/Users/Users/UserLogoutService.cs
Normal file
43
Moonlight.Api/Admin/Users/Users/UserLogoutService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserLogoutService
|
||||
{
|
||||
private readonly IEnumerable<IUserLogoutHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
|
||||
public UserLogoutService(
|
||||
DatabaseRepository<User> repository,
|
||||
IEnumerable<IUserLogoutHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
Repository = repository;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(int userId)
|
||||
{
|
||||
var user = await Repository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
throw new AggregateException($"User with id {userId} not found");
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
await hook.ExecuteAsync(user);
|
||||
|
||||
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
|
||||
await Repository.UpdateAsync(user);
|
||||
|
||||
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
|
||||
}
|
||||
}
|
||||
20
Moonlight.Api/Admin/Users/Users/UserMapper.cs
Normal file
20
Moonlight.Api/Admin/Users/Users/UserMapper.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[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<UserDto> ProjectToDto(this IQueryable<User> users);
|
||||
|
||||
public static partial UserDto ToDto(User user);
|
||||
|
||||
public static partial void Merge([MappingTarget] User user, UpdateUserDto request);
|
||||
|
||||
public static partial User ToEntity(CreateUserDto request);
|
||||
}
|
||||
7
Moonlight.Api/Admin/Users/Users/UserOptions.cs
Normal file
7
Moonlight.Api/Admin/Users/Users/UserOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserOptions
|
||||
{
|
||||
public TimeSpan ValidationCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan ValidationCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
|
||||
}
|
||||
113
Moonlight.Api/Admin/Users/Users/UsersController.cs
Normal file
113
Moonlight.Api/Admin/Users/Users/UsersController.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Moonlight.Shared.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
|
||||
public UsersController(DatabaseRepository<User> userRepository)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Permissions.Users.View)]
|
||||
public async Task<ActionResult<PagedData<UserDto>>> 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 = UserRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Infrastructure.Database.Entities.User.Email) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Infrastructure.Database.Entities.User.Username) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.OrderBy(x => x.Id)
|
||||
.ProjectToDto()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<UserDto>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Users.View)]
|
||||
public async Task<ActionResult<UserDto>> 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.ToDto(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Permissions.Users.Create)]
|
||||
public async Task<ActionResult<UserDto>> CreateAsync([FromBody] CreateUserDto request)
|
||||
{
|
||||
var user = UserMapper.ToEntity(request);
|
||||
user.InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
|
||||
var finalUser = await UserRepository.AddAsync(user);
|
||||
|
||||
return UserMapper.ToDto(finalUser);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Users.Edit)]
|
||||
public async Task<ActionResult<UserDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto 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.ToDto(user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user