diff --git a/Moonlight.Api/Configuration/VersionOptions.cs b/Moonlight.Api/Configuration/VersionOptions.cs new file mode 100644 index 00000000..341e2e85 --- /dev/null +++ b/Moonlight.Api/Configuration/VersionOptions.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Api.Configuration; + +public class VersionOptions +{ + public bool OfflineMode { get; set; } + public string? CurrentOverride { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/VersionsController.cs b/Moonlight.Api/Http/Controllers/Admin/VersionsController.cs new file mode 100644 index 00000000..bfa2f488 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/VersionsController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Moonlight.Api.Mappers; +using Moonlight.Api.Services; +using Moonlight.Shared; +using Moonlight.Shared.Http.Responses.Admin; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[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> GetAsync() + { + var versions = await VersionService.GetVersionsAsync(); + return VersionMapper.ToDtos(versions).ToArray(); + } + + [HttpGet("instance")] + public async Task> GetInstanceAsync() + { + var version = await VersionService.GetInstanceVersionAsync(); + return VersionMapper.ToDto(version); + } + + [HttpGet("latest")] + public async Task> GetLatestAsync() + { + var version = await VersionService.GetLatestVersionAsync(); + + if(version == null) + return Problem("Unable to retrieve latest version", statusCode: 404); + + return VersionMapper.ToDto(version); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/VersionMapper.cs b/Moonlight.Api/Mappers/VersionMapper.cs new file mode 100644 index 00000000..1477b1c3 --- /dev/null +++ b/Moonlight.Api/Mappers/VersionMapper.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Api.Models; +using Moonlight.Shared.Http.Responses.Admin; +using Riok.Mapperly.Abstractions; + +namespace Moonlight.Api.Mappers; + +[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 ToDtos(IEnumerable versions); + public static partial VersionDto ToDto(MoonlightVersion version); +} \ No newline at end of file diff --git a/Moonlight.Api/Models/MoonlightVersion.cs b/Moonlight.Api/Models/MoonlightVersion.cs new file mode 100644 index 00000000..3d76c95f --- /dev/null +++ b/Moonlight.Api/Models/MoonlightVersion.cs @@ -0,0 +1,12 @@ +namespace Moonlight.Api.Models; + +// 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 +); \ No newline at end of file diff --git a/Moonlight.Api/Services/ApplicationService.cs b/Moonlight.Api/Services/ApplicationService.cs index 3a002ab1..ce99cbdc 100644 --- a/Moonlight.Api/Services/ApplicationService.cs +++ b/Moonlight.Api/Services/ApplicationService.cs @@ -1,26 +1,36 @@ using System.Diagnostics; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Moonlight.Api.Helpers; namespace Moonlight.Api.Services; -public class ApplicationService : IHostedLifecycleService +public class ApplicationService : IHostedService { + private readonly VersionService VersionService; + private readonly ILogger 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 ApplicationService(VersionService versionService, ILogger logger) + { + VersionService = versionService; + Logger = logger; + } + public Task GetMemoryUsageAsync() { using var currentProcess = Process.GetCurrentProcess(); return Task.FromResult(currentProcess.WorkingSet64); } - + public async Task GetCpuUsageAsync() { using var currentProcess = Process.GetCurrentProcess(); - + // Get initial values var startCpuTime = currentProcess.TotalProcessorTime; var startTime = DateTime.UtcNow; @@ -39,26 +49,37 @@ public class ApplicationService : IHostedLifecycleService return Math.Round(cpuUsagePercent, 2); } - - public async Task StartedAsync(CancellationToken cancellationToken) + + public async Task StartAsync(CancellationToken cancellationToken) { StartedAt = DateTimeOffset.UtcNow; - // TODO: Update / version check - - VersionName = "v2.1.0 (a2d4edc0e5)"; - IsUpToDate = true; - 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"); + } } - #region Unused - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - #endregion } \ No newline at end of file diff --git a/Moonlight.Api/Services/VersionService.cs b/Moonlight.Api/Services/VersionService.cs new file mode 100644 index 00000000..fd5b6d2e --- /dev/null +++ b/Moonlight.Api/Services/VersionService.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; +using Moonlight.Api.Models; + +namespace Moonlight.Api.Services; + +public partial class VersionService +{ + private readonly IOptions Options; + private readonly IHttpClientFactory HttpClientFactory; + + private const string VersionPath = "/app/version"; + private const string GiteaServer = "https://git.battlestati.one"; + private const string GiteaRepository = "Moonlight-Panel/Moonlight"; + + [GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")] + private static partial Regex RegexFilter(); + + public VersionService( + IOptions options, + IHttpClientFactory httpClientFactory + ) + { + Options = options; + HttpClientFactory = httpClientFactory; + } + + public async Task GetVersionsAsync() + { + if (Options.Value.OfflineMode) + return []; + + var versions = new List(); + 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() ?? "N/A"; + var createdAt = node["createdAt"]?.GetValue() ?? 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() ?? "N/A"; + var commit = node["commit"]; + + if (commit == null) + continue; + + var createdAt = commit["timestamp"]?.GetValue() ?? DateTimeOffset.MinValue; + + if(!RegexFilter().IsMatch(name)) + continue; + + versions.Add(new MoonlightVersion( + name, + name.EndsWith('b'), + true, + createdAt + )); + } + } + + return versions.ToArray(); + } + + public async Task 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 GetLatestVersionAsync() + { + var versions = await GetVersionsAsync(); + + return versions + .Where(x => x is { IsDevelopment: false, IsPreRelease: false }) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefault(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index 147f6a48..ebb3c95f 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -9,7 +9,7 @@ using Moonlight.Api.Helpers; using Moonlight.Api.Implementations; using Moonlight.Api.Interfaces; using Moonlight.Api.Services; -using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions; +using SessionOptions = Moonlight.Api.Configuration.SessionOptions; namespace Moonlight.Api.Startup; @@ -38,6 +38,11 @@ public partial class Startup builder.Services.AddOptions().BindConfiguration("Moonlight:Frontend"); builder.Services.AddScoped(); + + builder.Services.AddHttpClient(); + + builder.Services.AddOptions().BindConfiguration("Moonlight:Version"); + builder.Services.AddSingleton(); } private static void UseBase(WebApplication application) diff --git a/Moonlight.Frontend/Implementations/PermissionProvider.cs b/Moonlight.Frontend/Implementations/PermissionProvider.cs index 83a3a93f..c0ad6166 100644 --- a/Moonlight.Frontend/Implementations/PermissionProvider.cs +++ b/Moonlight.Frontend/Implementations/PermissionProvider.cs @@ -27,6 +27,7 @@ public sealed class PermissionProvider : IPermissionProvider new PermissionCategory("System", typeof(CogIcon), [ new Permission(Permissions.System.Info, "Info", "View system info"), new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"), + new Permission(Permissions.System.Versions, "Versions", "Look at the available versions"), ]), new PermissionCategory("API Keys", typeof(KeyIcon), [ new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"), diff --git a/Moonlight.Shared/Http/Responses/Admin/VersionDto.cs b/Moonlight.Shared/Http/Responses/Admin/VersionDto.cs new file mode 100644 index 00000000..51116d71 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/VersionDto.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Shared.Http.Responses.Admin; + +public record VersionDto(string Identifier, bool IsPreRelease, bool IsDevelopment, DateTimeOffset CreatedAt); \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SerializationContext.cs index e3739a64..e306c219 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SerializationContext.cs @@ -43,6 +43,9 @@ namespace Moonlight.Shared.Http; [JsonSerializable(typeof(UpdateThemeDto))] [JsonSerializable(typeof(PagedData))] [JsonSerializable(typeof(ThemeDto))] + +//Misc +[JsonSerializable(typeof(VersionDto))] public partial class SerializationContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Moonlight.Shared/Permissions.cs b/Moonlight.Shared/Permissions.cs index aa565ca2..87943dea 100644 --- a/Moonlight.Shared/Permissions.cs +++ b/Moonlight.Shared/Permissions.cs @@ -53,5 +53,6 @@ public static class Permissions public const string Info = $"{Prefix}{Section}.{nameof(Info)}"; public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}"; + public const string Versions = $"{Prefix}{Section}.{nameof(Versions)}"; } } \ No newline at end of file