From e3579312e2f7fcb605c42a55590feca4fcbf6b28 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 17 Sep 2024 14:01:08 +0200 Subject: [PATCH 01/11] Added healt checks to server. Added health command to server-util. This fixes #5560 --- .../CommandLine/ServerUtil/Commands/Health.cs | 38 +++++++++++++++++++ .../Duplicati.CommandLine.ServerUtil.csproj | 2 +- Duplicati/CommandLine/ServerUtil/Program.cs | 1 + Duplicati/WebserverCore/DuplicatiWebserver.cs | 5 +++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 Duplicati/CommandLine/ServerUtil/Commands/Health.cs diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Health.cs b/Duplicati/CommandLine/ServerUtil/Commands/Health.cs new file mode 100644 index 000000000..866e697d4 --- /dev/null +++ b/Duplicati/CommandLine/ServerUtil/Commands/Health.cs @@ -0,0 +1,38 @@ +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; + +namespace Duplicati.CommandLine.ServerUtil.Commands; + +public static class Health +{ + public static Command Create() => + new Command("health", "Checks the server health endpoint") + .WithHandler(CommandHandler.Create(async (settings) => + { + using var client = new HttpClient(new HttpClientHandler() + { + ServerCertificateCustomValidationCallback = settings.Insecure + ? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + : null + }) + { + BaseAddress = settings.HostUrl, + Timeout = TimeSpan.FromSeconds(10) + }; + + try + { + var response = await client.GetAsync("health"); + response.EnsureSuccessStatusCode(); + + Console.WriteLine("Server is healthy"); + return 0; + } + catch (HttpRequestException) + { + Console.WriteLine("Server is unhealthy"); + return 1; + } + }) + ); +} diff --git a/Duplicati/CommandLine/ServerUtil/Duplicati.CommandLine.ServerUtil.csproj b/Duplicati/CommandLine/ServerUtil/Duplicati.CommandLine.ServerUtil.csproj index 499ac9732..cebbdee7a 100644 --- a/Duplicati/CommandLine/ServerUtil/Duplicati.CommandLine.ServerUtil.csproj +++ b/Duplicati/CommandLine/ServerUtil/Duplicati.CommandLine.ServerUtil.csproj @@ -6,7 +6,7 @@ enable Duplicati.CommandLine.ServerUtil.Implementation Copyright © 2024 Team Duplicati, MIT license - Duplicati.CommandLine.ServerUtil + Duplicati.CommandLine.ServerUtil diff --git a/Duplicati/CommandLine/ServerUtil/Program.cs b/Duplicati/CommandLine/ServerUtil/Program.cs index 486790e2d..1e17bb8ab 100644 --- a/Duplicati/CommandLine/ServerUtil/Program.cs +++ b/Duplicati/CommandLine/ServerUtil/Program.cs @@ -30,6 +30,7 @@ public static class Program Logout.Create(), Import.Create(), Export.Create(), + Health.Create(), }; rootCmd = SettingsBinder.AddGlobalOptions(rootCmd); diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 0517ebca6..b000baaa3 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.OpenApi.Models; namespace Duplicati.WebserverCore; @@ -156,6 +157,9 @@ public partial class DuplicatiWebserver }; }); + builder.Services.AddHealthChecks() + .AddCheck("Basic", () => HealthCheckResult.Healthy("Service is running")); + builder.Services.AddDuplicati(connection); // Prevent logs from spamming the console, but allow enabling for debugging @@ -216,6 +220,7 @@ public partial class DuplicatiWebserver public Task Start(InitSettings settings) { + App.MapHealthChecks("/health"); App.AddEndpoints() .UseNotifications("/notifications"); From f3e2a0a9023bee6643a90465151ed2dc1aa25d97 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 18 Sep 2024 09:24:55 +0200 Subject: [PATCH 02/11] Avoid setting Thread.CurrentCulture Avoids setting deprecated `Thread.CurrentCulture` and `Thread.CurrentUICulture`, using `CultureInfo.CurrentCulture` instead. Fixed a few legacy places where incorrect string compares were used. --- Duplicati/CommandLine/CLI/Help.cs | 6 +-- .../Library/AutoUpdater/UpdaterManager.cs | 2 +- Duplicati/Library/Main/Controller.cs | 6 +-- .../Main/Operation/SystemInfoHandler.cs | 7 ++- Duplicati/Library/Main/Options.cs | 18 +++---- Duplicati/Library/Utility/Timeparser.cs | 53 +++++++++---------- Duplicati/UnitTest/UtilityTests.cs | 6 +-- 7 files changed, 47 insertions(+), 51 deletions(-) diff --git a/Duplicati/CommandLine/CLI/Help.cs b/Duplicati/CommandLine/CLI/Help.cs index e5a3e51ae..9cced02ee 100644 --- a/Duplicati/CommandLine/CLI/Help.cs +++ b/Duplicati/CommandLine/CLI/Help.cs @@ -21,11 +21,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; using Duplicati.Library.AutoUpdater; -using Duplicati.Library.Common; using FilterGroup = Duplicati.Library.Utility.FilterGroup; namespace Duplicati.CommandLine @@ -88,8 +88,8 @@ namespace Duplicati.CommandLine //Force translation off if this is from the commandline if (Program.FROM_COMMANDLINE) { - System.Threading.Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture; - System.Threading.Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.InvariantCulture; + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; } } catch diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index 5233fffcf..d46962af4 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -354,7 +354,7 @@ namespace Duplicati.Library.AutoUpdater return null; // Don't install a debug update on a release build and vice versa - if (string.Equals(SelfVersion.ReleaseType, "Debug", StringComparison.OrdinalIgnoreCase) && !string.Equals(update.ReleaseType, SelfVersion.ReleaseType, StringComparison.CurrentCultureIgnoreCase)) + if (string.Equals(SelfVersion.ReleaseType, "Debug", StringComparison.OrdinalIgnoreCase) && !string.Equals(update.ReleaseType, SelfVersion.ReleaseType, StringComparison.OrdinalIgnoreCase)) return null; ReleaseType rt; diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index 679f4be17..b6746b9b6 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -574,7 +574,7 @@ namespace Duplicati.Library.Main m_resetPriority = null; } - if(m_localeChange != null) + if (m_localeChange != null) { m_localeChange.Dispose(); m_localeChange = null; @@ -1043,7 +1043,7 @@ namespace Duplicati.Library.Main { bool found = false; foreach (string v in arg.ValidValues ?? new string[0]) - if (string.Equals(v, value, StringComparison.CurrentCultureIgnoreCase)) + if (string.Equals(v, value, StringComparison.OrdinalIgnoreCase)) { found = true; break; @@ -1061,7 +1061,7 @@ namespace Duplicati.Library.Main foreach (var flag in flags) { - if (!validFlags.Any(validFlag => string.Equals(validFlag, flag, StringComparison.CurrentCultureIgnoreCase))) + if (!validFlags.Any(validFlag => string.Equals(validFlag, flag, StringComparison.OrdinalIgnoreCase))) { validatedAllFlags = false; break; diff --git a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs index 149d4d13e..e79c20041 100644 --- a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs +++ b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs @@ -21,9 +21,8 @@ using System; using System.Collections.Generic; -using Duplicati.Library.Interface; using System.Linq; -using Duplicati.Library.Common; +using System.Globalization; namespace Duplicati.Library.Main.Operation { @@ -51,8 +50,8 @@ namespace Duplicati.Library.Main.Operation yield return string.Format("Machinename: {0}", Environment.MachineName); yield return string.Format("Processors: {0}", Environment.ProcessorCount); yield return string.Format(".Net Version: {0}", Environment.Version); - yield return string.Format("Locale: {0}, {1}, {2}", System.Threading.Thread.CurrentThread.CurrentCulture, System.Threading.Thread.CurrentThread.CurrentUICulture, System.Globalization.CultureInfo.InstalledUICulture); - yield return string.Format("Date/time strings: {0} - {1}", System.Threading.Thread.CurrentThread.CurrentCulture.DateTimeFormat.LongDatePattern, System.Threading.Thread.CurrentThread.CurrentCulture.DateTimeFormat.LongTimePattern); + yield return string.Format("Locale: {0}, {1}, {2}", CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture, CultureInfo.InstalledUICulture); + yield return string.Format("Date/time strings: {0} - {1}", CultureInfo.CurrentCulture.DateTimeFormat.LongDatePattern, CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern); yield return string.Format("Tempdir: {0}", Library.Utility.TempFolder.SystemTempPath); Type sqlite = null; diff --git a/Duplicati/Library/Main/Options.cs b/Duplicati/Library/Main/Options.cs index 1c5218ead..20747fbb9 100644 --- a/Duplicati/Library/Main/Options.cs +++ b/Duplicati/Library/Main/Options.cs @@ -612,20 +612,18 @@ namespace Duplicati.Library.Main /// /// Gets the forced locale for the current user /// - public System.Globalization.CultureInfo ForcedLocale + public CultureInfo ForcedLocale { get { if (!m_options.ContainsKey("force-locale")) - return System.Threading.Thread.CurrentThread.CurrentCulture; - else - { - var localestring = m_options["force-locale"]; - if (string.IsNullOrWhiteSpace(localestring)) - return System.Globalization.CultureInfo.InvariantCulture; - else - return new System.Globalization.CultureInfo(localestring); - } + return CultureInfo.CurrentCulture; + + var localestring = m_options["force-locale"]; + if (string.IsNullOrWhiteSpace(localestring)) + return CultureInfo.InvariantCulture; + + return new CultureInfo(localestring); } } diff --git a/Duplicati/Library/Utility/Timeparser.cs b/Duplicati/Library/Utility/Timeparser.cs index 55501331e..b73c7b9a6 100644 --- a/Duplicati/Library/Utility/Timeparser.cs +++ b/Duplicati/Library/Utility/Timeparser.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2024, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2024, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -50,7 +50,7 @@ namespace Duplicati.Library.Utility int multiplier = negate ? -1 : 1; - if (string.IsNullOrEmpty(datestring)) + if (string.IsNullOrEmpty(datestring)) return offset; if (String.Equals(datestring.Trim(), "now", StringComparison.OrdinalIgnoreCase)) @@ -59,12 +59,11 @@ namespace Duplicati.Library.Utility long l; if (long.TryParse(datestring, System.Globalization.NumberStyles.Integer, null, out l)) return offset.AddSeconds(l * multiplier); - - DateTime t; - if (DateTime.TryParse(datestring, System.Globalization.CultureInfo.CurrentUICulture, System.Globalization.DateTimeStyles.AssumeLocal, out t)) + + if (DateTime.TryParse(datestring, System.Globalization.CultureInfo.CurrentCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var t)) return t; - - if (Library.Utility.Utility.TryDeserializeDateTime(datestring, out t)) + + if (Utility.TryDeserializeDateTime(datestring, out t)) return t; char[] separators = new char[] { 's', 'm', 'h', 'D', 'W', 'M', 'Y' }; @@ -107,7 +106,7 @@ namespace Duplicati.Library.Utility default: throw new Exception(Strings.Timeparser.InvalidSpecifierError(datestring[index])); } - previndex = index + 1; + previndex = index + 1; } if (datestring.Substring(previndex).Trim().Length > 0) diff --git a/Duplicati/UnitTest/UtilityTests.cs b/Duplicati/UnitTest/UtilityTests.cs index 63e2a9614..1c29d4938 100644 --- a/Duplicati/UnitTest/UtilityTests.cs +++ b/Duplicati/UnitTest/UtilityTests.cs @@ -72,10 +72,10 @@ namespace Duplicati.UnitTest Action checkStringComparison = (x, y) => Assert.IsFalse(String.Equals(x, y, Utility.ClientFilenameStringComparison)); Action checkStringComparer = (x, y) => Assert.IsFalse(new HashSet(new[] { x }).Contains(y, Utility.ClientFilenameStringComparer)); - System.Globalization.CultureInfo originalCulture = System.Globalization.CultureInfo.CurrentCulture; + CultureInfo originalCulture = CultureInfo.CurrentCulture; try { - System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo(cultureName, false); + CultureInfo.CurrentCulture = new CultureInfo(cultureName, false); // These are equivalent with respect to hu-HU, but different with respect to en-US. string ddzs = "ddzs"; @@ -97,7 +97,7 @@ namespace Duplicati.UnitTest } finally { - System.Threading.Thread.CurrentThread.CurrentCulture = originalCulture; + CultureInfo.CurrentCulture = originalCulture; } } From 99a2095e418b5a5a2c7dbd6f82cad14f59db3d5b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 2 Oct 2024 22:14:37 +0200 Subject: [PATCH 03/11] Adding new theme to client. Added support for loading NPM package in dev mode. Added support for SPA style redirects. --- .gitignore | 1 + Duplicati/Library/RestAPI/Strings.cs | 1 + Duplicati/Server/Program.cs | 5 +- Duplicati/Server/WebServerLoader.cs | 21 ++- Duplicati/Server/webroot/missing-spa.html | 14 ++ .../Server/webroot/ngclient/package-lock.json | 22 +++ .../Server/webroot/ngclient/package.json | 9 + Duplicati/Server/webroot/theme.html | 121 +++++++------ Duplicati/WebserverCore/DuplicatiWebserver.cs | 7 +- .../StaticFilesMiddleware.Debug.cs | 163 ++++++++++++++++++ .../Middlewares/StaticFilesMiddleware.cs | 129 ++++++++++++-- 11 files changed, 414 insertions(+), 79 deletions(-) create mode 100644 Duplicati/Server/webroot/missing-spa.html create mode 100644 Duplicati/Server/webroot/ngclient/package-lock.json create mode 100644 Duplicati/Server/webroot/ngclient/package.json create mode 100644 Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs diff --git a/.gitignore b/.gitignore index 2e3ba0263..b0ae77d64 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ Duplicati/Localization/*.new.csv Duplicati/Localization/*.updated.csv Duplicati/Localization/report.*.xml Duplicati/Server/webroot/extjs-* +Duplicati/Server/webroot/*/node_modules/ packages changelog-news.txt .idea/ diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index 45be90088..5fc550ee8 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -47,6 +47,7 @@ Error message: {0}", error); } public static string TempdirShort { get { return LC.L(@"Temporary storage folder"); } } public static string WebserverResetJwtConfigDescription { get { return LC.L(@"Reset the JWT configuration, invalidating any issued login tokens"); } } public static string WebserverDisableVisualCaptchaDescription { get { return LC.L(@"Disable the visual captcha"); } } + public static string WebserverSpaPathsDescription { get { return LC.L(@"The relative paths that should be served as single page applications, separated with semicolons."); } } public static string DisabledbencryptionLong { get { return LC.L(@"Use this option to disable database encryption of sensitive fields"); } } public static string DisabledbencryptionShort { get { return LC.L(@"Disable database encryption"); } } public static string LogwindowseventlogLong { get { return LC.L(@"Use this option to log to the Windows event log."); } } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 446ef2035..98283dde6 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -397,7 +397,9 @@ namespace Duplicati.Server parsedOptions.Interface, parsedOptions.Certificate, parsedOptions.Servername, - parsedOptions.AllowedHostnames); + parsedOptions.AllowedHostnames, + parsedOptions.SPAPaths + ); var server = new DuplicatiWebserver(); @@ -901,6 +903,7 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_ALLOWEDHOSTNAMES, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverAllowedhostnamesDescription, Strings.Program.WebserverAllowedhostnamesDescription, null, [WebServerLoader.OPTION_WEBSERVICE_ALLOWEDHOSTNAMES_ALT]), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_RESET_JWT_CONFIG, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverResetJwtConfigDescription, Strings.Program.WebserverResetJwtConfigDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableVisualCaptchaDescription, Strings.Program.WebserverDisableVisualCaptchaDescription), + new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_SPAPATHS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverSpaPathsDescription, Strings.Program.WebserverSpaPathsDescription, WebServerLoader.DEFAULT_OPTION_SPAPATHS), new Duplicati.Library.Interface.CommandLineArgument(PING_PONG_KEEPALIVE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PingpongkeepaliveShort, Strings.Program.PingpongkeepaliveLong), new Duplicati.Library.Interface.CommandLineArgument("log-retention", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Timespan, Strings.Program.LogretentionShort, Strings.Program.LogretentionLong, DEFAULT_LOG_RETENTION), new Duplicati.Library.Interface.CommandLineArgument("server-datafolder", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ServerdatafolderShort, Strings.Program.ServerdatafolderLong(DATAFOLDER_ENV_NAME), System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Library.AutoUpdater.AutoUpdateSettings.AppName)), diff --git a/Duplicati/Server/WebServerLoader.cs b/Duplicati/Server/WebServerLoader.cs index a3f91da75..3d1f77137 100644 --- a/Duplicati/Server/WebServerLoader.cs +++ b/Duplicati/Server/WebServerLoader.cs @@ -57,11 +57,21 @@ public static class WebServerLoader /// public const string OPTION_WEBSERVICE_ALLOWEDHOSTNAMES_ALT = "webservice-allowedhostnames"; + /// + /// Option for setting the webservice SPA paths + /// + public const string OPTION_WEBSERVICE_SPAPATHS = "webservice-spa-paths"; + /// /// The default path to the web root /// public const string DEFAULT_OPTION_WEBROOT = "webroot"; + /// + /// The default paths to serve as SPAs + /// + public const string DEFAULT_OPTION_SPAPATHS = "/ngclient"; + /// /// The default listening port /// @@ -91,13 +101,15 @@ public static class WebServerLoader /// The certificate, if any /// The servername to report /// The allowed hostnames + /// The paths to serve as SPAs public record ParsedWebserverSettings( string WebRoot, int Port, System.Net.IPAddress Interface, X509Certificate2? Certificate, string Servername, - IEnumerable AllowedHostnames + IEnumerable AllowedHostnames, + IEnumerable SPAPaths ); @@ -191,6 +203,10 @@ public static class WebServerLoader #endif } + options.TryGetValue(OPTION_WEBSERVICE_SPAPATHS, out var spaPathsString); + if (string.IsNullOrWhiteSpace(spaPathsString)) + spaPathsString = DEFAULT_OPTION_SPAPATHS; + var certValid = cert != null && cert.HasPrivateKey; var settings = new ParsedWebserverSettings( webroot, @@ -198,7 +214,8 @@ public static class WebServerLoader listenInterface, cert, string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version), - (connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + (connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries), + spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ); // Materialize the list of ports, and move the last-used port to the front, so we try the last-known port first diff --git a/Duplicati/Server/webroot/missing-spa.html b/Duplicati/Server/webroot/missing-spa.html new file mode 100644 index 000000000..f2d0a2e20 --- /dev/null +++ b/Duplicati/Server/webroot/missing-spa.html @@ -0,0 +1,14 @@ + + + +

+ This path is intended to host a single-page application (SPA) but it + failed to install. +

+
+ Check the logs for more information. You may be able to fix the issue by + running the following command inside the folder: +
npm i
+
+ + diff --git a/Duplicati/Server/webroot/ngclient/package-lock.json b/Duplicati/Server/webroot/ngclient/package-lock.json new file mode 100644 index 000000000..c6e466b37 --- /dev/null +++ b/Duplicati/Server/webroot/ngclient/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "ngclient", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ngclient", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@duplicati/ngclient": "^0.0.5" + } + }, + "node_modules/@duplicati/ngclient": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.5.tgz", + "integrity": "sha512-Esln7Lhdwc6UUoTLQrGKGJgThl/WidVpCR3RmH0NaJy5OkPmiT1vBU02QwxZoQ/hGvTAxpXhMfSyNFJv6/9glA==", + "license": "MIT" + } + } +} diff --git a/Duplicati/Server/webroot/ngclient/package.json b/Duplicati/Server/webroot/ngclient/package.json new file mode 100644 index 000000000..ebe0371f2 --- /dev/null +++ b/Duplicati/Server/webroot/ngclient/package.json @@ -0,0 +1,9 @@ +{ + "name": "ngclient", + "version": "1.0.0", + "description": "Modern Angular Client for Duplicati", + "license": "MIT", + "dependencies": { + "@duplicati/ngclient": "^0.0.5" + } +} diff --git a/Duplicati/Server/webroot/theme.html b/Duplicati/Server/webroot/theme.html index ebd2c131c..f3dc3e60a 100644 --- a/Duplicati/Server/webroot/theme.html +++ b/Duplicati/Server/webroot/theme.html @@ -1,98 +1,105 @@ - + - - - - + + + + Select theme - + - + + - - - +

Duplicati theme

    -
  • -

    NgAx - The responsive theme

    -
    -
    Use as default
    -
    View now
    -
    -
  • - +
  • +

    NgAx - The responsive theme

    +
    +
    + Use as default +
    +
    View now
    +
    +
  • +
  • +

    NgClient - Modern Angular theme

    +
    +
    + Use as default +
    +
    View now
    +
    +
- - + + diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index b000baaa3..6e3f71e3b 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -43,13 +43,16 @@ public partial class DuplicatiWebserver /// The listening interface /// The certificate, if any /// The servername to report + /// The allowed hostnames + /// The paths to serve as SPAs public record InitSettings( string WebRoot, int Port, System.Net.IPAddress Interface, X509Certificate2? Certificate, string Servername, - IEnumerable AllowedHostnames + IEnumerable AllowedHostnames, + IEnumerable SPAPaths ); public void InitWebServer(InitSettings settings, Connection connection) @@ -192,7 +195,7 @@ public partial class DuplicatiWebserver }); } - App.UseDefaultStaticFiles(settings.WebRoot); + App.UseDefaultStaticFiles(settings.WebRoot, settings.SPAPaths); App.UseExceptionHandler(app => { diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs new file mode 100644 index 000000000..8b7aa659d --- /dev/null +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs @@ -0,0 +1,163 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text.Json; +using Duplicati.Library.Logging; +using Duplicati.Library.Utility; + +namespace Duplicati.WebserverCore.Middlewares; + +internal static class NpmSpaHelper +{ + private static readonly string LOGTAG = Log.LogTagFromType(typeof(NpmSpaHelper)); + + private sealed record PackageJson(Dictionary? dependencies, string? packageId); + private sealed record PackageLockJson(Dictionary? packages); + private sealed record PackageInfo(string version, string resolved, string? integrity); + private sealed record InstalledPackageJson(string version); + public sealed record SpaConfig(FileInfo IndexFile, string BasePath); + + public static SpaConfig? InstallNpmPackage(string packageUrl, string targetPath) + { + var tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + // Download and extract the package + Directory.CreateDirectory(tempPath); + + var tgzFile = Path.Combine(tempPath, "package.tgz"); + var tarFile = Path.ChangeExtension(tgzFile, ".tar"); + using (var client = new HttpClient()) + { + var response = client.GetAsync(packageUrl).Await(); + response.EnsureSuccessStatusCode(); + + using (var stream = response.Content.ReadAsStream()) + using (var fileStream = File.Create(tgzFile)) + stream.CopyTo(fileStream); + } + + // Extract the zipped contents + using (var fsource = new FileStream(tgzFile, FileMode.Open, FileAccess.Read)) + using (var ftarget = new FileStream(tarFile, FileMode.Create, FileAccess.Write)) + using (var gzip = new GZipStream(new FileStream(tgzFile, FileMode.Open, FileAccess.Read), CompressionMode.Decompress)) + gzip.CopyTo(ftarget); + + var dest = Path.Combine(tempPath, "package"); + + // Extract the tar entries into the temp folder + using (var fsource = new FileStream(tarFile, FileMode.Open, FileAccess.Read)) + using (var reader = new TarReader(fsource)) + while (reader.GetNextEntry() is TarEntry tar) + { + var path = Path.Combine(tempPath, tar.Name); + + // Ensure the path is within the temp folder + if (!path.StartsWith(dest)) + continue; + + if (tar.EntryType == TarEntryType.Directory) + Directory.CreateDirectory(path); + else if (tar.EntryType == TarEntryType.RegularFile) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Invalid path")); + tar.ExtractToFile(path, false); + } + } + + // Read the package.json and check it looks correct + var packageJson = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dest, "package.json"))); + if (packageJson == null || string.IsNullOrWhiteSpace(packageJson.version)) + return null; + + // Make room for the new package + if (Directory.Exists(targetPath)) + Directory.Delete(targetPath, true); + + var parentFolder = Path.GetDirectoryName(targetPath); + if (parentFolder != null && !Directory.Exists(parentFolder)) + Directory.CreateDirectory(parentFolder); + + // Move the package to the target path + Directory.Move(dest, targetPath); + + // Clean up + Directory.Delete(tempPath, true); + + return new SpaConfig(new FileInfo(Path.Combine(targetPath, "index.html")), targetPath); + } + catch (Exception ex) + { + Library.Logging.Log.WriteErrorMessage(LOGTAG, "SpaDebugHelperFail", ex, "NPM SPA package installation failed"); + } + finally + { + if (Directory.Exists(tempPath)) + Directory.Delete(tempPath, true); + } + + return null; + } + + /// + /// Probes for a missing/outdates SPA in the target folder and installs it if found. + /// + /// The base path to probe + /// The SPA configuration if found, otherwise null + public static SpaConfig? ProbeForNpmSpa(string basepath) + { + try + { + // Check if the package.json and package-lock.json files exist + var packageFile = Path.Combine(basepath, "package.json"); + var packagelockFile = Path.Combine(basepath, "package-lock.json"); + + if (!File.Exists(packageFile) || !File.Exists(packagelockFile)) + return null; + + var packageJson = JsonSerializer.Deserialize(File.ReadAllText(packageFile)); + var packageLockJson = JsonSerializer.Deserialize(File.ReadAllText(packagelockFile)); + + var packageId = string.IsNullOrWhiteSpace(packageJson?.packageId) ? packageJson?.dependencies?.Keys.FirstOrDefault() : packageJson.packageId; + if (string.IsNullOrWhiteSpace(packageId) || packageJson?.dependencies == null) + return null; + + var version = packageJson.dependencies.GetValueOrDefault(packageId); + if (string.IsNullOrWhiteSpace(version)) + return null; + + // Remove version prefix + if (!char.IsAsciiDigit(version.First())) + version = version[1..]; + + var packageUrl = packageLockJson?.packages?.GetValueOrDefault($"node_modules/{packageId}")?.resolved; + if (string.IsNullOrWhiteSpace(packageUrl)) + return null; + + // Package is not installed, install it + var packageFolder = Path.Combine(basepath, "node_modules", packageId); + if (!Directory.Exists(packageFolder)) + return InstallNpmPackage(packageUrl, packageFolder); + + // The installed package is missing version, reinstall + var installedPackageJson = Path.Combine(packageFolder, "package.json"); + if (!File.Exists(installedPackageJson)) + return InstallNpmPackage(packageUrl, packageFolder); + + // The installed package is not the correct version, reinstall + var installedJson = JsonSerializer.Deserialize(File.ReadAllText(installedPackageJson)); + if (installedJson == null || string.IsNullOrWhiteSpace(installedJson.version) || installedJson.version != version) + return InstallNpmPackage(packageUrl, packageFolder); + + // Package is installed and correct version + return new SpaConfig(new FileInfo(Path.Combine(packageFolder, "index.html")), packageFolder); + } + catch (Exception ex) + { + Library.Logging.Log.WriteErrorMessage(LOGTAG, "SpaDebugHelperFail", ex, "NPM SPA package installation failed"); + } + + return null; + } + +} \ No newline at end of file diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs index 91668f197..c8abafe2e 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs @@ -1,11 +1,22 @@ +using System.Text; +using Duplicati.Library.Logging; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; namespace Duplicati.WebserverCore.Middlewares; public static class StaticFilesExtensions { +#if DEBUG + private static readonly bool DEBUG_MODE = true; +#else + private static readonly bool DEBUG_MODE = false; +#endif + + private static readonly HashSet _nonCachePaths = new HashSet(StringComparer.OrdinalIgnoreCase) { "/", @@ -27,9 +38,105 @@ public static class StaticFilesExtensions MaxAge = TimeSpan.FromDays(7) }; + private sealed record SpaConfig(string Prefix, byte[] IndexFile, string BasePath); - public static IApplicationBuilder UseDefaultStaticFiles(this WebApplication app, string webroot) + private static byte[] ReadAndPatchIndexFile(FileInfo file, string prefix) { + if (!prefix.EndsWith("/")) + prefix += "/"; + + var index = File.ReadAllBytes(file.FullName); + var indexStr = Encoding.UTF8.GetString(index); + indexStr = indexStr.Replace("", $""); + return Encoding.UTF8.GetBytes(indexStr); + } + + public static IApplicationBuilder UseDefaultStaticFiles(this WebApplication app, string webroot, IEnumerable spaPaths) + { + var fileTypeMappings = new FileExtensionContentTypeProvider() + { + Mappings = { + ["htc"] = "text/x-component", + ["json"] = "application/json", + ["map"] = "application/json", + ["htm"] = "text/html; charset=utf-8", + ["html"] = "text/html; charset=utf-8", + ["hbs"] = "application/x-handlebars-template", + ["woff"] = "application/font-woff", + ["woff2"] = "application/font-woff" + } + }; + + var prefixHandlerMap = new List(); + var missingFile = new FileInfo(Path.Combine(webroot, "missing-spa.html")); + + foreach (var prefix in spaPaths) + { + var basepath = Path.Combine(webroot, prefix.TrimStart('/')); + if (!Directory.Exists(basepath)) + continue; + + var file = Path.Combine(basepath, "index.html"); + var fi = new FileInfo(file); + if (fi.Exists) + { + prefixHandlerMap.Add(new SpaConfig(prefix, ReadAndPatchIndexFile(fi, prefix), basepath)); + } + else if (DEBUG_MODE) + { + // Install from NPM in debug mode for easier development + var spaConfig = NpmSpaHelper.ProbeForNpmSpa(basepath); + if (spaConfig != null) + prefixHandlerMap.Add(new SpaConfig(prefix, ReadAndPatchIndexFile(spaConfig.IndexFile, prefix), spaConfig.BasePath)); + else if (spaConfig == null && missingFile.Exists) + prefixHandlerMap.Add(new SpaConfig(prefix, File.ReadAllBytes(missingFile.FullName), basepath)); + } + } + + if (prefixHandlerMap.Any()) + { + prefixHandlerMap = prefixHandlerMap.OrderByDescending(p => p.Prefix.Length).ToList(); + app.Use(async (context, next) => + { + await next(); + + // Not found only + if (context.Response.StatusCode != 404 || context.Response.HasStarted) + return; + + // Check if we can use the path + var path = context.Request.Path; + if (!path.HasValue) + return; + + // Check if the path is a SPA path + var spaConfig = prefixHandlerMap.FirstOrDefault(p => path.Value.StartsWith(p.Prefix)); + if (spaConfig == null) + return; + + if (string.IsNullOrEmpty(Path.GetExtension(path)) || path.Value.EndsWith("/index.html")) + { + // Serve the index file + context.Response.ContentType = "text/html"; + context.Response.StatusCode = 200; + await context.Response.Body.WriteAsync(spaConfig.IndexFile); + await context.Response.CompleteAsync(); + } + else + { + // Serve the static file + var file = new FileInfo(Path.Combine(spaConfig.BasePath, path.Value.Substring(spaConfig.Prefix.Length).TrimStart('/'))); + if (file.FullName.StartsWith(spaConfig.BasePath) && file.Exists && fileTypeMappings.Mappings.TryGetValue(Path.GetExtension(file.Extension), out var contentType)) + { + context.Response.ContentType = contentType; + context.Response.StatusCode = 200; + await context.Response.SendFileAsync(new PhysicalFileInfo(file)); + await context.Response.CompleteAsync(); + } + } + }); + } + var fileProvider = new PhysicalFileProvider(Path.GetFullPath(webroot)); var defaultFiles = GetDefaultFiles(fileProvider); app.UseDefaultFiles(defaultFiles); @@ -43,23 +150,11 @@ public static class StaticFilesExtensions var headers = context.Context.Response.GetTypedHeaders(); var path = context.Context.Request.Path.Value ?? string.Empty; headers.CacheControl = - (path.EndsWith("/index.html") || _nonCachePaths.Contains(path)) - ? _noCache - : _allowCache; + (path.EndsWith("/index.html") || _nonCachePaths.Contains(path)) + ? _noCache + : _allowCache; }, - ContentTypeProvider = new FileExtensionContentTypeProvider() - { - Mappings = { - ["htc"] = "text/x-component", - ["json"] = "application/json", - ["map"] = "application/json", - ["htm"] = "text/html; charset=utf-8", - ["html"] = "text/html; charset=utf-8", - ["hbs"] = "application/x-handlebars-template", - ["woff"] = "application/font-woff", - ["woff2"] = "application/font-woff", - } - } + ContentTypeProvider = fileTypeMappings }); return app; From 48bd69cbdec6306568e9bcc4bb13133022e6c82e Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 2 Oct 2024 22:50:23 +0200 Subject: [PATCH 04/11] Injecting the NPM packages for builds --- ReleaseBuilder/Build/Command.cs | 58 +++++++++++++++++++++++++++++++++ ReleaseBuilder/Configuration.cs | 7 ++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index b0c36b9a5..c25472f98 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -464,6 +464,9 @@ public static partial class Command // Inject various files that will be embedded into the build artifacts var revertableFiles = await PrepareSourceDirectory(baseDir, releaseInfo, rtcfg); + // Record the files that are replaced by the npm package + revertableFiles.AddRange(await ReplaceNpmPackages(baseDir, input.BuildPath.FullName, releaseInfo, rtcfg)); + // Inject a version tag into the html files revertableFiles.AddRange(InjectVersionIntoFiles(baseDir, releaseInfo)); @@ -721,6 +724,61 @@ public static partial class Command }); } + /// + /// Uses NPM to replace the node_modules folder in the webroot with a fresh install + /// + /// The base directory + /// The build directory + /// The release info + /// The runtime configuration + /// The files that were deleted + static async Task> ReplaceNpmPackages(string baseDir, string buildDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg) + { + var deleted = new List(); + + // Find all webroot folders with a package.json and package-lock.json + var targets = Directory.EnumerateDirectories(Path.Combine(baseDir, "Duplicati", "Server", "webroot"), "*", SearchOption.TopDirectoryOnly) + .Where(x => File.Exists(Path.Combine(x, "package.json")) && File.Exists(Path.Combine(x, "package-lock.json"))) + .ToList(); + + foreach (var target in targets) + { + if (string.IsNullOrWhiteSpace(Program.Configuration.Commands.Npm)) + throw new Exception("NPM command not found, but required for building"); + + // Remove existing node_modules folder + if (Directory.Exists(Path.Combine(target, "node_modules"))) + Directory.Delete(Path.Combine(target, "node_modules"), true); + + // These will be deleted after the build + deleted.AddRange(Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories)); + + var tmp = Path.Combine(buildDir, "tmp-npm"); + if (Directory.Exists(tmp)) + Directory.Delete(tmp, true); + + Directory.CreateDirectory(tmp); + EnvHelper.CopyDirectory(target, tmp, true); + + // Run npm install in the temporary folder + await ProcessHelper.Execute(new[] { Program.Configuration.Commands.Npm, "ci" }, workingDirectory: tmp); + var basefolder = Directory.EnumerateDirectories(Path.Combine(tmp, "node_modules"), "*", SearchOption.AllDirectories) + .Where(x => File.Exists(Path.Combine(x, "index.html"))) + .OrderBy(x => x.Length) + .FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(basefolder)) + throw new Exception($"Failed to locate node_modules folder in {target}"); + + Directory.Delete(target, true); + Directory.Move(basefolder, target); + Directory.Delete(tmp, true); + } + + return deleted; + } + + /// /// Loads a keyfile and decrypts the RSA key inside /// diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs index 481e9fb7a..000c0d2b1 100644 --- a/ReleaseBuilder/Configuration.cs +++ b/ReleaseBuilder/Configuration.cs @@ -297,6 +297,7 @@ public record ConfigFiles( /// The "productsign" command /// The "wix" command /// The "docker" command +/// The "npm" command public record Commands( string Dotnet, string? Gpg, @@ -304,7 +305,8 @@ public record Commands( string? Codesign, string? Productsign, string? Wix, - string? Docker + string? Docker, + string? Npm ) { /// @@ -319,7 +321,8 @@ public record Commands( OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null, OperatingSystem.IsMacOS() ? FindCommand("productsign", "PRODUCTSIGN") : null, FindCommand(OperatingSystem.IsWindows() ? "wix" : "wixl", "WIX"), - FindCommand("docker", "DOCKER") + FindCommand("docker", "DOCKER"), + FindCommand("npm", "NPM") ); } From 9dfafa1492715773ba1b859f43eaf2f683111ca4 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 2 Oct 2024 22:57:13 +0200 Subject: [PATCH 05/11] Removed the NPM injector from release builds with pre-compiler flags --- .../Middlewares/StaticFilesMiddleware.Debug.cs | 4 +++- .../Middlewares/StaticFilesMiddleware.cs | 13 +++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs index 8b7aa659d..00e892369 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs @@ -1,3 +1,4 @@ +#if DEBUG using System.Formats.Tar; using System.IO.Compression; using System.Text.Json; @@ -160,4 +161,5 @@ internal static class NpmSpaHelper return null; } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs index c8abafe2e..f16ee6c98 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs @@ -1,22 +1,13 @@ using System.Text; -using Duplicati.Library.Logging; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; namespace Duplicati.WebserverCore.Middlewares; public static class StaticFilesExtensions { -#if DEBUG - private static readonly bool DEBUG_MODE = true; -#else - private static readonly bool DEBUG_MODE = false; -#endif - - private static readonly HashSet _nonCachePaths = new HashSet(StringComparer.OrdinalIgnoreCase) { "/", @@ -82,7 +73,8 @@ public static class StaticFilesExtensions { prefixHandlerMap.Add(new SpaConfig(prefix, ReadAndPatchIndexFile(fi, prefix), basepath)); } - else if (DEBUG_MODE) +#if DEBUG + else { // Install from NPM in debug mode for easier development var spaConfig = NpmSpaHelper.ProbeForNpmSpa(basepath); @@ -91,6 +83,7 @@ public static class StaticFilesExtensions else if (spaConfig == null && missingFile.Exists) prefixHandlerMap.Add(new SpaConfig(prefix, File.ReadAllBytes(missingFile.FullName), basepath)); } +#endif } if (prefixHandlerMap.Any()) From c427d5a98be9a413cd3a5bd238d42718d97730ee Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 3 Oct 2024 08:25:41 +0200 Subject: [PATCH 06/11] Improved cleanup of source folder after build --- ReleaseBuilder/Build/Command.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index c25472f98..d867de1a8 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -464,12 +464,13 @@ public static partial class Command // Inject various files that will be embedded into the build artifacts var revertableFiles = await PrepareSourceDirectory(baseDir, releaseInfo, rtcfg); - // Record the files that are replaced by the npm package - revertableFiles.AddRange(await ReplaceNpmPackages(baseDir, input.BuildPath.FullName, releaseInfo, rtcfg)); - // Inject a version tag into the html files revertableFiles.AddRange(InjectVersionIntoFiles(baseDir, releaseInfo)); + // Record the files that are replaced by the npm package + var foldersToRemove = new List(); + revertableFiles.AddRange(await ReplaceNpmPackages(baseDir, input.BuildPath.FullName, foldersToRemove, releaseInfo, rtcfg)); + // Perform the main compilations await Compile.BuildProjects(baseDir, input.BuildPath.FullName, sourceProjects, windowsOnly, guiOnlyProjects, buildTargets, releaseInfo, input.KeepBuilds, rtcfg, input.UseHostedBuilds); @@ -591,6 +592,10 @@ public static partial class Command } // Clean up the source tree + foreach (var folder in foldersToRemove) + if (Directory.Exists(folder)) + Directory.Delete(folder, true); + await ProcessHelper.Execute(new[] { "git", "checkout", }.Concat(revertableFiles.Select(x => Path.GetRelativePath(baseDir, x))) @@ -729,10 +734,11 @@ public static partial class Command /// /// The base directory /// The build directory + /// The folders to remove after the build /// The release info /// The runtime configuration /// The files that were deleted - static async Task> ReplaceNpmPackages(string baseDir, string buildDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg) + static async Task> ReplaceNpmPackages(string baseDir, string buildDir, List foldersToRemove, ReleaseInfo releaseInfo, RuntimeConfig rtcfg) { var deleted = new List(); @@ -773,9 +779,11 @@ public static partial class Command Directory.Delete(target, true); Directory.Move(basefolder, target); Directory.Delete(tmp, true); + foldersToRemove.Add(target); } - return deleted; + return deleted.Where(x => !Path.GetFileName(x).StartsWith(".")) + .ToList(); } From 7b958a0878ddaa267b7a193531ea533699953268 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Thu, 3 Oct 2024 10:05:15 +0200 Subject: [PATCH 07/11] Fixes to the multiple instance detection system Exception was bundled in the innerexception and thus the catch statement was not functioning as expected. Additionally adding ? operator to the ApplicationExitEvent ServerStartedEvent events. --- .../Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs | 4 ++-- Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs index fadcee413..b28d76eee 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs @@ -51,8 +51,8 @@ namespace Duplicati.GUI.TrayIcon catch (Exception ex) { m_runnerException = ex; - Duplicati.Server.Program.ServerStartedEvent.Set(); - Duplicati.Server.Program.ApplicationExitEvent.Set(); + Duplicati.Server.Program.ServerStartedEvent?.Set(); + Duplicati.Server.Program.ApplicationExitEvent?.Set(); } finally { diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index edd1bad50..8ee24dd66 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -25,6 +25,7 @@ using System.IO; using System.Net; using Duplicati.Library.Interface; using Duplicati.Server; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Duplicati.GUI.TrayIcon { @@ -107,11 +108,17 @@ namespace Duplicati.GUI.TrayIcon Server.Program.Origin = "Tray icon"; hosted = new HostedInstanceKeeper(_args); } - catch (Server.SingleInstance.MultipleInstanceException) + catch (Exception ex) { - return 1; + if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException) + { + Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected); + return 1; + } + else + throw; } - + // We have a hosted server, if this is the first run, // we should open the main page openui = Server.Program.IsFirstRun || Server.Program.ServerPortChanged; From 611e28fafca4ac1b843d2b3408bb3a3122f5370e Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Thu, 3 Oct 2024 10:05:15 +0200 Subject: [PATCH 08/11] Fixes to the multiple instance detection system Exception was bundled in the innerexception and thus the catch statement was not functioning as expected. Additionally adding ? operator to the ApplicationExitEvent ServerStartedEvent events. --- .../Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs | 4 ++-- Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs index fadcee413..b28d76eee 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HostedInstanceKeeper.cs @@ -51,8 +51,8 @@ namespace Duplicati.GUI.TrayIcon catch (Exception ex) { m_runnerException = ex; - Duplicati.Server.Program.ServerStartedEvent.Set(); - Duplicati.Server.Program.ApplicationExitEvent.Set(); + Duplicati.Server.Program.ServerStartedEvent?.Set(); + Duplicati.Server.Program.ApplicationExitEvent?.Set(); } finally { diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index edd1bad50..f6ff8f297 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -25,6 +25,7 @@ using System.IO; using System.Net; using Duplicati.Library.Interface; using Duplicati.Server; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Duplicati.GUI.TrayIcon { @@ -107,9 +108,15 @@ namespace Duplicati.GUI.TrayIcon Server.Program.Origin = "Tray icon"; hosted = new HostedInstanceKeeper(_args); } - catch (Server.SingleInstance.MultipleInstanceException) + catch (Exception ex) { - return 1; + if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException) + { + Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected); + return 1; + } + else + throw; } // We have a hosted server, if this is the first run, From 0755a79f731b308410ce263dc1b69e4113cff388 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 3 Oct 2024 11:26:11 +0200 Subject: [PATCH 09/11] Bump installed version of ngclient --- Duplicati/Server/webroot/ngclient/package-lock.json | 8 ++++---- Duplicati/Server/webroot/ngclient/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Duplicati/Server/webroot/ngclient/package-lock.json b/Duplicati/Server/webroot/ngclient/package-lock.json index c6e466b37..532175d11 100644 --- a/Duplicati/Server/webroot/ngclient/package-lock.json +++ b/Duplicati/Server/webroot/ngclient/package-lock.json @@ -9,13 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.5" + "@duplicati/ngclient": "^0.0.7" } }, "node_modules/@duplicati/ngclient": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.5.tgz", - "integrity": "sha512-Esln7Lhdwc6UUoTLQrGKGJgThl/WidVpCR3RmH0NaJy5OkPmiT1vBU02QwxZoQ/hGvTAxpXhMfSyNFJv6/9glA==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.7.tgz", + "integrity": "sha512-gibaRMwmnt76xPmK/jUXEQqRGgepBuIiKFAAazlG2y1nZGNLnpXBTNXGfxEgmsZuOXoCQFvpURcfwIlnEPJSgQ==", "license": "MIT" } } diff --git a/Duplicati/Server/webroot/ngclient/package.json b/Duplicati/Server/webroot/ngclient/package.json index ebe0371f2..0cde8e700 100644 --- a/Duplicati/Server/webroot/ngclient/package.json +++ b/Duplicati/Server/webroot/ngclient/package.json @@ -4,6 +4,6 @@ "description": "Modern Angular Client for Duplicati", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.5" + "@duplicati/ngclient": "^0.0.7" } } From b2c3c9abe63fd79888cce65cc6701d7f0a0735ee Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 3 Oct 2024 13:20:56 +0200 Subject: [PATCH 10/11] Simplified debugging with SPA themes --- Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs index f16ee6c98..52117cf59 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs @@ -112,7 +112,11 @@ public static class StaticFilesExtensions // Serve the index file context.Response.ContentType = "text/html"; context.Response.StatusCode = 200; +#if DEBUG + await context.Response.Body.WriteAsync(ReadAndPatchIndexFile(new FileInfo(Path.Combine(spaConfig.BasePath, "index.html")), spaConfig.Prefix)); +#else await context.Response.Body.WriteAsync(spaConfig.IndexFile); +#endif await context.Response.CompleteAsync(); } else From 6c71a1d9008d40085789f55c3596aac7b8dc486e Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 3 Oct 2024 13:21:09 +0200 Subject: [PATCH 11/11] Bumped ngclient --- Duplicati/Server/webroot/ngclient/package-lock.json | 8 ++++---- Duplicati/Server/webroot/ngclient/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Duplicati/Server/webroot/ngclient/package-lock.json b/Duplicati/Server/webroot/ngclient/package-lock.json index 532175d11..620acf21d 100644 --- a/Duplicati/Server/webroot/ngclient/package-lock.json +++ b/Duplicati/Server/webroot/ngclient/package-lock.json @@ -9,13 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.7" + "@duplicati/ngclient": "^0.0.11" } }, "node_modules/@duplicati/ngclient": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.7.tgz", - "integrity": "sha512-gibaRMwmnt76xPmK/jUXEQqRGgepBuIiKFAAazlG2y1nZGNLnpXBTNXGfxEgmsZuOXoCQFvpURcfwIlnEPJSgQ==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.11.tgz", + "integrity": "sha512-J1Oy5IZ20i3vV6lAzON2FTouU3gancBpurRubtyb/gt9dEWmN5niqvfAuupiO2lb5gQxcifV9DuarAXQKrPNRA==", "license": "MIT" } } diff --git a/Duplicati/Server/webroot/ngclient/package.json b/Duplicati/Server/webroot/ngclient/package.json index 0cde8e700..13ead96b1 100644 --- a/Duplicati/Server/webroot/ngclient/package.json +++ b/Duplicati/Server/webroot/ngclient/package.json @@ -4,6 +4,6 @@ "description": "Modern Angular Client for Duplicati", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.7" + "@duplicati/ngclient": "^0.0.11" } }