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/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/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/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; 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 aea264e9e..4464f5ef8 100644 --- a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs +++ b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs @@ -22,6 +22,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Globalization; namespace Duplicati.Library.Main.Operation { @@ -49,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 2fb0cfdda..eb7eadc81 100644 --- a/Duplicati/Library/Main/Options.cs +++ b/Duplicati/Library/Main/Options.cs @@ -610,20 +610,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/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index 167668cb3..8115953df 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -4,79 +4,80 @@ using System.Linq; namespace Duplicati.Server.Strings { - public static class Program - { - public static string AnotherInstanceDetected { get { return LC.L(@"Another instance is running, and was notified"); } } - public static string DatabaseOpenError(string message) { return LC.L(@"Failed to create, open or upgrade the database. + public static class Program + { + public static string AnotherInstanceDetected { get { return LC.L(@"Another instance is running, and was notified"); } } + public static string DatabaseOpenError(string message) { return LC.L(@"Failed to create, open or upgrade the database. Error message: {0}", message); } - public static string HelpCommandDescription { get { return LC.L(@"Display this help"); } } - public static string HelpDisplayDialog { get { return LC.L(@"Supported commandline arguments: + public static string HelpCommandDescription { get { return LC.L(@"Display this help"); } } + public static string HelpDisplayDialog { get { return LC.L(@"Supported commandline arguments: "); } } - public static string HelpDisplayFormat(string optionname, string optiontext) { return LC.L(@"--{0}: {1}", optionname, optiontext); } - public static string ParametersFileOptionLong2 { get { return LC.L(@"Use this option to store some or all of the options given to the commandline client. The file must be a plain text file, and UTF-8 encoding is preferred. Each line in the file should be of the format --option=value. Use the special options --{0} and --{1} to override the localpath and the remote destination uri, respectively. The options in this file take precedence over the options provided on the commandline. You cannot specify filters in both the file and on the commandline. Instead, you can use the special --{2}, --{3}, or --{4} options to specify filters inside the parameter file. Each filter must be prefixed with either a + or a -, and multiple filters must be joined with {5} ", "source", "target", "replace-filter", "append-filter", "prepend-filter", System.IO.Path.PathSeparator); } } - public static string ParametersFileOptionShort { get { return LC.L(@"Path to a file with parameters"); } } - public static string FiltersCannotBeUsedWithFileError2 { get { return LC.L(@"Filters cannot be specified on the commandline if filters are also present in the parameter file. Use the special --{0}, --{1}, or --{2} options to specify filters inside the parameter file. Each filter must be prefixed with either a + or a -, and multiple filters must be joined with {3}", "replace-filter", "append-filter", "prepend-filter", System.IO.Path.PathSeparator); } } - public static string FailedToParseParametersFileError(string path, string message) { return LC.L(@"Unable to read the parameters file ""{0}"", reason: {1}", path, message); } - public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored"; } } - public static string LogfileCommandDescription { get { return LC.L(@"Output log information to the file given"); } } - public static string LoglevelCommandDescription { get { return LC.L(@"Determine the amount of information written in the log file"); } } - public static string PortablemodeCommandDescription { get { return LC.L(@"Activate portable mode where the database is placed below the program executable"); } } - public static string SeriousError(string message) { return LC.L(@"A serious error occurred in Duplicati: {0}", message); } - public static string TearDownError(string message) { return LC.L(@"An error occurred on server tear down: {0}", message); } - public static string StartupFailure(System.Exception error) { return LC.L(@"Unable to start up. Perhaps another process is already running? + public static string HelpDisplayFormat(string optionname, string optiontext) { return LC.L(@"--{0}: {1}", optionname, optiontext); } + public static string ParametersFileOptionLong2 { get { return LC.L(@"Use this option to store some or all of the options given to the commandline client. The file must be a plain text file, and UTF-8 encoding is preferred. Each line in the file should be of the format --option=value. Use the special options --{0} and --{1} to override the localpath and the remote destination uri, respectively. The options in this file take precedence over the options provided on the commandline. You cannot specify filters in both the file and on the commandline. Instead, you can use the special --{2}, --{3}, or --{4} options to specify filters inside the parameter file. Each filter must be prefixed with either a + or a -, and multiple filters must be joined with {5} ", "source", "target", "replace-filter", "append-filter", "prepend-filter", System.IO.Path.PathSeparator); } } + public static string ParametersFileOptionShort { get { return LC.L(@"Path to a file with parameters"); } } + public static string FiltersCannotBeUsedWithFileError2 { get { return LC.L(@"Filters cannot be specified on the commandline if filters are also present in the parameter file. Use the special --{0}, --{1}, or --{2} options to specify filters inside the parameter file. Each filter must be prefixed with either a + or a -, and multiple filters must be joined with {3}", "replace-filter", "append-filter", "prepend-filter", System.IO.Path.PathSeparator); } } + public static string FailedToParseParametersFileError(string path, string message) { return LC.L(@"Unable to read the parameters file ""{0}"", reason: {1}", path, message); } + public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored"; } } + public static string LogfileCommandDescription { get { return LC.L(@"Output log information to the file given"); } } + public static string LoglevelCommandDescription { get { return LC.L(@"Determine the amount of information written in the log file"); } } + public static string PortablemodeCommandDescription { get { return LC.L(@"Activate portable mode where the database is placed below the program executable"); } } + public static string SeriousError(string message) { return LC.L(@"A serious error occurred in Duplicati: {0}", message); } + public static string TearDownError(string message) { return LC.L(@"An error occurred on server tear down: {0}", message); } + public static string StartupFailure(System.Exception error) { return LC.L(@"Unable to start up. Perhaps another process is already running? Error message: {0}", error); } - public static string UnencrypteddatabaseCommandDescription { get { return LC.L(@"Disable database encryption"); } } - public static string WrongSQLiteVersion(System.Version actualversion, string expectedversion) { return LC.L(@"Unsupported version of SQLite detected ({0}), must be {1} or higher", actualversion, expectedversion); } - public static string WebserverWebrootDescription { get { return LC.L(@"The path to the folder where the static files for the webserver is present. The folder must be located beneath the installation folder."); } } - public static string WebserverPortDescription { get { return LC.L(@"The port the webserver listens on. Multiple values may be supplied with a comma in between."); } } - public static string WebserverCertificateFileDescription { get { return LC.L(@"The certificate and key file in PKCS #12 format the webserver use for SSL. Only RSA/DSA keys are supported."); } } - public static string WebserverCertificatePasswordDescription { get { return LC.L(@"The password for decryption of certificate PKCS #12 file."); } } - public static string WebserverInterfaceDescription { get { return LC.L(@"The interface the webserver listens on. The special values ""*"" and ""any"" means any interface. The special value ""loopback"" means the loopback adapter."); } } - public static string WebserverPasswordDescription { get { return LC.L(@"The password required to access the webserver. This option is saved so you do not need to set it on each run. Setting an empty value disables the password."); } } - public static string WebserverAllowedhostnamesDescription { get { return LC.L(@"The hostnames that are accepted, separated with semicolons. If any of the hostnames are ""*"", all hostnames are allowed and the hostname checking is disabled."); } } - public static string PingpongkeepaliveLong { get { return LC.L(@"When running as a server, the service daemon must verify that the process is responding. If this option is enabled, the server reads stdin and writes a reply to each line read."); } } - public static string PingpongkeepaliveShort { get { return LC.L(@"Enable the ping-pong responder"); } } - public static string LogretentionLong { get { return LC.L(@"Set the time after which log data will be purged from the database."); } } - public static string LogretentionShort { get { return LC.L(@"Clean up old log data"); } } - public static string ServerdatafolderLong(string envname) { return LC.L(@"Duplicati needs to store a small database with all settings. Use this option to choose where the settings are stored. This option can also be set with the environment variable {0}.", envname); } - public static string ServerdatafolderShort { get { return LC.L(@"Set the folder where settings are stored"); } } - public static string ServerencryptionkeyLong(string envname, string decryptionoption) { return LC.L(@"This option sets the encryption key used to scramble the local settings database. This option can also be set with the environment variable {0}. Use the option --{1} to disable the database scrambling.", envname, decryptionoption); } - public static string ServerencryptionkeyShort { get { return LC.L(@"Set the database encryption key"); } } - public static string TempdirLong { get { return LC.L(@"Use this option to supply an alternative folder for temporary storage. By default the system default temporary folder is used. Note that also SQLite will put temporary files in this temporary folder."); } } - 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 WebserverApiOnlyDescription { get { return LC.L(@"Disable the web interface and only allow API access"); } } - public static string WebserverDisableSigninTokensDescription { get { return LC.L(@"Disable the use of signin tokens"); } } - 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."); } } - public static string LogwindowseventlogShort { get { return LC.L(@"Log to the Windows event log"); } } - public static string LogwindowseventloglevelLong { get { return LC.L(@"Use this option to set the log level for the Windows event log."); } } - public static string LogwindowseventloglevelShort { get { return LC.L(@"Set the log level for the Windows event log"); } } - public static string WindowsEventLogSourceNotFound(string source) { return LC.L(@"The Windows event log source {0} was not found. The source must be registered before the log can be written.", source); } - public static string WindowsEventLogNotSupported { get { return LC.L(@"The Windows event log is not supported on this platform"); } } - public static string ServerStarted(int port) { return LC.L(@"Server has started and is listening on port {0}", port); } - public static string ServerStartedSignin(string url) { return LC.L(@"Use the following link to sign in: {0}", url); } - public static string ServerCrashed(string message) { return LC.L(@"The server crashed: {0}", message); } - public static string RequiredbencryptionLong { get { return LC.L(@"Use this option to require a custom provided key for database encryption of sensitive fields and not rely on the serial number."); } } - public static string RequiredbencryptionShort { get { return LC.L(@"Require database encryption"); } } - public static string DatabaseEncryptionKeyRequired(string envkey, string disableoptionname) { return LC.L(@"Database encryption key is required. Supply an encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } - public static string BlacklistedEncryptionKey(string envkey, string disableoptionname) { return LC.L(@"The database encryption key is blacklisted and cannot be used. The database has been decrypted. Supply a new encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } - public static string NoEncryptionKeySpecified(string envkey, string disableoptionname) { return LC.L(@"No database encryption key was found. The database will be stored unencrypted. Supply an encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } - public static string EncryptionKeyMissing(string envkey) { return LC.L(@"The database appears to be encrypted, but no key was specified. Opening the database will likely fail. Use the environment variable {0} to specify the key.", envkey); } - } - internal static class Scheduler - { - public static string InvalidTimeSetupError(System.DateTime startdate, string interval, string alloweddays) { return LC.L(@"Unable to find a valid date, given the start date {0}, the repetition interval {1} and the allowed days {2}", startdate, interval, alloweddays); } - } - public static class Server - { - public static string DefectSSLCertInDatabase { get { return @"Unable to create SSL certificate using data from database. Starting without SSL."; } } - public static string StartedServer(string ip, int port) { return LC.L(@"Server has started and is listening on {0}, port {1}", ip, port); } - public static string SSLCertificateFailure(string errormessage) { return LC.L(@"Unable to create SSL certificate using provided parameters. Exception detail: {0}", errormessage); } - public static string ServerStartFailure(IEnumerable portstried) { return LC.L(@"Unable to open a socket for listening, tried ports: {0}", string.Join(",", from n in (portstried ?? new int[0]) select n.ToString())); } - } + public static string UnencrypteddatabaseCommandDescription { get { return LC.L(@"Disable database encryption"); } } + public static string WrongSQLiteVersion(System.Version actualversion, string expectedversion) { return LC.L(@"Unsupported version of SQLite detected ({0}), must be {1} or higher", actualversion, expectedversion); } + public static string WebserverWebrootDescription { get { return LC.L(@"The path to the folder where the static files for the webserver is present. The folder must be located beneath the installation folder."); } } + public static string WebserverPortDescription { get { return LC.L(@"The port the webserver listens on. Multiple values may be supplied with a comma in between."); } } + public static string WebserverCertificateFileDescription { get { return LC.L(@"The certificate and key file in PKCS #12 format the webserver use for SSL. Only RSA/DSA keys are supported."); } } + public static string WebserverCertificatePasswordDescription { get { return LC.L(@"The password for decryption of certificate PKCS #12 file."); } } + public static string WebserverInterfaceDescription { get { return LC.L(@"The interface the webserver listens on. The special values ""*"" and ""any"" means any interface. The special value ""loopback"" means the loopback adapter."); } } + public static string WebserverPasswordDescription { get { return LC.L(@"The password required to access the webserver. This option is saved so you do not need to set it on each run. Setting an empty value disables the password."); } } + public static string WebserverAllowedhostnamesDescription { get { return LC.L(@"The hostnames that are accepted, separated with semicolons. If any of the hostnames are ""*"", all hostnames are allowed and the hostname checking is disabled."); } } + public static string PingpongkeepaliveLong { get { return LC.L(@"When running as a server, the service daemon must verify that the process is responding. If this option is enabled, the server reads stdin and writes a reply to each line read."); } } + public static string PingpongkeepaliveShort { get { return LC.L(@"Enable the ping-pong responder"); } } + public static string LogretentionLong { get { return LC.L(@"Set the time after which log data will be purged from the database."); } } + public static string LogretentionShort { get { return LC.L(@"Clean up old log data"); } } + public static string ServerdatafolderLong(string envname) { return LC.L(@"Duplicati needs to store a small database with all settings. Use this option to choose where the settings are stored. This option can also be set with the environment variable {0}.", envname); } + public static string ServerdatafolderShort { get { return LC.L(@"Set the folder where settings are stored"); } } + public static string ServerencryptionkeyLong(string envname, string decryptionoption) { return LC.L(@"This option sets the encryption key used to scramble the local settings database. This option can also be set with the environment variable {0}. Use the option --{1} to disable the database scrambling.", envname, decryptionoption); } + public static string ServerencryptionkeyShort { get { return LC.L(@"Set the database encryption key"); } } + public static string TempdirLong { get { return LC.L(@"Use this option to supply an alternative folder for temporary storage. By default the system default temporary folder is used. Note that also SQLite will put temporary files in this temporary folder."); } } + 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 WebserverApiOnlyDescription { get { return LC.L(@"Disable the web interface and only allow API access"); } } + public static string WebserverDisableSigninTokensDescription { get { return LC.L(@"Disable the use of signin tokens"); } } + 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."); } } + public static string LogwindowseventlogShort { get { return LC.L(@"Log to the Windows event log"); } } + public static string LogwindowseventloglevelLong { get { return LC.L(@"Use this option to set the log level for the Windows event log."); } } + public static string LogwindowseventloglevelShort { get { return LC.L(@"Set the log level for the Windows event log"); } } + public static string WindowsEventLogSourceNotFound(string source) { return LC.L(@"The Windows event log source {0} was not found. The source must be registered before the log can be written.", source); } + public static string WindowsEventLogNotSupported { get { return LC.L(@"The Windows event log is not supported on this platform"); } } + public static string ServerStarted(int port) { return LC.L(@"Server has started and is listening on port {0}", port); } + public static string ServerStartedSignin(string url) { return LC.L(@"Use the following link to sign in: {0}", url); } + public static string ServerCrashed(string message) { return LC.L(@"The server crashed: {0}", message); } + public static string RequiredbencryptionLong { get { return LC.L(@"Use this option to require a custom provided key for database encryption of sensitive fields and not rely on the serial number."); } } + public static string RequiredbencryptionShort { get { return LC.L(@"Require database encryption"); } } + public static string DatabaseEncryptionKeyRequired(string envkey, string disableoptionname) { return LC.L(@"Database encryption key is required. Supply an encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } + public static string BlacklistedEncryptionKey(string envkey, string disableoptionname) { return LC.L(@"The database encryption key is blacklisted and cannot be used. The database has been decrypted. Supply a new encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } + public static string NoEncryptionKeySpecified(string envkey, string disableoptionname) { return LC.L(@"No database encryption key was found. The database will be stored unencrypted. Supply an encryption key via the environment variable {0} or disable database encryption with the option --{1}", envkey, disableoptionname); } + public static string EncryptionKeyMissing(string envkey) { return LC.L(@"The database appears to be encrypted, but no key was specified. Opening the database will likely fail. Use the environment variable {0} to specify the key.", envkey); } + } + internal static class Scheduler + { + public static string InvalidTimeSetupError(System.DateTime startdate, string interval, string alloweddays) { return LC.L(@"Unable to find a valid date, given the start date {0}, the repetition interval {1} and the allowed days {2}", startdate, interval, alloweddays); } + } + public static class Server + { + public static string DefectSSLCertInDatabase { get { return @"Unable to create SSL certificate using data from database. Starting without SSL."; } } + public static string StartedServer(string ip, int port) { return LC.L(@"Server has started and is listening on {0}, port {1}", ip, port); } + public static string SSLCertificateFailure(string errormessage) { return LC.L(@"Unable to create SSL certificate using provided parameters. Exception detail: {0}", errormessage); } + public static string ServerStartFailure(IEnumerable portstried) { return LC.L(@"Unable to open a socket for listening, tried ports: {0}", string.Join(",", from n in (portstried ?? new int[0]) select n.ToString())); } + } } 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/Server/Program.cs b/Duplicati/Server/Program.cs index c638f0375..b7876607f 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -399,7 +399,9 @@ namespace Duplicati.Server parsedOptions.Certificate, parsedOptions.Servername, parsedOptions.AllowedHostnames, - parsedOptions.DisableStaticFiles); + parsedOptions.DisableStaticFiles, + parsedOptions.SPAPaths + ); var server = new DuplicatiWebserver(); @@ -908,6 +910,7 @@ namespace Duplicati.Server 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_API_ONLY, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverApiOnlyDescription, Strings.Program.WebserverApiOnlyDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableSigninTokensDescription, Strings.Program.WebserverDisableSigninTokensDescription), + 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 aece0cee0..43724d688 100644 --- a/Duplicati/Server/WebServerLoader.cs +++ b/Duplicati/Server/WebServerLoader.cs @@ -86,12 +86,21 @@ public static class WebServerLoader /// Option for disabling the use of signin tokens /// public const string OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS = "webservice-disable-signin-tokens"; + /// + /// 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 /// @@ -122,6 +131,7 @@ public static class WebServerLoader /// The servername to report /// The allowed hostnames /// If static files should be disabled + /// The paths to serve as SPAs public record ParsedWebserverSettings( string WebRoot, int Port, @@ -129,7 +139,8 @@ public static class WebServerLoader X509Certificate2? Certificate, string Servername, IEnumerable AllowedHostnames, - bool DisableStaticFiles + bool DisableStaticFiles, + IEnumerable SPAPaths ); @@ -223,6 +234,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, @@ -231,7 +246,8 @@ public static class WebServerLoader 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), - Duplicati.Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_API_ONLY) + Duplicati.Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_API_ONLY), + 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..620acf21d --- /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.11" + } + }, + "node_modules/@duplicati/ngclient": { + "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 new file mode 100644 index 000000000..13ead96b1 --- /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.11" + } +} 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/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; } } diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index ac42fbf8d..e48706ffe 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -33,6 +33,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; @@ -65,6 +66,7 @@ public partial class DuplicatiWebserver /// The servername to report /// The allowed hostnames /// If static files should be disabled + /// The paths to serve as SPAs public record InitSettings( string WebRoot, int Port, @@ -72,7 +74,8 @@ public partial class DuplicatiWebserver X509Certificate2? Certificate, string Servername, IEnumerable AllowedHostnames, - bool DisableStaticFiles + bool DisableStaticFiles, + IEnumerable SPAPaths ); public void InitWebServer(InitSettings settings, Connection connection) @@ -180,6 +183,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 @@ -213,7 +219,7 @@ public partial class DuplicatiWebserver } if (!settings.DisableStaticFiles) - App.UseDefaultStaticFiles(settings.WebRoot); + App.UseDefaultStaticFiles(settings.WebRoot, settings.SPAPaths); App.UseExceptionHandler(app => { @@ -244,6 +250,7 @@ public partial class DuplicatiWebserver public Task Start(InitSettings settings) { + App.MapHealthChecks("/health"); App.AddEndpoints() .UseNotifications("/notifications"); diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs new file mode 100644 index 000000000..00e892369 --- /dev/null +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs @@ -0,0 +1,165 @@ +#if DEBUG +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; + } + +} +#endif \ No newline at end of file diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs index 91668f197..52117cf59 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs @@ -1,5 +1,7 @@ +using System.Text; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Net.Http.Headers; namespace Duplicati.WebserverCore.Middlewares; @@ -27,9 +29,111 @@ 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)); + } +#if DEBUG + else + { + // 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)); + } +#endif + } + + 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; +#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 + { + // 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 +147,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; diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index 701c1fb24..090fca864 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -491,6 +491,10 @@ public static partial class Command // 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 var sourceBuildMap = new Dictionary> { { InterfaceType.GUI, sourceProjects @@ -623,6 +627,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))) @@ -756,6 +764,64 @@ 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 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, List foldersToRemove, 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); + foldersToRemove.Add(target); + } + + return deleted.Where(x => !Path.GetFileName(x).StartsWith(".")) + .ToList(); + } + + /// /// 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") ); }