diff --git a/.gitignore b/.gitignore index e31ae9f..8d72798 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ obj/ /packages/ riderModule.iml /_ReSharper.Caches/ -release/ \ No newline at end of file +release/ +publish/ \ No newline at end of file diff --git a/Nebula.UpdateResolver/App.axaml.cs b/Nebula.UpdateResolver/App.axaml.cs index 47c2841..27f80d2 100644 --- a/Nebula.UpdateResolver/App.axaml.cs +++ b/Nebula.UpdateResolver/App.axaml.cs @@ -1,8 +1,6 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Microsoft.Extensions.DependencyInjection; -using Nebula.Shared; namespace Nebula.UpdateResolver; @@ -15,15 +13,11 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - var services = new ServiceCollection(); - services.AddServices(); - services.AddTransient(); - - var serviceProvider = services.BuildServiceProvider(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = serviceProvider.GetService(); + desktop.MainWindow = new MainWindow(); } base.OnFrameworkInitializationCompleted(); diff --git a/Nebula.UpdateResolver/Configuration/ConVar.cs b/Nebula.UpdateResolver/Configuration/ConVar.cs new file mode 100644 index 0000000..715ef23 --- /dev/null +++ b/Nebula.UpdateResolver/Configuration/ConVar.cs @@ -0,0 +1,16 @@ +using System; + +namespace Nebula.UpdateResolver.Configuration; + +public class ConVar +{ + public ConVar(string name, T? defaultValue = default) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + DefaultValue = defaultValue; + } + + public string Name { get; } + public Type Type => typeof(T); + public T? DefaultValue { get; } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/Configuration/ConVarBuilder.cs b/Nebula.UpdateResolver/Configuration/ConVarBuilder.cs new file mode 100644 index 0000000..2d82ae7 --- /dev/null +++ b/Nebula.UpdateResolver/Configuration/ConVarBuilder.cs @@ -0,0 +1,14 @@ +using System; + +namespace Nebula.UpdateResolver.Configuration; + +public static class ConVarBuilder +{ + public static ConVar Build(string name, T? defaultValue = default) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("ConVar name cannot be null or whitespace.", nameof(name)); + + return new ConVar(name, defaultValue); + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/Configuration/ConfigurationStandalone.cs b/Nebula.UpdateResolver/Configuration/ConfigurationStandalone.cs new file mode 100644 index 0000000..70218ac --- /dev/null +++ b/Nebula.UpdateResolver/Configuration/ConfigurationStandalone.cs @@ -0,0 +1,98 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.Json; + +namespace Nebula.UpdateResolver.Configuration; + +public static class ConfigurationStandalone +{ + private static FileApi _fileApi = new FileApi(Path.Join(MainWindow.RootPath, "config")); + + public static T? GetConfigValue(ConVar conVar) + { + ArgumentNullException.ThrowIfNull(conVar); + + try + { + if (_fileApi.TryOpen(GetFileName(conVar), out var stream)) + using (stream) + { + var obj = JsonSerializer.Deserialize(stream); + if (obj != null) + { + Console.WriteLine($"Successfully loaded config: {conVar.Name}"); + return obj; + } + } + } + catch (Exception e) + { + Console.WriteLine($"Error loading config for {conVar.Name}: {e.Message}"); + } + + Console.WriteLine($"Using default value for config: {conVar.Name}"); + return conVar.DefaultValue; + } + + public static bool TryGetConfigValue(ConVar conVar, + [NotNullWhen(true)] out T? value) + { + ArgumentNullException.ThrowIfNull(conVar); + value = default; + try + { + if (_fileApi.TryOpen(GetFileName(conVar), out var stream)) + using (stream) + { + var obj = JsonSerializer.Deserialize(stream); + if (obj != null) + { + Console.WriteLine($"Successfully loaded config: {conVar.Name}"); + value = obj; + return true; + } + } + } + catch (Exception e) + { + Console.WriteLine($"Error loading config for {conVar.Name}: {e.Message}"); + } + + Console.WriteLine($"Using default value for config: {conVar.Name}"); + return false; + } + + public static void SetConfigValue(ConVar conVar, T value) + { + ArgumentNullException.ThrowIfNull(conVar); + if (value == null) throw new ArgumentNullException(nameof(value)); + + if (!conVar.Type.IsInstanceOfType(value)) + { + return; + } + + try + { + var serializedData = JsonSerializer.Serialize(value); + + using var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + writer.Write(serializedData); + writer.Flush(); + stream.Seek(0, SeekOrigin.Begin); + + _fileApi.Save(GetFileName(conVar), stream); + } + catch (Exception e) + { + + } + } + + private static string GetFileName(ConVar conVar) + { + return $"{conVar.Name}.json"; + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/FileApi.cs b/Nebula.UpdateResolver/FileApi.cs new file mode 100644 index 0000000..59aa43b --- /dev/null +++ b/Nebula.UpdateResolver/FileApi.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +namespace Nebula.UpdateResolver; + +public sealed class FileApi +{ + public string RootPath; + + public FileApi(string rootPath) + { + RootPath = rootPath; + } + + public bool TryOpen(string path,[NotNullWhen(true)] out Stream? stream) + { + var fullPath = Path.Join(RootPath, path); + if (File.Exists(fullPath)) + try + { + stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return true; + } + catch + { + stream = null; + return false; + } + + stream = null; + return false; + } + + public bool Save(string path, Stream input) + { + var currPath = Path.Join(RootPath, path); + + try + { + var dirInfo = new DirectoryInfo(Path.GetDirectoryName(currPath) ?? throw new InvalidOperationException()); + if (!dirInfo.Exists) dirInfo.Create(); + + using var stream = new FileStream(currPath, FileMode.Create, FileAccess.Write, FileShare.None); + input.CopyTo(stream); + return true; + } + catch + { + return false; + } + } + + public bool Remove(string path) + { + var fullPath = Path.Join(RootPath, path); + try + { + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + return true; + } + } + catch + { + // Log exception if necessary + } + + return false; + } + + public bool Has(string path) + { + var fullPath = Path.Join(RootPath, path); + return File.Exists(fullPath); + } + + private IEnumerable GetAllFiles(){ + + if(!Directory.Exists(RootPath)) return []; + return Directory.EnumerateFiles(RootPath, "*.*", SearchOption.AllDirectories).Select(p=>p.Replace(RootPath,"").Substring(1)); + } + + public IEnumerable AllFiles => GetAllFiles(); +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/MainWindow.axaml.cs b/Nebula.UpdateResolver/MainWindow.axaml.cs index 1061834..d480ae7 100644 --- a/Nebula.UpdateResolver/MainWindow.axaml.cs +++ b/Nebula.UpdateResolver/MainWindow.axaml.cs @@ -7,75 +7,77 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; -using Nebula.Shared.FileApis; -using Nebula.Shared.FileApis.Interfaces; -using Nebula.Shared.Models; -using Nebula.Shared.Services; -using Tmds.DBus.Protocol; +using Nebula.UpdateResolver.Configuration; +using Nebula.UpdateResolver.Rest; namespace Nebula.UpdateResolver; public partial class MainWindow : Window { - private readonly ConfigurationService _configurationService; - private readonly RestService _restService; - private readonly HttpClient _httpClient = new HttpClient(); - public FileApi FileApi { get; set; } + public static readonly string RootPath = Path.Join(Environment.GetFolderPath( + Environment.SpecialFolder.ApplicationData), "Datum"); - public MainWindow(FileService fileService, ConfigurationService configurationService, RestService restService) + private readonly HttpClient _httpClient = new HttpClient(); + public readonly FileApi FileApi = new FileApi(Path.Join(RootPath,"app")); + + public MainWindow() { - _configurationService = configurationService; - _restService = restService; InitializeComponent(); - FileApi = (FileApi)fileService.CreateFileApi("app"); - Start(); } private async Task Start() { - var info = await EnsureFiles(); - Log("Downloading files..."); - - foreach (var file in info.ToDelete) + try { - Log("Deleting " + file.Path); - FileApi.Remove(file.Path); - } + var info = await EnsureFiles(); + Log("Downloading files..."); - var loadedManifest = info.FilesExist; - Save(loadedManifest); + foreach (var file in info.ToDelete) + { + Log("Deleting " + file.Path); + FileApi.Remove(file.Path); + } - var count = info.ToDownload.Count; - var resolved = 0; - - foreach (var file in info.ToDownload) - { - using var response = await _httpClient.GetAsync( - _configurationService.GetConfigValue(UpdateConVars.UpdateCacheUrl) - + "/" + file.Hash); - - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(); - FileApi.Save(file.Path, stream); - resolved++; - Log("Saving " + file.Path, (int)(resolved/(float)count*100f)); - - loadedManifest.Add(file); + var loadedManifest = info.FilesExist; Save(loadedManifest); + + var count = info.ToDownload.Count; + var resolved = 0; + + foreach (var file in info.ToDownload) + { + using var response = await _httpClient.GetAsync( + ConfigurationStandalone.GetConfigValue(UpdateConVars.UpdateCacheUrl) + + "/" + file.Hash); + + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(); + FileApi.Save(file.Path, stream); + resolved++; + Log("Saving " + file.Path, (int)(resolved / (float)count * 100f)); + + loadedManifest.Add(file); + Save(loadedManifest); + } + + Log("Download finished. Running launcher..."); + + var process = Process.Start(new ProcessStartInfo + { + FileName = "dotnet.exe", + Arguments = Path.Join(FileApi.RootPath, "Nebula.Launcher.dll"), + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8 + }); } - Log("Download finished. Running launcher..."); - - var process = Process.Start(new ProcessStartInfo + catch (Exception e) { - FileName = "dotnet.exe", - Arguments = Path.Join(FileApi.RootPath,"Nebula.Launcher.dll"), - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardOutputEncoding = Encoding.UTF8 - }); + Log("Error! " + e.Message); + } Thread.Sleep(2000); @@ -85,29 +87,29 @@ public partial class MainWindow : Window private async Task EnsureFiles() { Log("Ensuring launcher manifest..."); - var manifest = await _restService.GetAsync( - new Uri(_configurationService.GetConfigValue(UpdateConVars.UpdateCacheUrl)! + "/manifest.json"), CancellationToken.None); + var manifest = await RestStandalone.GetAsync( + new Uri(ConfigurationStandalone.GetConfigValue(UpdateConVars.UpdateCacheUrl)! + "/manifest.json"), CancellationToken.None); var toDownload = new HashSet(); var toDelete = new HashSet(); var filesExist = new HashSet(); Log("Manifest loaded!"); - if (_configurationService.TryGetConfigValue(UpdateConVars.CurrentLauncherManifest, out var currentManifest)) + if (ConfigurationStandalone.TryGetConfigValue(UpdateConVars.CurrentLauncherManifest, out var currentManifest)) { Log("Delta manifest loaded!"); foreach (var file in currentManifest.Entries) { if (!manifest.Entries.Contains(file)) - toDelete.Add(file); + toDelete.Add(EnsurePath(file)); else - filesExist.Add(file); + filesExist.Add(EnsurePath(file)); } foreach (var file in manifest.Entries) { if(!currentManifest.Entries.Contains(file)) - toDownload.Add(file); + toDownload.Add(EnsurePath(file)); } } else @@ -133,8 +135,43 @@ public partial class MainWindow : Window private void Save(HashSet entries) { - _configurationService.SetConfigValue(UpdateConVars.CurrentLauncherManifest, new LauncherManifest(entries)); + ConfigurationStandalone.SetConfigValue(UpdateConVars.CurrentLauncherManifest, new LauncherManifest(entries)); + } + + private LauncherManifestEntry EnsurePath(LauncherManifestEntry entry) + { + if(!PathValidator.IsSafePath(FileApi.RootPath, entry.Path)) + throw new ArgumentException("Path contains invalid characters. Manifest hash: " + entry.Hash); + + return entry; } } -public record struct ManifestEnsureInfo(HashSet ToDownload, HashSet ToDelete, HashSet FilesExist); \ No newline at end of file + +public static class PathValidator +{ + public static bool IsSafePath(string baseDirectory, string relativePath) + { + if (Path.IsPathRooted(relativePath)) + return false; + + var fullBase = Path.GetFullPath(baseDirectory); + + + var combinedPath = Path.Combine(fullBase, relativePath); + var fullPath = Path.GetFullPath(combinedPath); + + + if (!fullPath.StartsWith(fullBase, StringComparison.Ordinal)) + return false; + + if (File.Exists(fullPath) || Directory.Exists(fullPath)) + { + FileInfo fileInfo = new FileInfo(fullPath); + if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/ManifestEnsureInfo.cs b/Nebula.UpdateResolver/ManifestEnsureInfo.cs new file mode 100644 index 0000000..cae871b --- /dev/null +++ b/Nebula.UpdateResolver/ManifestEnsureInfo.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace Nebula.UpdateResolver; + +public record struct ManifestEnsureInfo(HashSet ToDownload, HashSet ToDelete, HashSet FilesExist); \ No newline at end of file diff --git a/Nebula.UpdateResolver/Nebula.UpdateResolver.csproj b/Nebula.UpdateResolver/Nebula.UpdateResolver.csproj index 8c95be5..e45ad42 100644 --- a/Nebula.UpdateResolver/Nebula.UpdateResolver.csproj +++ b/Nebula.UpdateResolver/Nebula.UpdateResolver.csproj @@ -6,6 +6,9 @@ true app.manifest true + true + + true @@ -22,10 +25,5 @@ None All - - - - - diff --git a/Nebula.UpdateResolver/Rest/Helper.cs b/Nebula.UpdateResolver/Rest/Helper.cs new file mode 100644 index 0000000..ae8f370 --- /dev/null +++ b/Nebula.UpdateResolver/Rest/Helper.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Nebula.UpdateResolver.Rest; + +public static class Helper +{ + public static readonly JsonSerializerOptions JsonWebOptions = new(JsonSerializerDefaults.Web); + public static void SafeOpenBrowser(string uri) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri)) + { + Console.WriteLine("Unable to parse URI in server-provided link: {Link}", uri); + return; + } + + if (parsedUri.Scheme is not ("http" or "https")) + { + Console.WriteLine("Refusing to open server-provided link {Link}, only http/https are allowed", parsedUri); + return; + } + + OpenBrowser(parsedUri.ToString()); + } + public static void OpenBrowser(string url) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + + public static async Task AsJson(this HttpContent content) where T : notnull + { + var str = await content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(str, JsonWebOptions) ?? + throw new JsonException("AsJson: did not expect null response"); + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/Rest/RestRequestException.cs b/Nebula.UpdateResolver/Rest/RestRequestException.cs new file mode 100644 index 0000000..153a043 --- /dev/null +++ b/Nebula.UpdateResolver/Rest/RestRequestException.cs @@ -0,0 +1,11 @@ +using System; +using System.Net; +using System.Net.Http; + +namespace Nebula.UpdateResolver.Rest; + +public sealed class RestRequestException(HttpContent content, HttpStatusCode statusCode) : Exception +{ + public HttpStatusCode StatusCode { get; } = statusCode; + public HttpContent Content { get; } = content; +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/Rest/RestStandalone.cs b/Nebula.UpdateResolver/Rest/RestStandalone.cs new file mode 100644 index 0000000..b283403 --- /dev/null +++ b/Nebula.UpdateResolver/Rest/RestStandalone.cs @@ -0,0 +1,77 @@ +using System; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nebula.UpdateResolver.Rest; + +public static class RestStandalone +{ + private static readonly HttpClient _client = new(); + + private static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public static async Task GetAsync(Uri uri, CancellationToken cancellationToken) where T : notnull + { + var response = await _client.GetAsync(uri, cancellationToken); + return await ReadResult(response, cancellationToken); + } + + public static async Task GetAsyncDefault(Uri uri, T defaultValue, CancellationToken cancellationToken) where T : notnull + { + try + { + return await GetAsync(uri, cancellationToken); + } + catch (Exception e) + { + return defaultValue; + } + } + + public static async Task PostAsync(T information, Uri uri, CancellationToken cancellationToken) where K : notnull + { + var json = JsonSerializer.Serialize(information, _serializerOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync(uri, content, cancellationToken); + return await ReadResult(response, cancellationToken); + } + + public static async Task PostAsync(Stream stream, Uri uri, CancellationToken cancellationToken) where T : notnull + { + using var multipartFormContent = + new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); + multipartFormContent.Add(new StreamContent(stream), "formFile", "image.png"); + var response = await _client.PostAsync(uri, multipartFormContent, cancellationToken); + return await ReadResult(response, cancellationToken); + } + + public static async Task DeleteAsync(Uri uri, CancellationToken cancellationToken) where T : notnull + { + var response = await _client.DeleteAsync(uri, cancellationToken); + return await ReadResult(response, cancellationToken); + } + + private static async Task ReadResult(HttpResponseMessage response, CancellationToken cancellationToken) where T : notnull + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (typeof(T) == typeof(string) && content is T t) + return t; + + if (response.IsSuccessStatusCode) + { + return await response.Content.AsJson(); + } + + throw new RestRequestException(response.Content, response.StatusCode); + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/UpdateCVars.cs b/Nebula.UpdateResolver/UpdateCVars.cs index c48928c..39e621d 100644 --- a/Nebula.UpdateResolver/UpdateCVars.cs +++ b/Nebula.UpdateResolver/UpdateCVars.cs @@ -1,6 +1,4 @@ -using System; -using Nebula.Shared.Models; -using Nebula.Shared.Services; +using Nebula.UpdateResolver.Configuration; namespace Nebula.UpdateResolver; diff --git a/Nebula.sln.DotSettings.user b/Nebula.sln.DotSettings.user index 5772bd1..262454c 100644 --- a/Nebula.sln.DotSettings.user +++ b/Nebula.sln.DotSettings.user @@ -1,4 +1,13 @@  + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file