From 3962723acba64943fdbbec1cbdfde475c2923443 Mon Sep 17 00:00:00 2001 From: Marcel Baumgartner Date: Fri, 16 Jun 2023 20:24:03 +0200 Subject: [PATCH] Implemented plugin installer --- .../ApiClients/Modrinth/ModrinthApiHelper.cs | 59 +++++++ .../ApiClients/Modrinth/ModrinthException.cs | 19 ++ .../Modrinth/Resources/Pagination.cs | 14 ++ .../ApiClients/Modrinth/Resources/Project.cs | 48 ++++++ .../ApiClients/Modrinth/Resources/Version.cs | 57 ++++++ .../Modrinth/Resources/VersionFile.cs | 21 +++ .../Addon/ServerAddonPluginService.cs | 83 +++++++++ Moonlight/Program.cs | 4 + .../ErrorBoundaries/SoftErrorBoundary.razor | 28 ++- .../Shared/Views/Server/ServerAddons.razor | 162 +++++++++++++++++- 10 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 Moonlight/App/ApiClients/Modrinth/ModrinthApiHelper.cs create mode 100644 Moonlight/App/ApiClients/Modrinth/ModrinthException.cs create mode 100644 Moonlight/App/ApiClients/Modrinth/Resources/Pagination.cs create mode 100644 Moonlight/App/ApiClients/Modrinth/Resources/Project.cs create mode 100644 Moonlight/App/ApiClients/Modrinth/Resources/Version.cs create mode 100644 Moonlight/App/ApiClients/Modrinth/Resources/VersionFile.cs create mode 100644 Moonlight/App/Services/Addon/ServerAddonPluginService.cs diff --git a/Moonlight/App/ApiClients/Modrinth/ModrinthApiHelper.cs b/Moonlight/App/ApiClients/Modrinth/ModrinthApiHelper.cs new file mode 100644 index 00000000..b354095b --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/ModrinthApiHelper.cs @@ -0,0 +1,59 @@ +using Logging.Net; +using Newtonsoft.Json; +using RestSharp; + +namespace Moonlight.App.ApiClients.Modrinth; + +public class ModrinthApiHelper +{ + private readonly RestClient Client; + + public ModrinthApiHelper() + { + Client = new(); + Client.AddDefaultParameter( + new HeaderParameter("User-Agent", "Moonlight-Panel/Moonlight (admin@endelon-hosting.de)") + ); + } + + public async Task Get(string resource) + { + var request = CreateRequest(resource); + + request.Method = Method.Get; + + var response = await Client.ExecuteAsync(request); + + if (!response.IsSuccessful) + { + if (response.StatusCode != 0) + { + throw new ModrinthException( + $"An error occured: ({response.StatusCode}) {response.Content}", + (int)response.StatusCode + ); + } + else + { + throw new Exception($"An internal error occured: {response.ErrorMessage}"); + } + } + + return JsonConvert.DeserializeObject(response.Content!)!; + } + + private RestRequest CreateRequest(string resource) + { + var url = "https://api.modrinth.com/v2/" + resource; + + var request = new RestRequest(url) + { + Timeout = 60 * 15 + }; + + request.AddHeader("Content-Type", "application/json"); + request.AddHeader("Accept", "application/json"); + + return request; + } +} \ No newline at end of file diff --git a/Moonlight/App/ApiClients/Modrinth/ModrinthException.cs b/Moonlight/App/ApiClients/Modrinth/ModrinthException.cs new file mode 100644 index 00000000..c6959359 --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/ModrinthException.cs @@ -0,0 +1,19 @@ +namespace Moonlight.App.ApiClients.Modrinth; + +public class ModrinthException : Exception +{ + public int StatusCode { get; set; } + + public ModrinthException() + { + } + + public ModrinthException(string message, int statusCode) : base(message) + { + StatusCode = statusCode; + } + + public ModrinthException(string message, Exception inner) : base(message, inner) + { + } +} \ No newline at end of file diff --git a/Moonlight/App/ApiClients/Modrinth/Resources/Pagination.cs b/Moonlight/App/ApiClients/Modrinth/Resources/Pagination.cs new file mode 100644 index 00000000..b462e5a3 --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/Resources/Pagination.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.ApiClients.Modrinth.Resources; + +public class Pagination +{ + [JsonProperty("hits")] public Project[] Hits { get; set; } + + [JsonProperty("offset")] public long Offset { get; set; } + + [JsonProperty("limit")] public long Limit { get; set; } + + [JsonProperty("total_hits")] public long TotalHits { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/ApiClients/Modrinth/Resources/Project.cs b/Moonlight/App/ApiClients/Modrinth/Resources/Project.cs new file mode 100644 index 00000000..6ae7a670 --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/Resources/Project.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.ApiClients.Modrinth.Resources; + +public class Project +{ + [JsonProperty("project_id")] public string ProjectId { get; set; } + + [JsonProperty("project_type")] public string ProjectType { get; set; } + + [JsonProperty("slug")] public string Slug { get; set; } + + [JsonProperty("author")] public string Author { get; set; } + + [JsonProperty("title")] public string Title { get; set; } + + [JsonProperty("description")] public string Description { get; set; } + + [JsonProperty("categories")] public string[] Categories { get; set; } + + [JsonProperty("display_categories")] public string[] DisplayCategories { get; set; } + + [JsonProperty("versions")] public string[] Versions { get; set; } + + [JsonProperty("downloads")] public long Downloads { get; set; } + + [JsonProperty("follows")] public long Follows { get; set; } + + [JsonProperty("icon_url")] public string IconUrl { get; set; } + + [JsonProperty("date_created")] public DateTimeOffset DateCreated { get; set; } + + [JsonProperty("date_modified")] public DateTimeOffset DateModified { get; set; } + + [JsonProperty("latest_version")] public string LatestVersion { get; set; } + + [JsonProperty("license")] public string License { get; set; } + + [JsonProperty("client_side")] public string ClientSide { get; set; } + + [JsonProperty("server_side")] public string ServerSide { get; set; } + + [JsonProperty("gallery")] public Uri[] Gallery { get; set; } + + [JsonProperty("featured_gallery")] public Uri FeaturedGallery { get; set; } + + [JsonProperty("color")] public long? Color { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/ApiClients/Modrinth/Resources/Version.cs b/Moonlight/App/ApiClients/Modrinth/Resources/Version.cs new file mode 100644 index 00000000..69c13326 --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/Resources/Version.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.ApiClients.Modrinth.Resources; + +public class Version +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("version_number")] + public string VersionNumber { get; set; } + + [JsonProperty("changelog")] + public string Changelog { get; set; } + + [JsonProperty("dependencies")] + public object[] Dependencies { get; set; } + + [JsonProperty("game_versions")] + public object[] GameVersions { get; set; } + + [JsonProperty("version_type")] + public string VersionType { get; set; } + + [JsonProperty("loaders")] + public object[] Loaders { get; set; } + + [JsonProperty("featured")] + public bool Featured { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("requested_status")] + public string RequestedStatus { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("project_id")] + public string ProjectId { get; set; } + + [JsonProperty("author_id")] + public string AuthorId { get; set; } + + [JsonProperty("date_published")] + public DateTime DatePublished { get; set; } + + [JsonProperty("downloads")] + public long Downloads { get; set; } + + [JsonProperty("changelog_url")] + public object ChangelogUrl { get; set; } + + [JsonProperty("files")] + public VersionFile[] Files { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/ApiClients/Modrinth/Resources/VersionFile.cs b/Moonlight/App/ApiClients/Modrinth/Resources/VersionFile.cs new file mode 100644 index 00000000..07ccfa63 --- /dev/null +++ b/Moonlight/App/ApiClients/Modrinth/Resources/VersionFile.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.ApiClients.Modrinth.Resources; + +public class VersionFile +{ + [JsonProperty("url")] + public Uri Url { get; set; } + + [JsonProperty("filename")] + public string Filename { get; set; } + + [JsonProperty("primary")] + public bool Primary { get; set; } + + [JsonProperty("size")] + public long Size { get; set; } + + [JsonProperty("file_type")] + public string FileType { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/Services/Addon/ServerAddonPluginService.cs b/Moonlight/App/Services/Addon/ServerAddonPluginService.cs new file mode 100644 index 00000000..dc00b72a --- /dev/null +++ b/Moonlight/App/Services/Addon/ServerAddonPluginService.cs @@ -0,0 +1,83 @@ +using Moonlight.App.ApiClients.Modrinth; +using Moonlight.App.ApiClients.Modrinth.Resources; +using Moonlight.App.Exceptions; +using FileAccess = Moonlight.App.Helpers.Files.FileAccess; +using Version = Moonlight.App.ApiClients.Modrinth.Resources.Version; + +namespace Moonlight.App.Services.Addon; + +public class ServerAddonPluginService +{ + private readonly ModrinthApiHelper ModrinthApiHelper; + private readonly ServerService ServerService; + + public ServerAddonPluginService(ModrinthApiHelper modrinthApiHelper) + { + ModrinthApiHelper = modrinthApiHelper; + } + + public async Task GetPluginsForVersion(string version, string search = "") + { + string resource; + var filter = + "[[\"categories:\'bukkit\'\",\"categories:\'paper\'\",\"categories:\'spigot\'\"],[\"versions:" + version + "\"],[\"project_type:mod\"]]"; + + if (string.IsNullOrEmpty(search)) + resource = "search?limit=21&index=relevance&facets=" + filter; + else + resource = $"search?query={search}&limit=21&index=relevance&facets=" + filter; + + var result = await ModrinthApiHelper.Get(resource); + + return result.Hits; + } + + public async Task InstallPlugin(FileAccess fileAccess, string version, Project project, Action? onStateUpdated = null) + { + // Resolve plugin download + + onStateUpdated?.Invoke($"Resolving {project.Slug}"); + + var filter = "game_versions=[\"" + version + "\"]&loaders=[\"bukkit\", \"paper\", \"spigot\"]"; + + var versions = await ModrinthApiHelper.Get( + $"project/{project.Slug}/version?" + filter); + + if (!versions.Any()) + throw new DisplayException("No plugin download for your minecraft version found"); + + var installVersion = versions.OrderByDescending(x => x.DatePublished).First(); + var fileToInstall = installVersion.Files.First(); + + // Download plugin in a stream cached mode + + var httpClient = new HttpClient(); + var stream = await httpClient.GetStreamAsync(fileToInstall.Url); + var dataStream = new MemoryStream(1024 * 1024 * 40); + await stream.CopyToAsync(dataStream); + stream.Close(); + dataStream.Position = 0; + + // Install plugin + + await fileAccess.SetDir("/"); + + try + { + await fileAccess.MkDir("plugins"); + } + catch (Exception) + { + // Ignored + } + + await fileAccess.SetDir("plugins"); + + onStateUpdated?.Invoke($"Installing {project.Slug}"); + await fileAccess.Upload(fileToInstall.Filename, dataStream); + + await dataStream.DisposeAsync(); + + //TODO: At some point of time, create a dependency resolver + } +} \ No newline at end of file diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index 5fd66bfb..c50a2560 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -5,6 +5,7 @@ using HealthChecks.UI.Client; using Logging.Net; using Moonlight.App.ApiClients.CloudPanel; using Moonlight.App.ApiClients.Daemon; +using Moonlight.App.ApiClients.Modrinth; using Moonlight.App.ApiClients.Paper; using Moonlight.App.ApiClients.Wings; using Moonlight.App.Database; @@ -18,6 +19,7 @@ using Moonlight.App.Repositories.Domains; using Moonlight.App.Repositories.LogEntries; using Moonlight.App.Repositories.Servers; using Moonlight.App.Services; +using Moonlight.App.Services.Addon; using Moonlight.App.Services.Background; using Moonlight.App.Services.DiscordBot; using Moonlight.App.Services.Files; @@ -132,6 +134,7 @@ namespace Moonlight builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -159,6 +162,7 @@ namespace Moonlight builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Background services builder.Services.AddSingleton(); diff --git a/Moonlight/Shared/Components/ErrorBoundaries/SoftErrorBoundary.razor b/Moonlight/Shared/Components/ErrorBoundaries/SoftErrorBoundary.razor index 6a417c56..b76369ad 100644 --- a/Moonlight/Shared/Components/ErrorBoundaries/SoftErrorBoundary.razor +++ b/Moonlight/Shared/Components/ErrorBoundaries/SoftErrorBoundary.razor @@ -3,10 +3,13 @@ @using Moonlight.App.Services @using Logging.Net @using Moonlight.App.ApiClients.CloudPanel +@using Moonlight.App.ApiClients.Daemon +@using Moonlight.App.ApiClients.Modrinth @using Moonlight.App.ApiClients.Wings @inherits ErrorBoundaryBase @inject AlertService AlertService +@inject ConfigService ConfigService @inject SmartTranslateService SmartTranslateService @if (Crashed) @@ -37,12 +40,14 @@ else protected override async Task OnErrorAsync(Exception exception) { - Logger.Warn(exception); + if (ConfigService.DebugMode) + { + Logger.Warn(exception); + } if (exception is DisplayException displayException) { await AlertService.Error( - SmartTranslateService.Translate("Error"), SmartTranslateService.Translate(displayException.Message) ); } @@ -56,7 +61,7 @@ else else if (exception is WingsException wingsException) { await AlertService.Error( - SmartTranslateService.Translate("Error from daemon"), + SmartTranslateService.Translate("Error from wings"), wingsException.Message ); @@ -64,6 +69,22 @@ else Logger.Warn($"Wings exception status code: {wingsException.StatusCode}"); } + else if (exception is DaemonException daemonException) + { + await AlertService.Error( + SmartTranslateService.Translate("Error from daemon"), + daemonException.Message + ); + + Logger.Warn($"Wings exception status code: {daemonException.StatusCode}"); + } + else if (exception is ModrinthException modrinthException) + { + await AlertService.Error( + SmartTranslateService.Translate("Error from modrinth"), + modrinthException.Message + ); + } else if (exception is CloudPanelException cloudPanelException) { await AlertService.Error( @@ -77,6 +98,7 @@ else } else { + Logger.Warn(exception); Crashed = true; await InvokeAsync(StateHasChanged); } diff --git a/Moonlight/Shared/Views/Server/ServerAddons.razor b/Moonlight/Shared/Views/Server/ServerAddons.razor index cb51e2a4..9b717527 100644 --- a/Moonlight/Shared/Views/Server/ServerAddons.razor +++ b/Moonlight/Shared/Views/Server/ServerAddons.razor @@ -1,10 +1,158 @@ -
-
-
-

Addons

-
- This feature is currently not available +@using Moonlight.App.Database.Entities +@using Moonlight.App.Services +@using Moonlight.App.Services.Addon +@using Moonlight.App.ApiClients.Modrinth.Resources +@using Logging.Net +@using Moonlight.App.Services.Interop + +@inject ServerAddonPluginService AddonPluginService +@inject SmartTranslateService SmartTranslateService +@inject ServerService ServerService +@inject ToastService ToastService + +@if (Tags.Contains("addon-plugins")) +{ +
+
+ + Plugins + +
+
+ + + + +
+
+
+
+ + @foreach (var pluginsPart in Plugins.Chunk(3)) + { +
+ @foreach (var plugin in pluginsPart) + { +
+
+
+ @(plugin.Title) +
+
+ @(plugin.Title) +

+ @(plugin.Description) +

+ + +
+
+
+ } +
+ } +
+
+
+} +else +{ +
+
+
+

+ Addons +

+
+ This feature is not available for @(CurrentServer.Image.Name) +
-
\ No newline at end of file +} + +@code +{ + [CascadingParameter] + public Server CurrentServer { get; set; } + + [CascadingParameter] + public string[] Tags { get; set; } + + private string PluginsSearch = ""; + private Project[] Plugins = Array.Empty(); + private LazyLoader PluginsLazyLoader; + private bool IsPluginInstalling = false; + + private async Task LoadPlugins(LazyLoader lazyLoader) + { + await lazyLoader.SetText(SmartTranslateService.Translate("Searching")); + + var version = CurrentServer.Variables.First(x => x.Key == "MINECRAFT_VERSION").Value; + + if (string.IsNullOrEmpty(version) || version == "latest") + version = "1.20.1"; // This should NOT be called at any time if all the images have the correct tags + + Plugins = await AddonPluginService.GetPluginsForVersion(version, PluginsSearch); + } + + private async Task SearchPlugins() + { + await PluginsLazyLoader.Reload(); + } + + private async Task InstallPlugin(Project project) + { + if (IsPluginInstalling) + { + await ToastService.Error( + SmartTranslateService.Translate("Please wait until the other plugin is installed")); + return; + } + + IsPluginInstalling = true; + + try + { + var fileAccess = await ServerService.CreateFileAccess(CurrentServer, null!); + + var version = CurrentServer.Variables.First(x => x.Key == "MINECRAFT_VERSION").Value; + + if (string.IsNullOrEmpty(version) || version == "latest") + version = "1.20.1"; // This should NOT be called at any time if all the images have the correct tags + + await ToastService.CreateProcessToast("pluginDownload", "Preparing"); + + await AddonPluginService.InstallPlugin(fileAccess, version, project, delegate(string s) + { + Task.Run(async () => + { + await ToastService.UpdateProcessToast("pluginDownload", s); + }); + }); + + await ToastService.Success( + SmartTranslateService.Translate("Successfully installed " + project.Slug) + ); + } + catch (Exception e) + { + Logger.Info(e.Message); + throw; + } + finally + { + IsPluginInstalling = false; + await ToastService.RemoveProcessToast("pluginDownload"); + } + } +} +