// Copyright (C) 2025, 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. #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()); // Handle move not working across drive boundaries on Windows if (OperatingSystem.IsWindows() && Path.GetPathRoot(tempPath) != Path.GetPathRoot(targetPath)) { tempPath = Path.GetFullPath(targetPath); if (targetPath.EndsWith(Path.DirectorySeparatorChar)) tempPath = tempPath[..-1]; tempPath += "-tmp"; if (Directory.Exists(tempPath)) Directory.Delete(tempPath, true); } 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.GetFullPath(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